Files
DarkflameServer/dDashboardServer/templates/network.jinja2
Aaron Kimbrell 3f0e9bc336 fix(dashboard): the Network diagram keeps its width on phones and scrolls sideways
Six columns squeezed into a phone's width drew the boxes over each other; the diagram now keeps at least
1100 px and its card scrolls, with the lists and tooltips following the scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 02:51:21 -05:00

895 lines
53 KiB
Django/Jinja

{% extends "base.jinja2" %}
{% block title %}Network - DarkflameServer{% endblock %}
{% block css %}
<style>
/* Load of a link against the busiest link right now */
.nf { --nf-idle: var(--bs-secondary-color); --nf-low: var(--bs-info); --nf-mid: var(--bs-warning); --nf-high: var(--bs-danger); }
/* Narrow screens (phones): the diagram keeps a readable width and the box scrolls sideways */
.nf-box { position: relative; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.nf-svg { display: block; max-width: none; }
.nf-svg .lane { fill: none; stroke-linecap: round; opacity: .55; }
.nf-svg .flow { fill: none; stroke-linecap: round; stroke-dasharray: 2 10; animation: nf-dash linear infinite; }
.nf-svg .estimated .lane { stroke-dasharray: 6 5; }
.nf-svg .load-idle { stroke: var(--nf-idle); }
.nf-svg .load-low { stroke: var(--nf-low); }
.nf-svg .load-mid { stroke: var(--nf-mid); }
.nf-svg .load-high { stroke: var(--nf-high); }
.nf-svg .edge .hit { fill: none; stroke: transparent; stroke-width: 16; cursor: help; }
/* A link between two machines: dash-dot lanes and a diamond half way */
.nf-svg .cross .lane { stroke-dasharray: 10 3 2 3; opacity: .7; }
.nf-svg .cross-mark { fill: var(--bs-body-bg); stroke: var(--bs-emphasis-color); stroke-width: 1.5; pointer-events: none; }
/* A machine's frame (only when the servers run on more than one) */
.nf-svg .frame rect.band { fill: var(--bs-tertiary-bg); stroke: var(--bs-border-color); stroke-width: 1; stroke-dasharray: 5 4; }
.nf-svg .frame.master rect.band { stroke: var(--bs-secondary-color); }
.nf-svg .frame .fl { fill: var(--bs-secondary-color); font-size: 11px; }
.nf-svg .frame .fl .fn { fill: var(--bs-body-color); font-weight: 600; }
.nf-svg .frame .ft { cursor: pointer; }
.nf-svg .frame .ft rect { fill: var(--bs-secondary-bg); stroke: var(--bs-border-color); stroke-width: 1; }
.nf-svg .frame .ft:hover rect, .nf-svg .frame .ft:focus rect { fill: var(--bs-primary); }
.nf-svg .frame .ft text { fill: var(--bs-body-color); font-size: 11px; font-weight: 600; }
.nf-svg .frame .ft:hover text, .nf-svg .frame .ft:focus text { fill: var(--bs-white); }
.nf-svg .frame.enter { animation: nf-in .28s ease; }
.nf-svg .node rect { fill: var(--bs-body-bg); stroke: var(--bs-border-color); stroke-width: 1.5; }
.nf-svg .node { cursor: grab; touch-action: none; }
.nf-svg.dragging, .nf-svg.dragging .node { cursor: grabbing; }
.nf-svg .node:hover rect, .nf-svg .node:focus rect { stroke: var(--bs-primary); }
.nf-svg .node.selected rect { stroke: var(--bs-primary); stroke-width: 2.5; }
.nf-svg .node.gone rect { stroke-dasharray: 4 3; }
.nf-svg .node .name { fill: var(--bs-body-color); font-size: 12px; font-weight: 600; }
.nf-svg .node .meta { fill: var(--bs-secondary-color); font-size: 11px; }
.nf-svg .node .warn { fill: var(--bs-warning-text-emphasis); }
.nf-svg .node .toggle { cursor: pointer; }
.nf-svg .node .toggle rect { fill: var(--bs-primary-bg-subtle); stroke: var(--bs-primary-border-subtle); stroke-width: 1; }
.nf-svg .node .toggle:hover rect { fill: var(--bs-primary); }
.nf-svg .node .toggle text { fill: var(--bs-primary-text-emphasis); font-size: 11px; font-weight: 600; }
.nf-svg .node .toggle:hover text { fill: var(--bs-white); }
.nf-svg .node.anim { transition: transform .28s ease; }
.nf-svg .node rect.box { transform-box: fill-box; transform-origin: top; }
.nf-svg .enter { animation: nf-in .28s ease; }
.nf-svg.settling .edge { animation: nf-in .35s ease; }
@keyframes nf-in { from { opacity: 0; } }
.nf-svg.paused .flow { animation-play-state: paused; }
/* An open group's filter and rows: HTML laid over its box (they outlive the diagram's redraws) */
.nf-layer { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; }
.nf-group { position: absolute; }
.nf-group.anim { transition: transform .28s ease; }
.nf-group.enter { animation: nf-in .28s ease; }
.nf-group.leaving { opacity: 0; transition: opacity .2s ease; pointer-events: none; }
.nf-group .nf-filter { height: 24px; margin-bottom: 6px; padding: 0 .4rem; font-size: .75rem; min-height: 0; }
.nf-rows { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.nf-member { box-sizing: border-box; padding: 0 5px; border-radius: 4px; cursor: pointer; display: flex; flex-direction: column; justify-content: center; line-height: 1.2; overflow: hidden; }
.nf-member .nm, .nf-member .mt { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nf-member .nm { font-size: 12px; font-weight: 600; color: var(--bs-body-color); }
.nf-member .mt { font-size: 10.5px; color: var(--bs-secondary-color); }
.nf-member:hover, .nf-member:focus { background: var(--bs-tertiary-bg); outline: none; }
.nf-member.active { background: var(--bs-primary-bg-subtle); }
.nf-none { font-size: .75rem; color: var(--bs-secondary-color); padding: 0 5px; display: flex; align-items: center; }
.nf-stack .nf-members { max-height: 24rem; overflow-y: auto; overscroll-behavior: contain; border-left: 2px solid var(--bs-border-color); margin: .35rem 0 0 .25rem; padding-left: .5rem; }
.nf-stack .nf-members .nf-row { padding: .35rem .25rem; }
@media (prefers-reduced-motion: reduce) { .nf-svg .flow, .nf-svg .enter, .nf-svg.settling .edge, .nf-group.enter { animation: none; } .nf-svg .node.anim, .nf-group.anim, .nf-group.leaving { transition: none; } }
@keyframes nf-dash { to { stroke-dashoffset: -24; } }
.nf-tip { position: absolute; pointer-events: none; z-index: 5; background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: .375rem;
padding: .35rem .5rem; font-size: .75rem; box-shadow: var(--bs-box-shadow-sm); max-width: 320px; }
.nf-legend { font-size: .75rem; color: var(--bs-secondary-color); }
.nf-legend .key { display: inline-block; width: 14px; height: 4px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.nf-table td, .nf-table th { font-variant-numeric: tabular-nums; }
.nf-table tr[data-peer] { cursor: pointer; }
.nf-spark { width: 100%; height: 60px; display: block; }
.nf-spark path { fill: none; stroke-width: 1.5; }
.nf-stack .nf-row { border-bottom: 1px solid var(--bs-border-color); padding: .5rem .75rem; }
.nf-stack .nf-row:last-child { border-bottom: 0; }
.nf-bar { height: 4px; border-radius: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="nf">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-2 mb-3">
<div>
<h2 class="mb-1">Network</h2>
<p class="text-body-secondary mb-0 small">What goes between the players, the servers and web clients right now: each line is a link, one lane each way,
as thick as its bytes per second and coloured by its load against the busiest link right now. Drag the boxes to arrange them; open a group (game clients, web clients, a zone's instances) with its ▸ button or a double click to list its members in the box, each with its own links, and filter them. Boxes show the port each server listens on; when the servers run on more than one machine, each machine's boxes are framed together (fold a frame with its ▾) and links between machines are dash-dotted with a ◇. The layout, open groups and folded machines are kept in this browser. Rates are over each server's last report (5 seconds).
Hover a line for its numbers, click a box for its details. History is on <a href="/diagnostics">Diagnostics</a>.</p>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="small text-body-secondary" id="nfUpdated" aria-live="polite"></span>
<div class="btn-group btn-group-sm" role="group" aria-label="View">
<input type="radio" class="btn-check" name="nfView" id="nfViewDiagram" value="diagram" checked data-pref="network.view"><label class="btn btn-outline-secondary" for="nfViewDiagram">Diagram</label>
<input type="radio" class="btn-check" name="nfView" id="nfViewList" value="list" data-pref="network.view"><label class="btn btn-outline-secondary" for="nfViewList">List</label>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-xxl-9">
<div class="card">
<div class="card-header py-2 d-flex flex-wrap justify-content-between gap-2">
<h5 class="mb-0 fs-6">Traffic flow <button type="button" class="btn btn-link btn-sm p-0 ms-2 align-baseline" id="nfResetLayout" title="Put every box back where it started">Reset layout</button></h5>
<span class="nf-legend">
<span class="me-2 text-nowrap"><span class="key" style="background:var(--nf-low)"></span>light</span>
<span class="me-2 text-nowrap"><span class="key" style="background:var(--nf-mid)"></span>busy</span>
<span class="me-2 text-nowrap"><span class="key" style="background:var(--nf-high)"></span>busiest</span>
<span class="me-2 text-nowrap"><span class="key" style="background:repeating-linear-gradient(90deg,var(--nf-idle) 0 5px,transparent 5px 8px)"></span>estimated (worked out from totals)</span>
<span class="me-2 text-nowrap d-none" id="nfCrossKey"><span class="key" style="background:repeating-linear-gradient(90deg,var(--bs-emphasis-color) 0 7px,transparent 7px 9px,var(--bs-emphasis-color) 9px 10px,transparent 10px 12px)"></span>◇ between machines</span>
</span>
</div>
<div class="card-body p-2 nf-box" id="nfBox">
<p class="text-body-secondary p-3 mb-0" id="nfEmpty">Waiting for the servers' traffic reports (every 5 seconds through master)…</p>
<svg class="nf-svg d-none" id="nfSvg" role="img" aria-label="Traffic between clients and servers"></svg>
<div class="nf-layer" id="nfLayer"></div>
<div class="nf-stack d-none" id="nfStack"></div>
</div>
</div>
</div>
<div class="col-xxl-3">
<div class="card" id="nfDetail"><div class="card-header py-2"><h5 class="mb-0 fs-6" id="nfDetailTitle">Details</h5></div>
<div class="card-body small" id="nfDetailBody"><p class="text-body-secondary mb-0">Click a box in the diagram to see its busiest message types and its last 10 minutes.</p></div></div>
</div>
</div>
<div class="card mt-3">
<div class="card-header py-2 d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0 fs-6">Connections <span class="text-body-secondary fw-normal small" id="nfPeersNote"></span></h5>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="search" class="form-control form-control-sm w-auto" id="nfPeerSearch" placeholder="Filter" aria-label="Filter connections">
<div class="btn-group btn-group-sm" role="group" aria-label="Kind">
<input type="radio" class="btn-check" name="nfKind" id="nfKindAll" value="" checked><label class="btn btn-outline-secondary" for="nfKindAll">All</label>
<input type="radio" class="btn-check" name="nfKind" id="nfKindGame" value="game"><label class="btn btn-outline-secondary" for="nfKindGame">Game clients</label>
<input type="radio" class="btn-check" name="nfKind" id="nfKindWeb" value="web"><label class="btn btn-outline-secondary" for="nfKindWeb">Web clients</label>
<input type="radio" class="btn-check" name="nfKind" id="nfKindServer" value="server"><label class="btn btn-outline-secondary" for="nfKindServer">Servers</label>
</div>
</div>
</div>
<div class="card-body p-0 table-responsive" id="nfPeers"><p class="text-body-secondary p-3 mb-0">Loading…</p></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/network-graph.js"></script>
<script>
(function () {
var G = window.NetworkGraph;
var nf = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
var PEAK_SECONDS = 300;
var summary = null, peers = null, selected = null, detail = null;
var expanded = {}, clientsExpanded = false, webExpanded = false, kind = '', search = '';
// Machines folded into one box (only when the servers run on more than one), by host token
var collapsedHosts = {};
// Open groups' filter text and how far their lists are scrolled (layout pixels), by group id; kept while the page is
// open, across live updates
var filters = {}, scrolls = {};
try {
var open = JSON.parse(localStorage.getItem('network.open') || '{}') || {};
expanded = open.zones || {}; clientsExpanded = !!open.clients; webExpanded = !!open.web; collapsedHosts = open.hosts || {};
} catch (e) { /* private window */ }
function saveOpen() { try { localStorage.setItem('network.open', JSON.stringify({ zones: expanded, clients: clientsExpanded, web: webExpanded, hosts: collapsedHosts })); } catch (e) { /* ignore */ } }
var animateNext = false;
var view = (document.querySelector('input[name="nfView"]:checked') || {}).value || 'diagram';
var history = {}; // edge id -> [[time, value]] for the load colours
var svg = document.getElementById('nfSvg'), box = document.getElementById('nfBox'), stack = document.getElementById('nfStack'), layer = document.getElementById('nfLayer');
var reducedMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
function bytes(v) {
if (v >= 1073741824) return (v / 1073741824).toFixed(2) + ' GB';
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB';
if (v >= 1024) return (v / 1024).toFixed(1) + ' kB';
return Math.round(v) + ' B';
}
function rateText(side, e, known) {
var unit = e.kind === 'http' || e.kind === 'peer-http' ? ' req/s' : e.kind === 'peer' ? ' datagrams/s' : ' pkt/s';
return nf.format(side.packets) + unit + (known ? ', ' + bytes(side.bytes) + '/s' : '');
}
function edgeValue(e) { return e.fwd.bytes + e.back.bytes || e.fwd.packets + e.back.packets; }
function labelOf(id) {
var n = graphNodes[id] || (graph && graph.members[id]);
if (!n) return id;
var owner = n.group && graphNodes[n.group];
return n.kind === 'world' ? n.title + (n.port ? ' ' + G.portLabel(n.port) : '') : owner && owner.kind === 'web' ? memberName(n) : n.group ? n.label : G.nameOf(n);
}
// A machine's name for this viewer: its address with the Network addresses permission, else its token
function hostName(host) { return G.hostLabel(host, peers && peers.hosts); }
// Keeps each link's value for PEAK_SECONDS, to colour it by its load against its own recent peak
function remember(graph, now) {
graph.edges.forEach(function (e) {
var list = history[e.id] = history[e.id] || [];
list.push([now, edgeValue(e)]);
while (list.length && list[0][0] < now - PEAK_SECONDS) list.shift();
});
}
function peakOf(id) { return (history[id] || []).reduce(function (m, p) { return Math.max(m, p[1]); }, 0); }
var graphNodes = {}, graph = null;
function render() {
if (!summary) return;
graph = G.build(summary, { expanded: expanded, clientsExpanded: clientsExpanded, webExpanded: webExpanded, peers: peers ? peers.peers : null,
addressesShown: !!(peers && peers.addresses_shown), filters: filters, collapsedHosts: collapsedHosts, hostNames: peers && peers.hosts });
document.getElementById('nfCrossKey').classList.toggle('d-none', graph.hosts.length < 2);
graphNodes = {};
graph.nodes.forEach(function (n) { graphNodes[n.id] = n; });
var hasServers = Object.keys(summary.servers || {}).length > 0;
document.getElementById('nfEmpty').classList.toggle('d-none', hasServers);
var list = view === 'list';
svg.classList.toggle('d-none', !hasServers || list);
stack.classList.toggle('d-none', !hasServers || !list);
layer.classList.toggle('d-none', !hasServers || list);
if (!hasServers) return;
if (list) renderStack(); else renderDiagram(diagramWidth());
}
// The diagram is laid out at the width it is drawn at, so the boxes and the HTML over them line up
function diagramWidth() {
var cs = getComputedStyle(box);
// Six columns need about 1100 px; narrower than that the diagram keeps 1100 and scrolls sideways instead of
// squeezing the boxes over each other
return Math.max(1100, Math.floor(box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight))) || 1100;
}
// Whether a group is open (asked for; a Game/Web clients box has its rows once the connection list is in)
function isOpen(n) { return n.kind === 'zone' ? !!expanded[n.zone] : n.kind === 'clients' ? clientsExpanded : n.kind === 'web' ? webExpanded : false; }
function canToggle(n) { return n.kind === 'zone' && (n.instances > 1 || isOpen(n)) || n.kind === 'clients' || n.kind === 'web' || n.kind === 'host'; }
function hostOf(id) { return (graph.hosts || []).filter(function (h) { return h.id === id; })[0]; }
// A machine's totals: "4 servers · 12 conn · 1.2 MB/s"
function hostTotals(h) {
return [nf.format(h.servers) + (h.servers === 1 ? ' server' : ' servers'), nf.format(h.connections) + ' conn', bytes(h.bytesIn + h.bytesOut) + '/s'].join(' · ');
}
function nodeMeta(n) {
var lines = [];
if (n.open) {
var noun = n.kind === 'zone' ? ' instances' : ' connections';
lines.push(n.shown.length !== n.members.length ? nf.format(n.shown.length) + ' of ' + nf.format(n.members.length) + noun : nf.format(n.members.length) + noun);
} else if (n.kind === 'clients') lines.push(nf.format(n.connections) + ' connected' + (isOpen(n) ? ' · loading…' : ''));
else if (n.kind === 'web') lines.push(isOpen(n) ? 'loading…' : n.websockets ? nf.format(n.websockets) + ' live pages' : 'browsers, API');
else if (n.kind === 'host') {
var h = hostOf(n.host);
lines.push(h ? hostTotals(h) : nf.format(n.servers.length) + ' servers');
lines.push('machine, folded');
} else {
var parts = [];
if (n.instances > 1) parts.push(n.instances + ' instances');
if (n.connections) parts.push(nf.format(n.connections) + ' conn');
if (n.ping != null) parts.push(n.ping + ' ms');
lines.push(parts.join(' · '));
var more = [];
if (n.workersThreads) more.push('workers ' + nf.format(n.workersBusy) + '/' + nf.format(n.workersThreads) + (n.workersQueued ? ' +' + nf.format(n.workersQueued) : ''));
if (n.websockets) more.push(nf.format(n.websockets) + ' ws');
if (n.resends) more.push(nf.format(n.resends) + ' resent');
if (more.length) lines.push(more.join(' · '));
}
return lines.filter(Boolean);
}
// A member row's name and the line under it: an instance, a player, or a web client (its dashboard user)
function ugcOnly(p) { return (p.servers || []).length > 0 && p.servers.every(function (e) { return (summary.servers[e.server] || {}).type === 'UGC'; }); }
function memberName(m) {
if (m.kind === 'world') return m.label;
var p = m.peer;
if (p.kind !== 'web' || ugcOnly(p)) return m.label;
return p.user || (p.account_id ? 'account ' + p.account_id : 'not signed in');
}
function memberMeta(m) {
var parts = [];
if (m.kind === 'world') {
if (m.port) parts.push(G.portLabel(m.port));
if (m.connections) parts.push(nf.format(m.connections) + ' conn');
if (m.ping != null) parts.push(m.ping + ' ms');
if (m.resends) parts.push(nf.format(m.resends) + ' resent');
return parts.join(' · ');
}
var p = m.peer;
if (p.kind === 'web') {
if (ugcOnly(p)) parts.push('UGC files');
else parts.push(p.address);
} else {
if (p.character && p.account) parts.push(p.account);
if (m.ping != null) parts.push(m.ping + ' ms');
parts.push(G.peerAddress(p)); // remote ports only come with the Network addresses permission
}
return parts.join(' · ');
}
function memberTitle(m) {
if (m.kind === 'world') return m.title + (m.port ? ' ' + G.portLabel(m.port) : '') + (m.host && graph.hosts.length > 1 ? '\non ' + hostName(m.host) : '');
var p = m.peer;
return [memberName(m), memberMeta(m)].concat((p.servers || []).map(function (e) { return e.label; })).join('\n');
}
// Boxes the viewer dragged, kept in this browser: {id: {fx, y}}
var moved = {}, lastLayout = null;
try { moved = JSON.parse(localStorage.getItem('network.positions') || '{}') || {}; } catch (e) { moved = {}; }
function saveMoved() { try { localStorage.setItem('network.positions', JSON.stringify(moved)); } catch (e) { /* private window */ } }
var busiest = 0;
// Splits text into at most `maxLines` lines of about `width` characters at spaces; the last line ends with … if cut
function wrapText(text, width, maxLines) {
var words = String(text).split(/\s+/), lines = [], line = '';
words.forEach(function (word) {
if (!line) line = word;
else if ((line + ' ' + word).length <= width) line += ' ' + word;
else { lines.push(line); line = word; }
});
if (line) lines.push(line);
lines = lines.map(function (l) { return l.length > width ? l.slice(0, width - 1) + '…' : l; });
if (lines.length > maxLines) {
var last = lines.slice(maxLines - 1).join(' ');
lines = lines.slice(0, maxLines - 1).concat([last.length > width ? last.slice(0, width - 1) + '…' : last]);
}
return lines;
}
function renderDiagram(width) {
var before = animateNext && lastLayout ? lastLayout.nodes : null;
animateNext = false;
var L = G.layout(graph, width, moved, scrolls);
lastLayout = L;
busiest = graph.edges.reduce(function (m, e) { return Math.max(m, edgeValue(e)); }, 0);
var html = '';
// Machines: a frame around each one's boxes, its name and totals above them, ▾ to fold it into one box
(graph.hosts || []).forEach(function (h) {
var f = L.frames[h.id];
if (!f) return;
var label = h.collapsed ? '▸' : '▾', tw = 22, tx = f.x + f.w - tw - 6, ty = f.y + 5;
var chars = Math.max(10, Math.floor((f.w - tw - 24) / 6.2));
var text = (h.master ? 'Master\'s machine ' : 'Machine ') + hostName(h.id);
var totals = hostTotals(h);
var line = text + ' · ' + totals;
if (line.length > chars) line = line.length - totals.length - 3 <= chars ? text : text.slice(0, chars - 1) + '…';
html += '<g class="frame' + (h.master ? ' master' : '') + (before && !lastFrames[h.id] ? ' enter' : '') + '" data-frame="' + esc(h.id) + '">' +
'<rect class="band" x="' + f.x + '" y="' + f.y + '" width="' + f.w + '" height="' + f.h + '" rx="10"/>' +
'<text class="fl" x="' + (f.x + 10) + '" y="' + (f.y + 17) + '"><title>' + esc(text + '\n' + totals) + '</title><tspan class="fn">' + esc(line.slice(0, text.length)) + '</tspan>' + esc(line.slice(text.length)) + '</text>' +
'<g class="ft" data-host-toggle="' + esc(h.id) + '" role="button" tabindex="0" aria-expanded="' + !h.collapsed + '" aria-label="' + esc((h.collapsed ? 'Unfold ' : 'Fold ') + text) + '"><title>' + (h.collapsed ? 'Show this machine\'s servers' : 'Fold this machine into one box') + '</title>' +
'<rect x="' + tx + '" y="' + ty + '" width="' + tw + '" height="16" rx="8"/><text x="' + (tx + tw / 2) + '" y="' + (ty + 12) + '" text-anchor="middle">' + label + '</text></g></g>';
});
lastFrames = L.frames;
graph.edges.forEach(function (e) {
var p = L.paths[e.id];
if (!p) return; // a row scrolled out of view
var load = G.loadClass(edgeValue(e), busiest);
var lanes = [['fwd', e.fwd, e.bytesKnown.fwd], ['back', e.back, e.bytesKnown.back]];
html += '<g class="edge' + (e.exact ? '' : ' estimated') + (e.cross ? ' cross' : '') + '" data-edge="' + esc(e.id) + '">';
lanes.forEach(function (lane) {
var side = lane[1], value = lane[2] ? side.bytes : side.packets * 200;
var w = G.width(value), dur = G.speed(side.packets);
html += '<path class="lane load-' + (value > 0 ? load : 'idle') + '" stroke-width="' + w + '" d="' + p[lane[0]] + '"/>';
if (dur) html += '<path class="flow load-' + load + '" stroke-width="' + Math.max(1.5, w * 0.6) + '" style="animation-duration:' + dur + 's" d="' + p[lane[0]] + '"/>';
});
if (p.mid) html += '<path class="cross-mark" d="M' + p.mid.x + ',' + (p.mid.y - 6) + ' l6,6 l-6,6 l-6,-6 z"/>';
html += '<path class="hit" d="' + p.fwd + '"/></g>';
});
graph.nodes.forEach(function (n) {
var at = L.nodes[n.id];
var x = at.x - L.nodeWidth / 2, y = at.y - L.nodeHeight / 2;
var meta = nodeMeta(n);
if (n.open) meta = meta.slice(0, 1); // the header has room for one line
var chars = Math.floor((L.nodeWidth - 16) / 6.6);
var toggle = canToggle(n), open = isOpen(n);
var nameChars = toggle ? chars - 5 : chars;
var nameLines = wrapText(G.nameOf(n), nameChars, 2);
html += '<g class="node' + (selected === n.id ? ' selected' : '') + '" data-node="' + esc(n.id) + '" tabindex="0" role="button" aria-label="' + esc(G.nameOf(n)) + '"' + (toggle ? ' aria-expanded="' + open + '"' : '') + '>' +
'<rect class="box" x="' + x + '" y="' + y + '" width="' + L.nodeWidth + '" height="' + at.h + '" rx="8"/>' +
'<title>' + esc(boxTitle(n)) + '</title>' +
nameLines.map(function (line, i) { return '<text class="name" x="' + (x + 8) + '" y="' + (y + 17 + i * 14) + '">' + esc(line) + '</text>'; }).join('');
var metaTop = y + 32 + (nameLines.length - 1) * 14;
if (nameLines.length > 1 && !n.open) meta = meta.slice(0, 2);
meta.forEach(function (line, i) {
html += '<text class="meta' + (/resent/.test(line) ? ' warn' : '') + '" x="' + (x + 8) + '" y="' + (metaTop + i * 13) + '">' + esc(line.length > chars + 4 ? line.slice(0, chars + 3) + '…' : line) + '</text>';
});
if (n.noSplit && !toggle) html += '<text class="meta warn" x="' + (x + L.nodeWidth - 8) + '" y="' + (y + 17) + '" text-anchor="end"><title>This server reports only totals: its links are estimated</title>≈</text>';
else if (toggle) {
var pill = n.kind === 'host' ? '▸ ' + n.servers.length : open ? '▾' : n.kind === 'zone' ? '▸ ' + n.instances : '▸ each';
var pw = Math.max(22, pill.length * 6.2 + 12), px = x + L.nodeWidth - pw - 6, py = y + 5;
var tipText = n.kind === 'host' ? 'Show this machine\'s servers' : open ? 'Close the list' : (n.kind === 'zone' ? 'List each instance in the box' : 'List each connection in the box');
html += '<g class="toggle" data-toggle="' + esc(n.id) + '" role="button" aria-label="' + esc(tipText) + '"><title>' + esc(tipText) + '</title>' +
'<rect x="' + px + '" y="' + py + '" width="' + pw + '" height="16" rx="8"/>' +
'<text x="' + (px + pw / 2) + '" y="' + (py + 12) + '" text-anchor="middle">' + esc(pill) + '</text></g>';
}
html += '</g>';
});
svg.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height);
svg.setAttribute('width', L.width);
svg.setAttribute('height', L.height);
svg.innerHTML = html;
var fresh = renderLayer(L);
if (before) glide(before, L, fresh);
}
// A box's tooltip: its name with its port, and the machine it runs on
function boxTitle(n) {
var hosts = [];
n.servers.forEach(function (k) { var h = (summary.servers[k] || {}).host; if (h && hosts.indexOf(h) === -1) hosts.push(h); });
return G.nameOf(n) + (hosts.length ? '\non ' + hosts.map(hostName).join(', ') : '');
}
var lastFrames = {};
// The open groups' filters and rows, over their boxes. Each panel, its filter and its rows are made once and then
// only moved and updated, so a live update keeps what is typed, the focus and the scroll position.
var panels = {}; // group id -> {el, input, rows, items: {memberId: element}}
var scrollFrame = 0;
function renderLayer(L) {
var k = (svg.getBoundingClientRect().width / L.width) || 1;
// The layer sits at the top left of the box's padding box
var sr = svg.getBoundingClientRect(), br = box.getBoundingClientRect();
var ox = sr.left - br.left - box.clientLeft + box.scrollLeft, oy = sr.top - br.top - box.clientTop + box.scrollTop, fresh = {}; // the box scrolls sideways on narrow screens
graph.nodes.forEach(function (n) {
if (!n.open) return;
var at = L.nodes[n.id], list = L.lists[n.id];
var panel = panels[n.id];
if (!panel) {
panel = panels[n.id] = makePanel(n);
fresh[n.id] = true;
}
var el = panel.el;
el.classList.remove('leaving');
el.style.left = (ox + (at.x - L.nodeWidth / 2 + 5) * k) + 'px';
el.style.top = (oy + (at.y - L.nodeHeight / 2 + G.GROUP.head) * k) + 'px';
el.style.width = ((L.nodeWidth - 10) * k) + 'px';
panel.rows.style.height = (list.height * k) + 'px';
fillRows(panel, n, G.GROUP.row * k, 'nf-member');
// Where the layout has the list scrolled to (a list shown again after the List view starts at its top)
if (Math.abs(panel.rows.scrollTop - list.scroll * k) > 1) panel.rows.scrollTop = list.scroll * k;
panel.k = k;
});
Object.keys(panels).forEach(function (id) {
var n = graphNodes[id];
if (n && n.open) return;
closePanel(panels, id);
});
return fresh;
}
function makePanel(n) {
var el = document.createElement('div');
el.className = 'nf-group';
el.setAttribute('data-group', n.id);
var input = document.createElement('input');
input.type = 'search';
input.className = 'form-control form-control-sm nf-filter';
input.placeholder = n.kind === 'zone' ? 'Filter instances' : 'Filter';
input.setAttribute('aria-label', 'Filter ' + n.label);
input.value = filters[n.id] || '';
input.addEventListener('input', function () { setFilter(n.id, input.value); });
var rows = document.createElement('div');
rows.className = 'nf-rows';
rows.setAttribute('role', 'list');
rows.setAttribute('aria-label', n.label);
rows.addEventListener('scroll', function () {
var panel = panels[n.id];
scrolls[n.id] = rows.scrollTop / ((panel && panel.k) || 1);
// The rows' links follow the scroll, once a frame
if (scrollFrame) return;
scrollFrame = requestAnimationFrame(function () { scrollFrame = 0; if (graph && view !== 'list') renderDiagram(lastLayout ? lastLayout.width : diagramWidth()); });
}, { passive: true });
el.appendChild(input);
el.appendChild(rows);
layer.appendChild(el);
return { el: el, input: input, rows: rows, items: {} };
}
function closePanel(set, id) {
var panel = set[id];
delete set[id];
if (reducedMotion.matches || !panel.el.classList.contains('nf-group')) { panel.el.remove(); return; }
panel.el.classList.add('leaving');
setTimeout(function () { panel.el.remove(); }, 220);
}
// A filter typed in either view: both show it; the list starts from its top again
function setFilter(id, value) {
filters[id] = value;
scrolls[id] = 0;
[panels[id], stackPanels[id]].forEach(function (p) {
if (!p) return;
if (p.input.value !== value) p.input.value = value;
p.rows.scrollTop = 0;
});
render();
}
// The group's shown members as rows, keyed by member id: kept, updated in place and put in order
function fillRows(panel, n, rowHeight, className, extra) {
var keep = {};
n.shown.forEach(function (id, i) {
var m = graph.members[id];
keep[id] = true;
var row = panel.items[id];
if (!row) {
row = panel.items[id] = document.createElement('div');
row.className = className;
row.setAttribute('data-member', id);
row.setAttribute('role', 'listitem');
row.tabIndex = 0;
row.innerHTML = '<div class="nm"></div><div class="mt"></div>' + (extra ? '<div class="nf-links"></div>' : '');
}
if (rowHeight) row.style.height = rowHeight + 'px';
setText(row.children[0], memberName(m));
setText(row.children[1], memberMeta(m));
if (row.title !== memberTitle(m)) row.title = memberTitle(m);
row.classList.toggle('active', selected === id);
if (extra) { var html = extra(m); if (row.children[2].innerHTML !== html) row.children[2].innerHTML = html; }
if (panel.rows.children[i] !== row) panel.rows.insertBefore(row, panel.rows.children[i] || null);
});
Object.keys(panel.items).forEach(function (id) { if (!keep[id]) { panel.items[id].remove(); delete panel.items[id]; } });
// Nothing to list: one line saying why
var none = panel.rows.querySelector('.nf-none');
if (!n.shown.length) {
if (!none) { none = document.createElement('div'); none.className = 'nf-none'; panel.rows.appendChild(none); }
if (rowHeight) none.style.height = rowHeight + 'px';
setText(none, n.members.length ? 'Nothing matches the filter' : 'Nothing connected');
} else if (none) none.remove();
}
function setText(el, text) { if (el.textContent !== text) el.textContent = text; }
// After opening or closing a group: boxes glide from where they were, a box grows or shrinks to its list, new ones
// and links fade in
function glide(before, L, fresh) {
svg.classList.remove('settling');
void svg.getBoundingClientRect();
svg.classList.add('settling');
setTimeout(function () { svg.classList.remove('settling'); }, 400);
var move = function (el, from, now) {
el.style.transform = 'translate(' + (from.x - now.x) + 'px,' + (from.y - now.y) + 'px)';
requestAnimationFrame(function () {
el.classList.add('anim');
el.style.transform = '';
setTimeout(function () { el.classList.remove('anim'); }, 320);
});
};
svg.querySelectorAll('.node').forEach(function (g) {
var id = g.getAttribute('data-node'), now = L.nodes[id], from = before[id];
if (!now) return;
if (!from) { g.classList.add('enter'); return; }
if (from.h !== now.h && !reducedMotion.matches && g.firstChild && g.firstChild.animate) {
try { g.firstChild.animate([{ transform: 'scaleY(' + (from.h / now.h) + ')' }, { transform: 'none' }], { duration: 280, easing: 'ease' }); } catch (e) { /* older browser: no grow */ }
}
if (from.x !== now.x || from.y !== now.y) {
move(g, from, now);
var panel = panels[id];
if (panel && !fresh[id]) move(panel.el, from, now);
}
});
Object.keys(fresh).forEach(function (id) { if (panels[id]) panels[id].el.classList.add('enter'); });
}
// Phones: every box with its links, busiest first; an open group lists its members under it with the same filter.
// Boxes, filters and member rows are kept and updated in place, like the diagram's.
var stackItems = {}, stackPanels = {};
function linkLines(id, max) {
var links = graph.edges.filter(function (e) { return e.from === id; }).sort(function (a, b) { return edgeValue(b) - edgeValue(a); });
return links.map(function (e) {
var load = G.loadClass(edgeValue(e), busiest);
return '<div class="small mt-1">→ ' + esc(labelOf(e.to)) + (e.exact ? '' : ' <span class="badge text-bg-secondary">estimated</span>') +
'<div class="text-body-secondary">out ' + esc(rateText(e.fwd, e, e.bytesKnown.fwd)) + ' · back ' + esc(rateText(e.back, e, e.bytesKnown.back)) + '</div>' +
'<div class="nf-bar" style="width:' + Math.max(2, edgeValue(e) / max * 100).toFixed(1) + '%;background:var(--nf-' + load + ')"></div></div>';
}).join('');
}
function renderStack() {
busiest = graph.edges.reduce(function (m, e) { return Math.max(m, edgeValue(e)); }, 0);
var max = busiest || 1;
var keep = {};
graph.nodes.forEach(function (n, i) {
keep[n.id] = true;
var item = stackItems[n.id];
if (!item) {
item = stackItems[n.id] = document.createElement('div');
item.className = 'nf-row';
item.setAttribute('data-node', n.id);
item.innerHTML = '<div class="nf-head" role="button" tabindex="0"></div><div class="nf-links"></div>';
}
var toggle = canToggle(n), open = isOpen(n);
var machines = [];
n.servers.forEach(function (k) { var h = (summary.servers[k] || {}).host; if (h && machines.indexOf(h) === -1) machines.push(h); });
var where = machines.length ? '<div class="small text-body-secondary">on ' + esc(machines.map(hostName).join(', ')) + '</div>' : '';
var head = '<div class="d-flex justify-content-between gap-2"><div><b>' + esc(G.nameOf(n)) + '</b>' + where + '</div><span class="small text-body-secondary text-end">' + esc(nodeMeta(n).join(' · ')) +
(toggle ? ' <button type="button" class="btn btn-sm btn-outline-primary py-0 px-1 ms-1" data-toggle="' + esc(n.id) + '" aria-expanded="' + open + '">' + (open ? '▾' : '▸') + '</button>' : '') + '</span></div>';
if (item.children[0].innerHTML !== head) item.children[0].innerHTML = head;
var links = linkLines(n.id, max);
if (item.children[1].innerHTML !== links) item.children[1].innerHTML = links;
// An open group's members, with their filter
var panel = stackPanels[n.id];
if (n.open) {
if (!panel) {
panel = stackPanels[n.id] = { el: document.createElement('div'), items: {} };
panel.input = document.createElement('input');
panel.input.type = 'search';
panel.input.className = 'form-control form-control-sm mt-2';
panel.input.placeholder = n.kind === 'zone' ? 'Filter instances' : 'Filter';
panel.input.setAttribute('aria-label', 'Filter ' + n.label);
panel.input.value = filters[n.id] || '';
panel.input.addEventListener('input', function () { setFilter(n.id, panel.input.value); });
panel.rows = document.createElement('div');
panel.rows.className = 'nf-members';
panel.rows.setAttribute('role', 'list');
panel.el.appendChild(panel.input);
panel.el.appendChild(panel.rows);
item.appendChild(panel.el);
}
fillRows(panel, n, 0, 'nf-row nf-member', function (m) { return linkLines(m.id, max); });
} else if (panel) {
panel.el.remove();
delete stackPanels[n.id];
}
if (stack.children[i] !== item) stack.insertBefore(item, stack.children[i] || null);
});
Object.keys(stackItems).forEach(function (id) {
if (keep[id]) return;
stackItems[id].remove();
delete stackItems[id];
delete stackPanels[id];
});
}
// Hover: a link's numbers each way
var tip = document.createElement('div');
tip.className = 'nf-tip d-none';
box.appendChild(tip);
svg.addEventListener('mousemove', function (ev) {
var g = ev.target.closest('[data-edge]');
if (!g || !graph) { tip.classList.add('d-none'); return; }
var e = graph.edges.filter(function (x) { return x.id === g.dataset.edge; })[0];
if (!e) return;
var peak = peakOf(e.id);
tip.innerHTML = '<div class="fw-semibold">' + esc(labelOf(e.from)) + ' ⇄ ' + esc(labelOf(e.to)) + '</div>' +
'<div>→ ' + esc(rateText(e.fwd, e, e.bytesKnown.fwd)) + '</div><div>← ' + esc(rateText(e.back, e, e.bytesKnown.back)) + '</div>' +
(peak ? '<div class="text-body-secondary">5 min peak: ' + esc(e.fwd.bytes + e.back.bytes ? bytes(peak) + '/s' : nf.format(peak) + '/s') + '</div>' : '') +
(e.exact ? '' : '<div class="text-warning-emphasis">Estimated</div>') +
(e.cross ? '<div>◇ between machines ' + esc(hostName(e.hosts[0])) + ' and ' + esc(hostName(e.hosts[1])) + '</div>' : '') +
e.notes.map(function (t) { return '<div class="text-body-secondary">' + esc(t) + '</div>'; }).join('') +
(e.kind === 'http' ? '<div class="text-body-secondary">HTTP: requests, and the bodies answered</div>' : '');
tip.classList.remove('d-none');
var r = box.getBoundingClientRect();
var x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 10;
if (x + tip.offsetWidth > r.width) x = ev.clientX - r.left - tip.offsetWidth - 14;
tip.style.left = (Math.max(0, x) + box.scrollLeft) + 'px';
tip.style.top = (y + box.scrollTop) + 'px';
});
svg.addEventListener('mouseleave', function () { tip.classList.add('d-none'); });
function toggleGroup(n) {
if (n.kind === 'zone') { if (expanded[n.zone]) delete expanded[n.zone]; else expanded[n.zone] = true; }
else if (n.kind === 'clients') { clientsExpanded = !clientsExpanded; if (clientsExpanded) loadPeers(); }
else if (n.kind === 'web') { webExpanded = !webExpanded; if (webExpanded) loadPeers(); }
else if (n.kind === 'host') return toggleHost(n.host);
else return false;
saveOpen();
animateNext = true;
render();
return true;
}
// Folds a machine into one box, or shows its servers again
function toggleHost(id) {
if (collapsedHosts[id]) delete collapsedHosts[id]; else collapsedHosts[id] = true;
saveOpen();
animateNext = true;
render();
return true;
}
function onNode(ev) {
if (ev.target.closest('input')) return;
var hostToggle = ev.target.closest('[data-host-toggle]');
if (hostToggle) { toggleHost(hostToggle.getAttribute('data-host-toggle')); return; }
var row = ev.target.closest('[data-member]');
if (row && graph && graph.members[row.getAttribute('data-member')]) { showMember(graph.members[row.getAttribute('data-member')]); return; }
var toggle = ev.target.closest('[data-toggle]');
var el = ev.target.closest('[data-node]');
if (!el || !graph) return;
var n = graphNodes[el.getAttribute('data-node')];
if (!n) return;
if (toggle) { toggleGroup(n); return; }
selected = n.id;
render();
showDetail(n);
}
function showMember(m) {
selected = m.id;
render();
if (m.kind === 'world') showDetail({ id: m.id, kind: 'world', label: m.title, servers: [m.server] });
else showDetail({ id: m.id, kind: 'peer', label: memberName(m), peer: m.peer, servers: [] });
}
// Dragging a box moves it (a short press is still a click)
var drag = null, justDragged = false;
function svgPoint(ev) {
var r = svg.getBoundingClientRect(), vb = svg.viewBox.baseVal;
return { x: (ev.clientX - r.left) * (vb.width / r.width), y: (ev.clientY - r.top) * (vb.height / r.height) };
}
svg.addEventListener('pointerdown', function (ev) {
var el = ev.target.closest('[data-node]');
if (!el || ev.button !== 0 || ev.target.closest('[data-toggle]') || !lastLayout) return;
var id = el.getAttribute('data-node'), at = lastLayout.nodes[id], p = svgPoint(ev);
if (!at) return;
drag = { id: id, dx: at.x - p.x, dy: at.y - p.y, startX: ev.clientX, startY: ev.clientY, moving: false, pointer: ev.pointerId };
});
svg.addEventListener('pointermove', function (ev) {
if (!drag || ev.pointerId !== drag.pointer) return;
if (!drag.moving && Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) < 5) return;
if (!drag.moving) { drag.moving = true; svg.classList.add('dragging'); try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ } }
var p = svgPoint(ev);
moved[drag.id] = { fx: (p.x + drag.dx) / lastLayout.width, y: p.y + drag.dy };
renderDiagram(lastLayout.width);
});
function endDrag(ev) {
if (!drag || (ev && ev.pointerId !== drag.pointer)) return;
if (drag.moving) { justDragged = true; saveMoved(); setTimeout(function () { justDragged = false; }, 0); }
svg.classList.remove('dragging');
drag = null;
}
svg.addEventListener('pointerup', endDrag);
svg.addEventListener('pointercancel', endDrag);
document.getElementById('nfResetLayout').addEventListener('click', function () { moved = {}; saveMoved(); animateNext = true; render(); });
svg.addEventListener('dblclick', function (ev) {
var el = ev.target.closest('[data-node]');
var n = el && graphNodes[el.getAttribute('data-node')];
if (!n || !canToggle(n)) return;
if (toggleGroup(n)) ev.preventDefault();
});
svg.addEventListener('click', function (ev) { if (justDragged) { ev.stopPropagation(); return; } onNode(ev); });
layer.addEventListener('click', onNode);
stack.addEventListener('click', onNode);
[svg, stack, layer].forEach(function (el) {
el.addEventListener('keydown', function (ev) {
if (ev.target.closest('input')) return;
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onNode(ev); }
});
});
function sparkline(series) {
var n = series.times.length, max = 1;
['packets_in', 'packets_out'].forEach(function (k) { series[k].forEach(function (v) { if (v > max) max = v; }); });
var path = function (values) {
return values.map(function (v, i) { return (i ? 'L' : 'M') + (i / Math.max(1, n - 1) * 300).toFixed(1) + ',' + (58 - v / max * 54).toFixed(1); }).join('');
};
return '<svg class="nf-spark" viewBox="0 0 300 60" preserveAspectRatio="none" role="img" aria-label="Packets per second over the last 10 minutes">' +
'<path d="' + path(series.packets_in) + '" style="stroke:var(--bs-primary)"/><path d="' + path(series.packets_out) + '" style="stroke:var(--nf-mid)"/></svg>' +
'<div class="nf-legend d-flex justify-content-between"><span><span class="key" style="background:var(--bs-primary)"></span>in <span class="key ms-2" style="background:var(--nf-mid)"></span>out (packets/s, 10 min)</span><span>peak ' + esc(nf.format(max)) + '/s</span></div>';
}
function messageTable(list, direction) {
var rows = list.filter(function (m) { return m.direction === direction; });
if (!rows.length) return '<p class="text-body-secondary mb-2">Nothing in the last 5 minutes.</p>';
return '<table class="table table-sm mb-2 nf-table"><tbody>' + rows.map(function (m) {
return '<tr><td><code>' + esc(m.game_message || m.packet) + '</code> <span class="text-body-secondary">' + esc(m.service) + '</span></td><td class="text-end">' + esc(nf.format(m.count)) + '</td><td class="text-end">' + esc(bytes(m.bytes)) + '</td></tr>';
}).join('') + '</tbody></table>';
}
function linkList(n) {
var links = graph.edges.filter(function (e) { return e.from === n.id || e.to === n.id; });
if (!links.length) return '';
return '<div class="mb-2">' + links.map(function (e) {
var out = e.from === n.id, other = out ? e.to : e.from;
var sent = out ? e.fwd : e.back, got = out ? e.back : e.fwd;
return '<div>' + esc(labelOf(other)) + (e.exact ? '' : ' <span class="badge text-bg-secondary">estimated</span>') + '<div class="text-body-secondary">sends ' + esc(rateText(sent, e, out ? e.bytesKnown.fwd : e.bytesKnown.back)) +
' · gets ' + esc(rateText(got, e, out ? e.bytesKnown.back : e.bytesKnown.fwd)) + '</div></div>';
}).join('') + '</div>';
}
function showDetail(n) {
var title = document.getElementById('nfDetailTitle'), body = document.getElementById('nfDetailBody');
title.textContent = n.label;
detail = n.id;
var intro = linkList(n);
if (n.kind === 'clients' || n.kind === 'web' || n.kind === 'peer') {
var who = n.kind === 'peer' ? n.peer : null;
body.innerHTML = intro + (who ? '<div>' + esc(who.address) + (who.user ? ' · ' + esc(who.user) : '') + (who.account ? ' · ' + esc(who.account) : '') + (who.character ? ' · ' + esc(who.character) : '') + '</div>' : '') +
'<p class="text-body-secondary mb-0">Each connection is in the Connections table below.' + (n.kind === 'peer' ? '' : ' ▸ on the box lists them in it, each with its own links.') + '</p>';
return;
}
if (n.kind === 'host') {
var h = hostOf(n.host);
body.innerHTML = intro + '<p class="mb-1">' + esc(h ? hostTotals(h) : '') + '</p><ul class="mb-2">' + n.servers.map(function (k) {
var s = summary.servers[k];
return '<li><a href="/diagnostics?server=' + encodeURIComponent(k) + '">' + esc(s.label) + '</a>' + (s.port ? ' <span class="text-body-secondary">' + esc(G.portLabel(s.port)) + '</span>' : '') + '</li>';
}).join('') + '</ul><p class="text-body-secondary mb-0">▸ on the box shows this machine\'s servers again.</p>';
return;
}
if (n.servers.length > 1) {
body.innerHTML = intro + '<p class="mb-1">' + esc(n.servers.length) + ' instances:</p><ul class="mb-2">' + n.servers.map(function (k) {
var s = summary.servers[k];
return '<li><a href="/diagnostics?server=' + encodeURIComponent(k) + '">' + esc(s.label) + '</a>' + (s.port ? ' ' + esc(G.portLabel(s.port)) : '') + ' <span class="text-body-secondary">' + esc(nf.format(s.packets_in + s.packets_out)) + ' pkt/s</span></li>';
}).join('') + '</ul><p class="text-body-secondary mb-0">▸ on the box lists each instance in it, each with its own links.</p>';
return;
}
var key = n.servers[0];
var own = summary.servers[key] || {};
var where = (own.port ? 'Listens on ' + G.portLabel(own.port) : '') + (own.host ? (own.port ? ', on ' : 'On ') + hostName(own.host) : '');
intro += where ? '<p class="text-body-secondary mb-2">' + esc(where) + '</p>' : '';
body.innerHTML = intro + '<p class="text-body-secondary mb-0">Loading…</p>';
api.get('/api/diagnostics/network/server?key=' + encodeURIComponent(key)).then(function (d) {
if (detail !== n.id || !d.success) return;
var gauges = d.gauges || {}, link = d.link || {};
var facts = [];
if (link.connections != null) facts.push(nf.format(link.connections) + ' connections, ' + nf.format(link.ping_ms) + ' ms average ping');
if (link.resends) facts.push(nf.format(link.resends) + ' messages resent in the last report' + (link.resend_queue ? ', ' + nf.format(link.resend_queue) + ' waiting' : ''));
if (gauges.workers_threads) facts.push('workers ' + nf.format(gauges.workers_busy || 0) + ' of ' + nf.format(gauges.workers_threads) + ' busy' + (gauges.workers_queued ? ', ' + nf.format(gauges.workers_queued) + ' queued' : ''));
if (gauges.websocket_clients) facts.push(nf.format(gauges.websocket_clients) + ' live dashboard pages');
if (gauges.http_deferred_pending) facts.push(nf.format(gauges.http_deferred_pending) + ' requests waiting on workers');
body.innerHTML = intro + (facts.length ? '<ul class="mb-2 ps-3">' + facts.map(function (f) { return '<li>' + esc(f) + '</li>'; }).join('') + '</ul>' : '') +
sparkline(d.series) +
'<h6 class="mt-3 mb-1 small fw-semibold">Busiest in <span class="text-body-secondary fw-normal">(5 min)</span></h6>' + messageTable(d.messages, 'in') +
'<h6 class="mb-1 small fw-semibold">Busiest out</h6>' + messageTable(d.messages, 'out') +
'<a href="/diagnostics?server=' + encodeURIComponent(key) + '">Open in Diagnostics</a>';
}).catch(function () {});
}
function renderPeers() {
var el = document.getElementById('nfPeers');
if (!peers) return;
document.getElementById('nfPeersNote').textContent = peers.addresses_shown ? '(each server\'s last report)' : '(addresses hidden: they need the Network addresses permission)';
var rows = peers.peers.filter(function (p) {
if (kind && p.kind !== kind) return false;
if (!search) return true;
var text = [p.address, p.account, p.character, p.user].concat(p.servers.map(function (s) { return s.label; })).join(' ').toLowerCase();
return text.indexOf(search) !== -1;
});
if (!peers.reported) { el.innerHTML = '<p class="text-body-secondary p-3 mb-0">No server has reported its connections yet (servers older than this dashboard don\'t).</p>'; return; }
var KIND = { game: 'Game client', web: 'Web client', server: 'Server' };
var html = '<table class="table table-sm table-hover align-middle mb-0 nf-table small"><thead><tr><th>Address</th><th>Kind</th><th>Player or user</th><th>Servers</th><th class="text-end">In/s</th><th class="text-end">Out/s</th><th class="text-end">Ping</th></tr></thead><tbody>';
html += rows.slice(0, 300).map(function (p) {
var pings = p.servers.filter(function (s) { return s.ping_ms != null; }).map(function (s) { return s.ping_ms; });
var player = p.character ? esc(p.character) + ' <span class="text-body-secondary">(' + esc(p.account) + ')</span>' : p.account ? esc(p.account) : p.user ? esc(p.user) : '';
if (p.account_id) player = '<a href="/accounts/' + encodeURIComponent(p.account_id) + '">' + player + '</a>';
return '<tr><td><code>' + esc(p.address) + '</code></td><td>' + esc(KIND[p.kind] || p.kind) + '</td><td>' + player + '</td>' +
'<td>' + p.servers.map(function (s) {
return '<div title="' + esc((s.http ? 'HTTP: requests' : 'RakNet: datagrams') + ', ' + s.peer) + '">' + esc(s.label) + (s.port != null ? ' <span class="text-body-secondary">:' + esc(s.port) + '</span>' : '') +
' <span class="text-body-secondary">' + esc(bytes(s.bytes_in)) + '/s in, ' + esc(bytes(s.bytes_out)) + '/s out' + (s.resends ? ', ' + esc(nf.format(s.resends)) + ' resent' : '') + '</span></div>';
}).join('') + '</td>' +
'<td class="text-end">' + esc(bytes(p.bytes_in)) + '<div class="text-body-secondary">' + esc(nf.format(p.packets_in)) + '</div></td>' +
'<td class="text-end">' + esc(bytes(p.bytes_out)) + '<div class="text-body-secondary">' + esc(nf.format(p.packets_out)) + '</div></td>' +
'<td class="text-end">' + (pings.length ? esc(Math.max.apply(null, pings)) + ' ms' : '-') + '</td></tr>';
}).join('');
(peers.others || []).forEach(function (o) {
if (kind && kind !== 'game' && kind !== 'web') return;
html += '<tr class="text-body-secondary"><td colspan="3">' + esc(nf.format(o.count)) + ' more</td><td>' + esc(o.label) + '</td><td class="text-end">' + esc(bytes(o.bytes_in)) + '</td><td class="text-end">' + esc(bytes(o.bytes_out)) + '</td><td></td></tr>';
});
html += '</tbody></table>';
el.innerHTML = rows.length || (peers.others || []).length ? html : '<p class="text-body-secondary p-3 mb-0">No connections' + (kind || search ? ' match' : '') + '.</p>';
}
var loadingPeers = false;
function loadPeers() {
if (loadingPeers || document.hidden) return;
loadingPeers = true;
api.get('/api/diagnostics/network/connections').then(function (d) {
loadingPeers = false;
if (!d.success) return;
peers = d;
renderPeers();
if (clientsExpanded || webExpanded) render();
}, function () { loadingPeers = false; });
}
function update(data) {
summary = data;
var now = data.time || Math.floor(Date.now() / 1000);
remember(G.build(summary, {}), now);
render();
document.getElementById('nfUpdated').textContent = 'Updated ' + new Date(now * 1000).toLocaleTimeString();
}
function load() {
if (document.hidden) return;
api.get('/api/diagnostics/network').then(function (d) { if (d.success) update(d); }).catch(function () {});
}
// Live from the traffic topic; nothing is drawn while the tab is hidden, and it catches up when shown
Live.onTopic('traffic', function (data) {
if (document.hidden || !data || !data.servers) return;
update(data);
});
document.addEventListener('visibilitychange', function () {
svg.classList.toggle('paused', document.hidden);
if (!document.hidden) { load(); loadPeers(); }
});
document.querySelectorAll('input[name="nfView"]').forEach(function (r) {
r.addEventListener('change', function () { view = r.value; render(); });
});
document.querySelectorAll('input[name="nfKind"]').forEach(function (r) {
r.addEventListener('change', function () { kind = r.value; renderPeers(); });
});
document.getElementById('nfPeerSearch').addEventListener('input', function (e) { search = e.target.value.trim().toLowerCase(); renderPeers(); });
window.addEventListener('resize', Live.throttle(render, 200));
setInterval(loadPeers, 5000);
setInterval(load, 30000); // in case the socket is down
load();
loadPeers();
})();
</script>
{% endblock %}