feat(dashboard): Network page with a live traffic flow diagram

Next to Diagnostics (health_view): game and web clients, auth, chat,
worlds per zone (expandable), other reporting servers, master, the
dashboard and the UGC server, with a lane each way per link sized by
bytes/s, animated by packets/s and coloured by load against its own
5 minute peak. Estimated links (servers without a split) are dashed.
Clicking a box shows its links, busiest message types and a sparkline,
with a link to Diagnostics (which now takes ?server=). A connections
table lists each remote address; single clients can be drawn too.
Narrow screens get a list. Drawing stops while the tab is hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 18:26:01 -05:00
parent 7e87f2a482
commit e6b7677c1f
7 changed files with 776 additions and 3 deletions

View File

@@ -473,6 +473,7 @@ void RegisterDashboardRoutes() {
SimplePage("/leaderboards", Perm("leaderboards_view"), "leaderboards.jinja2", "leaderboards", "Leaderboards for every activity");
SimplePage("/health", Perm("health_view"), "health.jinja2", "health", "Player counts, worlds, uptime and memory over time; crash dumps");
SimplePage("/diagnostics", Perm("health_view"), "diagnostics.jinja2", "diagnostics", "Packets, bytes and HTTP requests per second of every server");
SimplePage("/network", Perm("health_view"), "network.jinja2", "network", "Live diagram of the traffic between players, the servers and web clients, and the traffic of each connection");
SimplePage("/players", Perm("players_view"), "players.jinja2", "players", "Who is online, with kick, rescue and teleport");
SimplePage("/backups", Perm("backups"), "backups.jinja2", "backups", "Database backups");
// The GM level tabs need permissions_manage, the grants tab grants_manage

View File

@@ -0,0 +1,239 @@
/**
* The Network page's diagram model, without the DOM so it can be tested with node (tests/dWebTests/network-graph.test.mjs).
*
* build(summary, options) turns what /api/diagnostics/network (and the `traffic` WebSocket topic) sends into nodes and
* links: game clients and web clients on the left, auth, chat, the worlds (one node per zone, or per instance when the
* zone is expanded) and any other reporting server in the middle, master, the dashboard and the UGC server on the right.
* Nodes come from whatever servers report, so a new kind of server shows up by itself. Each link has its rates each way:
* `fwd` from `from` to `to`, `back` the other way, in packets (HTTP links: requests) and bytes per second; `exact` says
* whether they are measured on that link or worked out from a server's totals (older servers report no split).
*
* layout(graph, width) places the nodes in four columns in a fixed order and gives each link its path.
*/
(function (root) {
'use strict';
var COLUMNS = [0, 1, 2, 3]; // clients | auth, chat, worlds, others | master | dashboard, UGC
function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; }
function rates(r) { r = r || {}; return { packets_in: num(r.packets_in), packets_out: num(r.packets_out), bytes_in: num(r.bytes_in), bytes_out: num(r.bytes_out) }; }
function any(r) { return r.packets_in > 0 || r.packets_out > 0 || r.bytes_in > 0 || r.bytes_out > 0; }
// The node a server belongs to
function nodeOf(server, expanded) {
switch (server.type) {
case 'AUTH': return { id: 'auth', kind: 'auth', label: 'Auth', column: 1, order: 0 };
case 'CHAT': return { id: 'chat', kind: 'chat', label: 'Chat', column: 1, order: 1 };
case 'MASTER': return { id: 'master', kind: 'master', label: 'Master', column: 2, order: 0 };
case 'DASHBOARD': return { id: 'dashboard', kind: 'dashboard', label: 'Dashboard', column: 3, order: 0 };
case 'UGC': return { id: 'ugc', kind: 'ugc', label: 'UGC server', column: 3, order: 1 };
case 'WORLD': {
var zone = num(server.zone);
// "World 1200 Nimbus Station #3" -> "World 1200 Nimbus Station"
var zoneLabel = String(server.label || ('World ' + zone)).replace(/\s+#\d+$/, '');
if (expanded && expanded[zone]) return { id: server.key, kind: 'world', label: server.label || server.key, column: 1, order: 2 + zone + num(server.instance) / 1e6, zone: zone };
return { id: 'zone:' + zone, kind: 'zone', label: zoneLabel, column: 1, order: 2 + zone, zone: zone };
}
default: return { id: server.key, kind: 'other', label: server.label || server.key, column: 1, order: 1e9 };
}
}
function build(summary, options) {
options = options || {};
var servers = (summary && summary.servers) || {};
var keys = Object.keys(servers).sort();
var nodes = {}, edges = {};
function node(spec) {
var n = nodes[spec.id];
if (!n) {
n = nodes[spec.id] = { id: spec.id, kind: spec.kind, label: spec.label, column: spec.column, order: spec.order, zone: spec.zone, servers: [], instances: 0,
connections: 0, pingSum: 0, pingCount: 0, resends: 0, resendQueue: 0, workersBusy: 0, workersThreads: 0, workersQueued: 0, websockets: 0,
packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, http: 0, noSplit: false };
}
return n;
}
// Adds a link's rates; links between the same two nodes add up (every instance of a zone)
function edge(from, to, fwdPackets, fwdBytes, backPackets, backBytes, exact, kind, note) {
var id = from + '>' + to + (kind && kind !== 'packets' ? ':' + kind : '');
var e = edges[id];
if (!e) e = edges[id] = { id: id, from: from, to: to, kind: kind || 'packets', exact: true, notes: [], fwd: { packets: 0, bytes: 0 }, back: { packets: 0, bytes: 0 }, bytesKnown: { fwd: true, back: true } };
e.fwd.packets += num(fwdPackets);
e.back.packets += num(backPackets);
if (fwdBytes == null) e.bytesKnown.fwd = false; else e.fwd.bytes += num(fwdBytes);
if (backBytes == null) e.bytesKnown.back = false; else e.back.bytes += num(backBytes);
if (!exact) e.exact = false;
if (note && e.notes.indexOf(note) === -1) e.notes.push(note);
return e;
}
var clients = node({ id: 'clients', kind: 'clients', label: 'Game clients', column: 0, order: 0 });
var web = node({ id: 'web', kind: 'web', label: 'Web clients', column: 0, order: 10 });
var hasChat = keys.some(function (k) { return servers[k].type === 'CHAT'; });
keys.forEach(function (key) {
var s = servers[key];
var n = node(nodeOf(s, options.expanded));
var link = s.link || {}, gauges = s.gauges || {};
n.servers.push(key);
if (s.type === 'WORLD') n.instances++;
n.connections += num(link.connections);
if (num(link.connections) > 0) { n.pingSum += num(link.ping_ms); n.pingCount++; }
n.resends += num(link.resends);
n.resendQueue += num(link.resend_queue);
n.workersBusy += num(gauges.workers_busy);
n.workersThreads += num(gauges.workers_threads);
n.workersQueued += num(gauges.workers_queued);
n.websockets += num(gauges.websocket_clients);
n.packetsIn += num(s.packets_in); n.packetsOut += num(s.packets_out);
n.bytesIn += num(s.bytes_in); n.bytesOut += num(s.bytes_out);
n.http += num(s.http);
var split = s.split;
if (!split) n.noSplit = true;
var totals = rates(s);
var ESTIMATED = 'from the server\'s totals (it reports no split by peer)';
// Its master link, measured on its side (master itself is the other end of all of them)
if (s.type !== 'MASTER') {
if (split) {
var m = rates(split.master);
if (any(m)) edge(n.id, 'master', m.packets_out, m.bytes_out, m.packets_in, m.bytes_in, true);
} else if (s.type === 'DASHBOARD' || s.type === 'UGC' || s.type === 'CHAT') {
// Nearly all of their packets are with master (chat's are with the worlds too)
edge(n.id, 'master', totals.packets_out, totals.bytes_out, totals.packets_in, totals.bytes_in, false, 'packets', ESTIMATED);
}
}
if (s.type === 'DASHBOARD' || s.type === 'UGC') {
// HTTP: requests from browsers and game clients (all but other servers'), and the bodies answered
var fromServers = split ? num(split.http_from_servers) : 0, fromServersBytes = split ? num(split.http_from_servers_bytes_out) : 0;
var requests = Math.max(0, num(s.http) - fromServers), bytesOut = Math.max(0, num(s.http_bytes_out) - fromServersBytes);
var who = s.type === 'UGC' ? 'clients' : 'web';
if (requests > 0 || bytesOut > 0) edge(who, n.id, requests, null, requests, bytesOut, !!split, 'http', split ? '' : 'other servers\' requests not told apart (older server)');
// The dashboard's own requests to the UGC server, counted where they were made
if (split && num(split.http_out) > 0) edge(n.id, 'ugc', split.http_out, null, split.http_out, split.http_out_bytes_in, true, 'http');
return;
}
if (s.type === 'MASTER') return;
if (split) {
var c = rates(split.clients);
if (any(c)) edge('clients', n.id, c.packets_in, c.bytes_in, c.packets_out, c.bytes_out, true);
var o = rates(split.servers);
// A world's "other servers" is its chat link; chat's are the worlds, drawn from their side
if (s.type === 'WORLD' && any(o)) edge(n.id, hasChat ? 'chat' : 'master', o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, hasChat, 'packets', hasChat ? '' : 'chat link, chat not reporting');
else if (s.type !== 'CHAT' && any(o)) edge(n.id, 'master', o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, false, 'servers', 'with other servers, drawn to master');
} else if (s.type !== 'CHAT') {
edge('clients', n.id, totals.packets_in, totals.bytes_in, totals.packets_out, totals.bytes_out, false, 'packets', ESTIMATED);
}
});
// Game clients and web clients: connections from the servers they use
keys.forEach(function (key) {
var s = servers[key], link = s.link || {};
if (s.type === 'AUTH' || s.type === 'WORLD') clients.connections += Math.max(0, num(link.connections) - 1); // less its master link
});
web.websockets = keys.reduce(function (t, k) { return t + (servers[k].type === 'DASHBOARD' ? num((servers[k].gauges || {}).websocket_clients) : 0); }, 0);
// Single clients (from /api/diagnostics/network/connections) when their node is expanded
(options.peers || []).forEach(function (peer, i) {
if (!(peer.kind === 'game' && options.clientsExpanded) && !(peer.kind === 'web' && options.webExpanded)) return;
var limit = options.peerLimit || 8;
var shownOfKind = (options.peers || []).slice(0, i).filter(function (p) { return p.kind === peer.kind; }).length;
if (shownOfKind >= limit) return;
var id = 'peer:' + peer.address;
var who = peer.character || peer.account || peer.address;
var p = node({ id: id, kind: 'peer', label: who, column: 0, order: (peer.kind === 'game' ? 0.1 : 10.1) + shownOfKind / 100 });
p.peer = peer;
(peer.servers || []).forEach(function (entry) {
var target = servers[entry.server];
if (!target) return;
var to = nodeOf(target, options.expanded).id;
if (!nodes[to]) return;
if (entry.http) edge(id, to, entry.packets_in, entry.bytes_in, entry.packets_out, entry.bytes_out, true, 'peer-http');
else edge(id, to, entry.packets_in, entry.bytes_in, entry.packets_out, entry.bytes_out, true, 'peer', 'RakNet datagrams of this connection, acknowledgements and resends included');
if (!entry.http) { p.connections++; p.pingSum += num(entry.ping_ms); p.pingCount++; p.resends += num(entry.resends); }
});
});
var list = Object.keys(nodes).map(function (k) { return nodes[k]; });
list.forEach(function (n) { n.ping = n.pingCount ? Math.round(n.pingSum / n.pingCount) : null; });
// Nodes nothing is going through and no server is behind (no web clients without a web server) stay out
var linked = {};
Object.keys(edges).forEach(function (k) { linked[edges[k].from] = linked[edges[k].to] = true; });
list = list.filter(function (n) { return n.servers.length || linked[n.id] || (n.kind === 'clients' && n.connections > 0); });
list.sort(function (a, b) { return a.column - b.column || a.order - b.order || String(a.label).localeCompare(String(b.label)); });
var edgeList = Object.keys(edges).sort().map(function (k) { return edges[k]; }).filter(function (e) { return nodes[e.from] && nodes[e.to]; });
return { nodes: list, edges: edgeList };
}
// Load of a link against its recent peak: 'idle', 'low', 'mid' or 'high'
function loadClass(value, peak) {
if (!(value > 0)) return 'idle';
var ratio = peak > 0 ? value / peak : 1;
return ratio >= 0.8 ? 'high' : ratio >= 0.4 ? 'mid' : 'low';
}
// Line width from bytes (or requests) per second: 1.5 px idle up to ~12 px
function width(value) {
return Math.round((1.5 + Math.min(10.5, Math.log10(1 + Math.max(0, value)) * 1.6)) * 10) / 10;
}
// Seconds one dash takes along the line: faster with more packets, still at nothing
function speed(packets) {
if (!(packets > 0)) return 0;
return Math.round(Math.max(0.35, Math.min(4, 4 / Math.log10(10 + packets))) * 100) / 100;
}
/**
* Positions: columns across `width` (node centres), rows in each column spread over the tallest column's height.
* Returns {width, height, nodeWidth, nodeHeight, nodes: {id: {x, y}}, paths: {edgeId: {fwd, back}}}.
*/
function layout(graph, totalWidth) {
var nodeWidth = Math.max(110, Math.min(170, Math.floor(totalWidth / 5.2))), nodeHeight = 54, rowHeight = 74, pad = 16;
var byColumn = COLUMNS.map(function () { return []; });
graph.nodes.forEach(function (n) { byColumn[n.column].push(n); });
var rows = Math.max.apply(null, byColumn.map(function (c) { return c.length; }).concat([1]));
var height = rows * rowHeight + pad * 2;
var usable = totalWidth - nodeWidth - pad * 2;
var xs = [0, 0.36, 0.68, 1].map(function (f) { return Math.round(pad + nodeWidth / 2 + f * usable); });
var at = {};
byColumn.forEach(function (column, c) {
var step = (height - pad * 2) / Math.max(column.length, 1);
column.forEach(function (n, i) { at[n.id] = { x: xs[c], y: Math.round(pad + step * (i + 0.5)), column: c }; });
});
var paths = {};
graph.edges.forEach(function (e) {
var a = at[e.from], b = at[e.to];
if (!a || !b) return;
paths[e.id] = { fwd: curve(a, b, nodeWidth, 3.5), back: curve(b, a, nodeWidth, 3.5) };
});
return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, paths: paths };
}
// A curve from a's side to b's side, moved `offset` px to its left (so the two directions sit side by side)
function curve(a, b, nodeWidth, offset) {
var half = nodeWidth / 2, x1, x2, c1, c2;
if (a.x === b.x) {
// Same column: bulge out on the side with room (left of the rightmost column, right of the others)
var side = a.column === COLUMNS.length - 1 ? -1 : 1;
x1 = x2 = a.x + side * half;
var bulge = side * Math.max(50, Math.abs(b.y - a.y) * 0.35);
c1 = [x1 + bulge, a.y]; c2 = [x2 + bulge, b.y];
} else {
var dir = b.x > a.x ? 1 : -1;
x1 = a.x + dir * half; x2 = b.x - dir * half;
var mid = (x2 - x1) / 2;
c1 = [x1 + mid, a.y]; c2 = [x2 - mid, b.y];
}
// Offset along the normal of the chord
var dx = x2 - x1, dy = b.y - a.y, len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len * offset, ny = dx / len * offset;
var p = function (x, y) { return (Math.round((x + nx) * 10) / 10) + ',' + (Math.round((y + ny) * 10) / 10); };
return 'M' + p(x1, a.y) + ' C' + p(c1[0], c1[1]) + ' ' + p(c2[0], c2[1]) + ' ' + p(x2, b.y);
}
var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed };
root.NetworkGraph = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -79,7 +79,7 @@
var COLORS = ['var(--tr-1)', 'var(--tr-2)', 'var(--tr-3)', 'var(--tr-4)', 'var(--tr-5)', 'var(--tr-6)'];
var OTHER = 'var(--tr-other)';
var range = (document.querySelector('input[name="trRange"]:checked') || {}).value || '5m';
var server = '';
var server = new URLSearchParams(location.search).get('server') || ''; // the Network page links here with ?server=<key>
var direction = 'in';
var data = null;

View File

@@ -105,7 +105,7 @@
</div>
{% endif %}
{% set logsPages = ["health", "instances", "diagnostics", "activity_log", "command_log", "audit_log", "system_log"] %}
{% set logsPages = ["health", "instances", "diagnostics", "network", "activity_log", "command_log", "audit_log", "system_log"] %}
{% if can.health_view or can.logs_activity or can.logs_command or can.logs_audit or can.logs_system %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-logs" aria-expanded="{% if current_page in logsPages %}true{% else %}false{% endif %}" aria-controls="nav-logs">
@@ -113,7 +113,7 @@
</button>
<div class="collapse{% if current_page in logsPages %} show{% endif %}" id="nav-logs">
<div class="list-group list-group-flush">
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a><a href="/diagnostics" class="list-group-item list-group-item-action{% if current_page == "diagnostics" %} active{% endif %}">Diagnostics</a>{% endif %}
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a><a href="/diagnostics" class="list-group-item list-group-item-action{% if current_page == "diagnostics" %} active{% endif %}">Diagnostics</a><a href="/network" class="list-group-item list-group-item-action{% if current_page == "network" %} active{% endif %}">Network</a>{% endif %}
{% if can.logs_activity %}<a href="/activity_log" class="list-group-item list-group-item-action{% if current_page == "activity_log" %} active{% endif %}">Activity Log</a>{% endif %}
{% if can.logs_command %}<a href="/command_log" class="list-group-item list-group-item-action{% if current_page == "command_log" %} active{% endif %}">Command Log</a>{% endif %}
{% if can.logs_audit %}<a href="/audit_log" class="list-group-item list-group-item-action{% if current_page == "audit_log" %} active{% endif %}">Audit Log</a>{% endif %}

View File

@@ -0,0 +1,434 @@
{% extends "base.jinja2" %}
{% block title %}Network - DarkflameServer{% endblock %}
{% block css %}
<style>
/* Load of a link against its own peak of the last 5 minutes */
.nf { --nf-idle: var(--bs-secondary-color); --nf-low: var(--bs-info); --nf-mid: var(--bs-warning); --nf-high: var(--bs-danger); }
.nf-box { position: relative; }
.nf-svg { display: block; width: 100%; }
.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; }
.nf-svg .node rect { fill: var(--bs-body-bg); stroke: var(--bs-border-color); stroke-width: 1.5; }
.nf-svg .node { cursor: pointer; }
.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 { fill: var(--bs-primary); font-size: 11px; }
.nf-svg.paused .flow { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) { .nf-svg .flow { animation: 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 its own peak of the last 5 minutes. 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="nfViewAuto" value="auto" checked data-pref="network.view"><label class="btn btn-outline-secondary" for="nfViewAuto">Auto</label>
<input type="radio" class="btn-check" name="nfView" id="nfViewDiagram" value="diagram" 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</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>at its peak</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>
</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-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 = '';
var view = (document.querySelector('input[name="nfView"]:checked') || {}).value || 'auto';
var history = {}; // edge id -> [[time, value]] for the load colours
var svg = document.getElementById('nfSvg'), box = document.getElementById('nfBox'), stack = document.getElementById('nfStack');
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]; return n ? n.label : id; }
// 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 });
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 width = box.clientWidth || 800;
var list = view === 'list' || (view === 'auto' && width < 640);
svg.classList.toggle('d-none', !hasServers || list);
stack.classList.toggle('d-none', !hasServers || !list);
if (!hasServers) return;
if (list) renderStack(); else renderDiagram(width);
}
function nodeMeta(n) {
var lines = [];
if (n.kind === 'clients') lines.push(nf.format(n.connections) + ' connected' + (clientsExpanded ? '' : ''));
else if (n.kind === 'web') lines.push(n.websockets ? nf.format(n.websockets) + ' live pages' : 'browsers, API');
else if (n.kind === 'peer') {
var p = n.peer;
lines.push(p.kind === 'web' ? 'web' : (n.ping != null ? n.ping + ' ms' : ''));
} 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);
}
function renderDiagram(width) {
var L = G.layout(graph, width);
var html = '';
graph.edges.forEach(function (e) {
var p = L.paths[e.id];
if (!p) return;
var load = G.loadClass(edgeValue(e), peakOf(e.id));
var lanes = [['fwd', e.fwd, e.bytesKnown.fwd], ['back', e.back, e.bytesKnown.back]];
html += '<g class="edge' + (e.exact ? '' : ' estimated') + '" 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]] + '"/>';
});
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);
var chars = Math.floor((L.nodeWidth - 16) / 6.6);
var name = n.label.length > chars ? n.label.slice(0, chars - 1) + '…' : n.label;
var canToggle = n.kind === 'zone' && n.instances > 1 || n.kind === 'world' && expanded[n.zone] || n.kind === 'clients' || n.kind === 'web';
var open = n.kind === 'world' || (n.kind === 'clients' && clientsExpanded) || (n.kind === 'web' && webExpanded);
html += '<g class="node' + (selected === n.id ? ' selected' : '') + '" data-node="' + esc(n.id) + '" tabindex="0" role="button" aria-label="' + esc(n.label) + '">' +
'<rect x="' + x + '" y="' + y + '" width="' + L.nodeWidth + '" height="' + L.nodeHeight + '" rx="8"/>' +
'<text class="name" x="' + (x + 8) + '" y="' + (y + 17) + '">' + esc(name) + '</text>';
meta.forEach(function (line, i) {
html += '<text class="meta' + (/resent/.test(line) ? ' warn' : '') + '" x="' + (x + 8) + '" y="' + (y + 32 + i * 13) + '">' + esc(line.length > chars + 4 ? line.slice(0, chars + 3) + '…' : line) + '</text>';
});
if (n.noSplit) 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 (canToggle) html += '<text class="toggle" data-toggle="' + esc(n.id) + '" x="' + (x + L.nodeWidth - 8) + '" y="' + (y + 17) + '" text-anchor="end">' + (open ? '−' : '+') + '</text>';
html += '</g>';
});
svg.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height);
svg.setAttribute('height', L.height);
svg.innerHTML = html;
}
// Phones: every box with its links, busiest first
function renderStack() {
var max = graph.edges.reduce(function (m, e) { return Math.max(m, edgeValue(e)); }, 0) || 1;
stack.innerHTML = graph.nodes.map(function (n) {
var links = graph.edges.filter(function (e) { return e.from === n.id; }).sort(function (a, b) { return edgeValue(b) - edgeValue(a); });
return '<div class="nf-row" data-node="' + esc(n.id) + '" role="button" tabindex="0"><div class="d-flex justify-content-between"><b>' + esc(n.label) + '</b><span class="small text-body-secondary">' + esc(nodeMeta(n).join(' · ')) + '</span></div>' +
links.map(function (e) {
var load = G.loadClass(edgeValue(e), peakOf(e.id));
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('') + '</div>';
}).join('');
}
// 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.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) + 'px';
tip.style.top = y + 'px';
});
svg.addEventListener('mouseleave', function () { tip.classList.add('d-none'); });
function onNode(ev) {
var toggle = ev.target.closest('[data-toggle]');
var el = ev.target.closest('[data-node]');
if (!el || !graph) return;
var n = graphNodes[el.dataset.node];
if (!n) return;
if (toggle) {
if (n.kind === 'zone' || n.kind === 'world') expanded[n.zone] = !expanded[n.zone];
else if (n.kind === 'clients') { clientsExpanded = !clientsExpanded; if (clientsExpanded) loadPeers(); }
else if (n.kind === 'web') { webExpanded = !webExpanded; if (webExpanded) loadPeers(); }
render();
return;
}
selected = n.id;
render();
showDetail(n);
}
svg.addEventListener('click', onNode);
stack.addEventListener('click', onNode);
[svg, stack].forEach(function (el) {
el.addEventListener('keydown', function (ev) { 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.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' ? '' : ' The + on the box shows the busiest ones in the diagram.') + '</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> <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">The + on the box shows each instance in the diagram.</p>';
return;
}
var key = n.servers[0];
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].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</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) : '';
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 %}

View File

@@ -101,4 +101,7 @@ if(NODE_EXECUTABLE)
# Which links the dashboard follows without a reload, and its in-place update rules (static/js/nav.js)
add_test(NAME NavRulesJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/nav.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/nav.js")
# The Network page's diagram model: nodes and links from the traffic summary (static/js/network-graph.js)
add_test(NAME NetworkGraphJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/network-graph.test.mjs"
"${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/network-graph.js")
endif()

View File

@@ -0,0 +1,96 @@
// The Network page's diagram model (static/js/network-graph.js): nodes and links from the `traffic` summary.
// Run by ctest: node network-graph.test.mjs <network-graph.js>
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const [scriptPath] = process.argv.slice(2);
const context = { window: {} };
vm.createContext(context);
vm.runInContext(readFileSync(scriptPath, 'utf8'), context);
const G = context.window.NetworkGraph;
let failures = 0;
const same = (actual, expected, what) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
failures++;
console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`);
}
};
const r = (pi, po, bi, bo) => ({ packets_in: pi, packets_out: po, bytes_in: bi, bytes_out: bo });
const split = (clients, master, servers, http = {}) => Object.assign({ clients, master, servers, http_from_servers: 0, http_from_servers_bytes_out: 0, http_out: 0, http_out_bytes_in: 0 }, http);
// What NetworkView::Summary sends
const summary = {
time: 1700000000,
servers: {
master: Object.assign(r(30, 30, 3000, 3000), { key: 'master', type: 'MASTER', label: 'Master', link: { connections: 5 }, split: split(r(0, 0, 0, 0), r(0, 0, 0, 0), r(30, 30, 3000, 3000)) }),
auth: Object.assign(r(4, 4, 400, 400), { key: 'auth', type: 'AUTH', label: 'Auth', link: { connections: 3, ping_ms: 20 }, split: split(r(3, 3, 300, 300), r(1, 1, 100, 100), r(0, 0, 0, 0)) }),
chat: Object.assign(r(2, 2, 200, 200), { key: 'chat', type: 'CHAT', label: 'Chat', link: { connections: 2 }, split: split(r(0, 0, 0, 0), r(1, 1, 100, 100), r(1, 1, 100, 100)) }),
'world:1200:1': Object.assign(r(20, 40, 2000, 8000), { key: 'world:1200:1', type: 'WORLD', zone: 1200, instance: 1, label: 'World 1200 Nimbus Station #1', link: { connections: 11, ping_ms: 50, resends: 3 },
split: split(r(18, 38, 1800, 7800), r(1, 1, 100, 100), r(1, 1, 100, 100)) }),
'world:1200:2': Object.assign(r(10, 20, 1000, 4000), { key: 'world:1200:2', type: 'WORLD', zone: 1200, instance: 2, label: 'World 1200 Nimbus Station #2', link: { connections: 6, ping_ms: 70 },
split: split(r(9, 19, 900, 3900), r(1, 1, 100, 100), r(0, 0, 0, 0)) }),
dashboard: Object.assign(r(1, 1, 100, 100), { key: 'dashboard', type: 'DASHBOARD', label: 'Dashboard', http: 4, http_bytes_out: 40000, link: { connections: 1 }, gauges: { websocket_clients: 2, workers_busy: 1, workers_threads: 4 },
split: split(r(0, 0, 0, 0), r(1, 1, 100, 100), r(0, 0, 0, 0), { http_out: 1, http_out_bytes_in: 5000 }) }),
ugc: Object.assign(r(1, 1, 100, 100), { key: 'ugc', type: 'UGC', label: 'UGC', http: 3, http_bytes_out: 70000, link: { connections: 1 },
split: split(r(0, 0, 0, 0), r(1, 1, 100, 100), r(0, 0, 0, 0), { http_from_servers: 1, http_from_servers_bytes_out: 5000 }) }),
'service:99:0:0': Object.assign(r(1, 1, 10, 10), { key: 'service:99:0:0', type: '', label: 'service:99:0:0', link: {}, split: null })
}
};
const graph = G.build(summary, {});
const ids = graph.nodes.map((n) => n.id);
same(ids, ['clients', 'web', 'auth', 'chat', 'zone:1200', 'service:99:0:0', 'master', 'dashboard', 'ugc'], 'nodes in column order, one per zone');
const edge = (id) => graph.edges.find((e) => e.id === id);
same(edge('clients>zone:1200').fwd, { packets: 27, bytes: 2700 }, 'players to the zone: both instances');
same(edge('clients>zone:1200').back, { packets: 57, bytes: 11700 }, 'the zone to its players');
same(edge('clients>zone:1200').exact, true, 'measured');
same(edge('zone:1200>master').fwd, { packets: 2, bytes: 200 }, 'the zone to master');
same(edge('zone:1200>chat').fwd, { packets: 1, bytes: 100 }, 'the zone to chat');
same(edge('clients>auth').fwd.packets, 3, 'players to auth');
same(edge('web>dashboard:http').fwd.packets, 4, 'browser requests to the dashboard');
same(edge('web>dashboard:http').back.bytes, 40000, 'what the dashboard answered them');
same(edge('clients>ugc:http').fwd.packets, 2, 'game clients\' requests to UGC, less the dashboard\'s');
same(edge('clients>ugc:http').back.bytes, 65000, 'UGC files sent to them');
same(edge('dashboard>ugc:http').back.bytes, 5000, 'the dashboard fetching from UGC');
same(edge('dashboard>master').exact, true, 'dashboard master link measured');
same(edge('clients>service:99:0:0').exact, false, 'a server without a split: estimated from totals');
same(graph.nodes.find((n) => n.id === 'service:99:0:0').noSplit, true, 'and marked');
same(graph.nodes.find((n) => n.id === 'clients').connections, 2 + 10 + 5, 'players: connections of auth and worlds less their master links');
same(graph.nodes.find((n) => n.id === 'web').websockets, 2, 'live dashboard pages');
same(graph.nodes.find((n) => n.id === 'zone:1200').instances, 2, 'instances');
same(graph.nodes.find((n) => n.id === 'zone:1200').label, 'World 1200 Nimbus Station', 'zone label without the instance');
// Expanding the zone draws each instance
const open = G.build(summary, { expanded: { 1200: true } });
same(open.nodes.filter((n) => n.kind === 'world').map((n) => n.id), ['world:1200:1', 'world:1200:2'], 'instances when expanded');
same(open.edges.find((e) => e.id === 'clients>world:1200:2').fwd.packets, 9, 'an instance\'s own link');
// Single clients from the connections list
const peers = [{ address: 'peer-0001', kind: 'game', character: 'Alice', servers: [{ server: 'world:1200:1', packets_in: 5, packets_out: 9, bytes_in: 500, bytes_out: 900, ping_ms: 40 }] },
{ address: 'peer-0002', kind: 'web', servers: [{ server: 'dashboard', http: true, packets_in: 1, packets_out: 1, bytes_in: 200, bytes_out: 3000 }] }];
const withPeers = G.build(summary, { clientsExpanded: true, peers });
same(withPeers.nodes.filter((n) => n.kind === 'peer').map((n) => n.label), ['Alice'], 'only game clients when only they are expanded');
same(withPeers.edges.find((e) => e.id === 'peer:peer-0001>zone:1200:peer').back.bytes, 900, 'a client\'s own link');
// No servers: nothing to draw but nodes with nothing behind them are left out
same(G.build({ servers: {} }, {}).nodes.length, 0, 'empty summary');
// Load colours, widths and speeds
same([G.loadClass(0, 10), G.loadClass(1, 10), G.loadClass(5, 10), G.loadClass(9, 10), G.loadClass(5, 0)], ['idle', 'low', 'mid', 'high', 'high'], 'load classes');
same(G.width(0), 1.5, 'idle width');
same(G.width(1e9) <= 12, true, 'width is capped');
same(G.speed(0), 0, 'no packets, no motion');
same(G.speed(1e6) < G.speed(1), true, 'busier moves faster');
// Layout: columns left to right, every link a path each way
const L = G.layout(graph, 1000);
same(L.nodes.clients.x < L.nodes.auth.x && L.nodes.auth.x < L.nodes.master.x && L.nodes.master.x < L.nodes.dashboard.x, true, 'columns');
same(graph.edges.every((e) => L.paths[e.id] && /^M/.test(L.paths[e.id].fwd) && /^M/.test(L.paths[e.id].back)), true, 'paths');
same(G.layout(graph, 1000), L, 'the same layout every time');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('network-graph: all passed');