feat(dashboard): Network groups open in place as a filterable list in their box

An open group (game clients, web clients, a zone's instances) keeps one box
that grows by its rows, at most 8 in view with the rest scrolled. Each row
in view is a link end of its own with only its member's traffic, and the
open box's header has none. Members are filtered by name, account or user,
instance and (with network_ips) address, in a steady order; web clients
show the dashboard user.

The filter and rows are HTML over the SVG, made once and then moved and
updated in place, so live updates keep the typed text, the focus and the
scroll; links follow the list's scroll. The box grows and shrinks with its
list, and the List view nests the members under their group with the same
filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 22:01:09 -05:00
parent 2fb68cd20e
commit c57ef49b15
3 changed files with 560 additions and 151 deletions

View File

@@ -2,25 +2,32 @@
* 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).
* links: game clients on the left, auth, chat, the worlds (one node per zone) and any other reporting server in the
* middle, then master, the dashboard and the UGC server, and web clients 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.
* Groups (Game clients, Web clients, a zone with several instances) open in place: the box gets `open`, its `members`
* (single connections, or instances) and `shown` (the ids of those matching the group's filter). Each shown member is
* a link end of its own, and the open box itself has none; `graph.members` finds a member by id.
*
* layout(graph, width, moved, scroll) places the nodes in five columns in a fixed order, grows open boxes by their rows
* and gives each link its path; rows in view of an open box's list are anchors (`anchors`).
*/
(function (root) {
'use strict';
var COLUMNS = [0, 1, 2, 3, 4]; // game clients | auth, chat, worlds, others | master | dashboard, UGC | web clients
// An open group's box: its header, the filter, the rows (at most MAX_ROWS in view, the rest scroll) and a margin
var GROUP = { head: 44, filter: 30, row: 30, maxRows: 8, foot: 6 };
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) {
// The node a server belongs to (a zone's instances are all in the zone's box)
function nodeOf(server) {
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 };
@@ -31,28 +38,56 @@
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, group: '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 };
}
}
// Which group a connection from /api/diagnostics/network/connections is a member of: players are game clients, and
// so are HTTP clients of the UGC server only (game clients fetching models; the closed box counts them there too)
function peerGroup(peer, servers) {
if (peer.kind === 'game') return 'clients';
if (peer.kind !== 'web') return null;
var list = peer.servers || [];
var ugcOnly = list.length > 0 && list.every(function (e) { return servers[e.server] && servers[e.server].type === 'UGC'; });
return ugcOnly ? 'clients' : 'web';
}
// What a group's filter matches a member by
function matches(member, filter) {
filter = String(filter || '').trim().toLowerCase();
if (!filter) return true;
return filter.split(/\s+/).every(function (word) { return member.search.indexOf(word) !== -1; });
}
function build(summary, options) {
options = options || {};
var servers = (summary && summary.servers) || {};
var keys = Object.keys(servers).sort();
var nodes = {}, edges = {};
var expanded = options.expanded || {}, filters = options.filters || {};
var nodes = {}, edges = {}, members = {};
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, group: spec.group, servers: [], instances: 0,
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 };
packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, http: 0, noSplit: false, open: false, members: [], shown: [] };
}
return n;
}
function member(group, spec) {
spec.group = group.id;
spec.connections = spec.connections || 0;
spec.pingSum = spec.pingSum || 0;
spec.pingCount = spec.pingCount || 0;
spec.resends = spec.resends || 0;
members[spec.id] = spec;
group.members.push(spec);
group.open = true;
return spec;
}
// 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 : '');
@@ -66,6 +101,8 @@
if (note && e.notes.indexOf(note) === -1) e.notes.push(note);
return e;
}
// Where a link to a server ends: the server's box, or its row while its zone is open
function endOf(server) { return server.type === 'WORLD' && expanded[num(server.zone)] ? server.key : nodeOf(server).id; }
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: 4, order: 0 });
@@ -73,10 +110,19 @@
keys.forEach(function (key) {
var s = servers[key];
var n = node(nodeOf(s, options.expanded));
var n = node(nodeOf(s));
var link = s.link || {}, gauges = s.gauges || {};
// Links end at the zone's box, or at the instance's row while the zone is open
var id = s.type === 'WORLD' ? endOf(s) : n.id;
n.servers.push(key);
if (s.type === 'WORLD') n.instances++;
if (s.type === 'WORLD') {
n.instances++;
if (id !== n.id) {
member(n, { id: key, kind: 'world', label: '#' + num(s.instance), title: s.label || key, server: key, instance: num(s.instance),
connections: num(link.connections), pingSum: num(link.connections) > 0 ? num(link.ping_ms) : 0, pingCount: num(link.connections) > 0 ? 1 : 0,
resends: num(link.resends), search: (String(s.label || key) + ' #' + num(s.instance) + ' ' + key).toLowerCase() });
}
}
n.connections += num(link.connections);
if (num(link.connections) > 0) { n.pingSum += num(link.ping_ms); n.pingCount++; }
n.resends += num(link.resends);
@@ -98,10 +144,10 @@
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);
if (any(m)) edge(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);
edge(id, 'master', totals.packets_out, totals.bytes_out, totals.packets_in, totals.bytes_in, false, 'packets', ESTIMATED);
}
}
@@ -110,22 +156,22 @@
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)');
if (requests > 0 || bytesOut > 0) edge(who, 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');
if (split && num(split.http_out) > 0) edge(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);
if (any(c)) edge('clients', 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');
if (s.type === 'WORLD' && any(o)) edge(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(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);
edge('clients', id, totals.packets_in, totals.bytes_in, totals.packets_out, totals.bytes_out, false, 'packets', ESTIMATED);
}
});
@@ -136,21 +182,27 @@
});
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: peer.kind === 'game' ? 0 : 4, order: 0.1 + shownOfKind / 100, group: peer.kind === 'game' ? 'clients' : 'web' });
p.peer = peer;
// An open Game clients or Web clients box: each connection (from /api/diagnostics/network/connections) is a row
// with its own links, in place of the box's. Until the list has loaded the box keeps its summed links.
var peersLoaded = Array.isArray(options.peers);
var peerOpen = { clients: !!options.clientsExpanded && peersLoaded, web: !!options.webExpanded && peersLoaded };
if (peerOpen.clients) clients.open = true;
if (peerOpen.web) web.open = true;
var seen = {};
(peersLoaded ? options.peers : []).forEach(function (peer) {
var groupId = peerGroup(peer, servers);
if (!groupId || !peerOpen[groupId]) return;
var base = 'peer:' + groupId + ':' + (peer.account_id ? peer.account_id + '@' : '') + peer.address, id = base;
for (var k = 2; seen[id]; k++) id = base + '#' + k;
seen[id] = true;
var names = [peer.character, peer.account, peer.user];
var p = member(nodes[groupId], { id: id, kind: 'peer', label: String(peer.character || peer.account || peer.user || peer.address || ''), peer: peer,
search: names.concat([options.addressesShown ? peer.address : ''], (peer.servers || []).map(function (e) { return e.label; }))
.filter(Boolean).join(' ').toLowerCase() });
(peer.servers || []).forEach(function (entry) {
var target = servers[entry.server];
if (!target) return;
var to = nodeOf(target, options.expanded).id;
if (!nodes[to]) return;
var to = endOf(target);
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); }
@@ -158,14 +210,28 @@
});
var list = Object.keys(nodes).map(function (k) { return nodes[k]; });
// Open groups: members in a steady order (a list that is read and scrolled should not reshuffle every update),
// and the ones matching the group's filter
list.forEach(function (n) {
if (!n.open) return;
n.members.sort(function (a, b) {
return a.kind === 'world' ? a.instance - b.instance : String(a.label).localeCompare(String(b.label)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
});
n.shown = n.members.filter(function (m) { return matches(m, filters[n.id]); }).map(function (m) { return m.id; });
});
list.forEach(function (n) { n.ping = n.pingCount ? Math.round(n.pingSum / n.pingCount) : null; });
Object.keys(members).forEach(function (k) { var m = members[k]; m.ping = m.pingCount ? Math.round(m.pingSum / m.pingCount) : null; });
// A link ends at a box that is not open, or at a row its group's filter shows: an open box's traffic is its rows'
var shown = {};
list.forEach(function (n) { n.shown.forEach(function (id) { shown[id] = true; }); });
var end = function (id) { return nodes[id] ? !nodes[id].open : !!shown[id]; };
var edgeList = Object.keys(edges).sort().map(function (k) { return edges[k]; }).filter(function (e) { return end(e.from) && end(e.to); });
// 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 = list.filter(function (n) { return n.servers.length || n.open || 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 };
return { nodes: list, edges: edgeList, members: members };
}
// Load of a link against its recent peak: 'idle', 'low', 'mid' or 'high'
@@ -186,12 +252,20 @@
return Math.round(Math.max(0.35, Math.min(4, 4 / Math.log10(10 + packets))) * 100) / 100;
}
// An open group's box height for `rows` members (at least one row, for "no matches" or "loading")
function groupHeight(rows) {
return GROUP.head + GROUP.filter + Math.max(1, Math.min(GROUP.maxRows, rows)) * GROUP.row + GROUP.foot;
}
/**
* Positions: columns across `width` (node centres), rows in each column spread over the tallest column's height.
* `moved` ({id: {fx, y}}, optional) places nodes the viewer dragged: fx is a fraction of the width, y in pixels.
* Returns {width, height, nodeWidth, nodeHeight, nodes: {id: {x, y}}, paths: {edgeId: {fwd, back}}}.
* `scroll` ({groupId: px}, optional) is how far each open box's list is scrolled.
* Returns {width, height, nodeWidth, nodeHeight, nodes: {id: {x, y, h}}, lists: {groupId: {top, height, rows}},
* anchors: {memberId: {x, y}}, paths: {edgeId: {fwd, back}}}. A node's y is the centre of its header (of the box
* when closed), h its whole height; an open box's list starts at lists[id].top and anchors are the rows in view.
*/
function layout(graph, totalWidth, moved) {
function layout(graph, totalWidth, moved, scroll) {
var nodeWidth = Math.max(110, Math.min(170, Math.floor(totalWidth / 6.4))), nodeHeight = 54, rowHeight = 74, pad = 16;
var byColumn = COLUMNS.map(function () { return []; });
graph.nodes.forEach(function (n) { byColumn[n.column].push(n); });
@@ -199,66 +273,79 @@
var height = rows * rowHeight + pad * 2;
var usable = totalWidth - nodeWidth - pad * 2;
var xs = [0, 0.28, 0.54, 0.78, 1].map(function (f) { return Math.round(pad + nodeWidth / 2 + f * usable); });
var heightOf = function (n) { return n.open ? groupHeight(n.shown.length) : nodeHeight; };
var at = {};
// Rows at a fixed pitch from the top, each column centred on the tallest: opening a group only moves the boxes
// of its own column
// Rows at a fixed pitch from the top, each column centred on the tallest; an open box pushes the boxes under it
// down by what it grew, so opening a group only moves the boxes of its own column
byColumn.forEach(function (column, c) {
var top = pad + (rows - column.length) * rowHeight / 2;
column.forEach(function (n, i) { at[n.id] = { x: xs[c], y: Math.round(top + rowHeight * (i + 0.5)), column: c }; });
});
moved = moved || {};
var clampX = function (x) { return Math.round(Math.min(totalWidth - pad - nodeWidth / 2, Math.max(pad + nodeWidth / 2, x))); };
var place = function (a, x, y) { a.x = clampX(x); a.y = Math.round(Math.max(pad + nodeHeight / 2, y)); height = Math.max(height, a.y + nodeHeight / 2 + pad); };
// Boxes the viewer dragged
graph.nodes.forEach(function (n) {
var m = moved[n.id];
if (n.group || !m || !(m.fx >= 0) || !(m.y >= 0)) return;
place(at[n.id], m.fx * totalWidth, m.y);
});
// An opened group's members stack from where the group is: under the Game/Web clients box, or where the zone's
// box stood (or was dragged to)
var members = {};
graph.nodes.forEach(function (n) { if (n.group) (members[n.group] = members[n.group] || []).push(n); });
Object.keys(members).forEach(function (group) {
var list = members[group], owner = at[group], m = moved[group], x, y0;
if (owner) { x = owner.x; y0 = owner.y + rowHeight * 0.8; }
else if (m && m.fx >= 0 && m.y >= 0) { x = m.fx * totalWidth; y0 = m.y; }
else { x = at[list[0].id].x; y0 = at[list[0].id].y; }
list.forEach(function (n, k) {
var own = moved[n.id];
if (own && own.fx >= 0 && own.y >= 0) place(at[n.id], own.fx * totalWidth, own.y);
else place(at[n.id], x, y0 + k * rowHeight * (owner ? 0.8 : 1));
var top = pad + (rows - column.length) * rowHeight / 2, extra = 0;
column.forEach(function (n, i) {
at[n.id] = { x: xs[c], y: Math.round(top + rowHeight * (i + 0.5) + extra), h: heightOf(n), column: c };
extra += at[n.id].h - nodeHeight;
});
});
moved = moved || {};
scroll = scroll || {};
var clampX = function (x) { return Math.round(Math.min(totalWidth - pad - nodeWidth / 2, Math.max(pad + nodeWidth / 2, x))); };
// Boxes the viewer dragged
graph.nodes.forEach(function (n) {
var m = moved[n.id], a = at[n.id];
if (!m || !(m.fx >= 0) || !(m.y >= 0)) return;
a.x = clampX(m.fx * totalWidth);
a.y = Math.round(Math.max(pad + nodeHeight / 2, m.y));
});
graph.nodes.forEach(function (n) { var a = at[n.id]; height = Math.max(height, a.y - nodeHeight / 2 + a.h + pad); });
// An open box's rows in view of its list are link ends of their own, on the box's sides
var lists = {}, anchors = {};
graph.nodes.forEach(function (n) {
if (!n.open) return;
var a = at[n.id], top = a.y - nodeHeight / 2 + GROUP.head + GROUP.filter;
var visible = Math.max(1, Math.min(GROUP.maxRows, n.shown.length)) * GROUP.row;
var offset = Math.max(0, Math.min(num(scroll[n.id]), n.shown.length * GROUP.row - visible));
lists[n.id] = { top: top, height: visible, rows: n.shown.length, scroll: offset };
n.shown.forEach(function (id, i) {
var y = top + (i + 0.5) * GROUP.row - offset;
if (y < top || y > top + visible) return; // scrolled out of view: no links
anchors[id] = { x: a.x, y: Math.round(y), h: GROUP.row, column: a.column, row: true };
});
});
var paths = {};
graph.edges.forEach(function (e) {
var a = at[e.from], b = at[e.to];
var a = at[e.from] || anchors[e.from], b = at[e.to] || anchors[e.to];
if (!a || !b) return;
paths[e.id] = { fwd: curve(a, b, nodeWidth, 3.5, nodeHeight), back: curve(b, a, nodeWidth, 3.5, nodeHeight) };
});
return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, paths: paths };
return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, lists: lists, anchors: anchors, paths: paths };
}
// A curve between two boxes, leaving and entering on the sides that face each other (left/right when they are apart
// sideways, top/bottom when one is above the other), moved `offset` px to its left so the two directions sit side by
// side. Boxes in the same column with others between them bulge out sideways instead of cutting through.
// A curve between two boxes (or an open box's rows), leaving and entering on the sides that face each other
// (left/right when they are apart sideways, top/bottom when one is above the other), moved `offset` px to its left so
// the two directions sit side by side. Boxes in the same column with others between them, and rows of a box with
// something in their own column, bulge out sideways instead of cutting through.
function curve(a, b, nodeWidth, offset, nodeHeight) {
var half = nodeWidth / 2, halfH = (nodeHeight || 54) / 2, x1, y1, x2, y2, c1, c2;
var half = nodeWidth / 2, x1, y1, x2, y2, c1, c2;
var halfA = (a.row ? a.h : nodeHeight || 54) / 2, halfB = (b.row ? b.h : nodeHeight || 54) / 2;
var dx0 = b.x - a.x, dy0 = b.y - a.y;
if (Math.abs(dx0) >= nodeWidth * 0.75) {
var dir = dx0 > 0 ? 1 : -1;
x1 = a.x + dir * half; x2 = b.x - dir * half; y1 = a.y; y2 = b.y;
var mid = (x2 - x1) / 2;
c1 = [x1 + mid, y1]; c2 = [x2 - mid, y2];
} else if (Math.abs(dy0) > nodeHeight * 2.2 && a.column === b.column && Math.abs(dx0) < 1) {
// Same column, far apart: out of the side with room and back in, clear of the boxes between
} else if ((a.row || b.row) || (Math.abs(dy0) > nodeHeight * 2.2 && a.column === b.column && Math.abs(dx0) < 1)) {
// Same column, far apart or from a row: out of the side with room and back in, clear of the boxes between
var side = a.column === COLUMNS.length - 1 ? -1 : 1;
x1 = a.x + side * half; x2 = b.x + side * half; y1 = a.y; y2 = b.y;
var bulge = side * Math.max(50, Math.abs(dy0) * 0.35);
c1 = [x1 + bulge, y1]; c2 = [x2 + bulge, y2];
} else {
var v = dy0 >= 0 ? 1 : -1;
x1 = a.x; x2 = b.x; y1 = a.y + v * halfH; y2 = b.y - v * halfH;
// From the bottom of the upper box (an open one's too) to the top of the lower
var nh = nodeHeight || 54;
var top = function (p, half) { return p.row ? p.y - half : p.y - nh / 2; };
var bottom = function (p, half) { return p.row ? p.y + half : p.y - nh / 2 + (p.h || nh); };
x1 = a.x; x2 = b.x;
if (dy0 >= 0) { y1 = bottom(a, halfA); y2 = top(b, halfB); } else { y1 = top(a, halfA); y2 = bottom(b, halfB); }
var midY = (y2 - y1) / 2;
c1 = [x1, y1 + midY]; c2 = [x2, y2 - midY];
}
@@ -269,6 +356,6 @@
return 'M' + p(x1, y1) + ' C' + p(c1[0], c1[1]) + ' ' + p(c2[0], c2[1]) + ' ' + p(x2, y2);
}
var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed };
var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed, matches: matches, groupHeight: groupHeight, GROUP: GROUP };
root.NetworkGraph = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -31,11 +31,29 @@
.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; }
@media (prefers-reduced-motion: reduce) { .nf-svg .flow, .nf-svg .enter, .nf-svg.settling .edge { animation: none; } .nf-svg .node.anim { transition: none; } }
/* 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; }
@@ -57,7 +75,7 @@
<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 with its ▸ button or a double click. The layout and open groups are kept in this browser. Rates are over each server's last report (5 seconds).
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. The layout and open groups 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">
@@ -84,6 +102,7 @@
<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>
@@ -121,6 +140,9 @@
var PEAK_SECONDS = 300;
var summary = null, peers = null, selected = null, detail = null;
var expanded = {}, clientsExpanded = false, webExpanded = false, kind = '', search = '';
// 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;
@@ -129,7 +151,8 @@
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');
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';
@@ -142,7 +165,12 @@
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; }
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 : owner && owner.kind === 'web' ? memberName(n) : n.label;
}
// Keeps each link's value for PEAK_SECONDS, to colour it by its load against its own recent peak
function remember(graph, now) {
@@ -157,27 +185,37 @@
var graphNodes = {}, graph = null;
function render() {
if (!summary) return;
graph = G.build(summary, { expanded: expanded, clientsExpanded: clientsExpanded, webExpanded: webExpanded, peers: peers ? peers.peers : null });
graph = G.build(summary, { expanded: expanded, clientsExpanded: clientsExpanded, webExpanded: webExpanded, peers: peers ? peers.peers : null,
addressesShown: !!(peers && peers.addresses_shown), filters: filters });
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';
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(width);
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);
return Math.max(320, Math.floor(box.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight))) || 800;
}
// 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'; }
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 {
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 {
var parts = [];
if (n.instances > 1) parts.push(n.instances + ' instances');
if (n.connections) parts.push(nf.format(n.connections) + ' conn');
@@ -192,24 +230,54 @@
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.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(p.address);
}
return parts.join(' · ');
}
function memberTitle(m) {
if (m.kind === 'world') return m.title;
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, zoneFirst = {};
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;
function renderDiagram(width) {
var before = animateNext && lastLayout ? lastLayout.nodes : null;
var beforeZones = zoneFirst;
animateNext = false;
var L = G.layout(graph, width, moved);
var L = G.layout(graph, width, moved, scrolls);
lastLayout = L;
zoneFirst = {};
graph.nodes.forEach(function (n) { if (n.kind === 'world' && !zoneFirst[n.zone]) zoneFirst[n.zone] = L.nodes[n.id]; });
busiest = graph.edges.reduce(function (m, e) { return Math.max(m, edgeValue(e)); }, 0);
var html = '';
graph.edges.forEach(function (e) {
var p = L.paths[e.id];
if (!p) return;
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') + '" data-edge="' + esc(e.id) + '">';
@@ -225,23 +293,22 @@
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 canToggleName = n.kind === 'zone' && n.instances > 1 || n.kind === 'world' && expanded[n.zone] || n.kind === 'clients' || n.kind === 'web';
var nameChars = canToggleName ? chars - 8 : chars;
var toggle = canToggle(n), open = isOpen(n);
var nameChars = toggle ? chars - 5 : chars;
var name = n.label.length > nameChars ? n.label.slice(0, nameChars - 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"/>' +
html += '<g class="node' + (selected === n.id ? ' selected' : '') + '" data-node="' + esc(n.id) + '" tabindex="0" role="button" aria-label="' + esc(n.label) + '"' + (toggle ? ' aria-expanded="' + open + '"' : '') + '>' +
'<rect class="box" x="' + x + '" y="' + y + '" width="' + L.nodeWidth + '" height="' + at.h + '" 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) {
var pill = open ? '▾ group' : n.kind === 'zone' ? '▸ ' + n.instances : '▸ each';
var pw = pill.length * 6.2 + 12, px = x + L.nodeWidth - pw - 6, py = y + 5;
var tipText = open ? (n.kind === 'world' ? 'Show the zone as one box' : 'Hide the separate connections') : (n.kind === 'zone' ? 'Show each instance' : 'Show the busiest connections one by one');
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 = 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 = 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>';
@@ -249,47 +316,222 @@
html += '</g>';
});
svg.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height);
svg.setAttribute('width', L.width);
svg.setAttribute('height', L.height);
svg.innerHTML = html;
if (before) glide(before, beforeZones, L);
var fresh = renderLayer(L);
if (before) glide(before, L, fresh);
}
// After opening or closing a group: boxes glide from where they were, new ones grow out of their group, links fade in
function glide(before, beforeZones, L) {
// 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, oy = sr.top - br.top - box.clientTop, fresh = {};
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);
svg.querySelectorAll('.node').forEach(function (g) {
var id = g.getAttribute('data-node'), n = graphNodes[id], now = L.nodes[id];
if (!now) return;
var from = before[id] || (n && n.group && (before[n.group] || before['zone:' + n.zone])) ||
(n && n.kind === 'zone' && beforeZones[n.zone]) || null; // a zone box coming back: where its first instance was
if (!before[id]) g.classList.add('enter');
if (!from || (from.x === now.x && from.y === now.y)) return;
g.style.transform = 'translate(' + (from.x - now.x) + 'px,' + (from.y - now.y) + 'px)';
var move = function (el, from, now) {
el.style.transform = 'translate(' + (from.x - now.x) + 'px,' + (from.y - now.y) + 'px)';
requestAnimationFrame(function () {
g.classList.add('anim');
g.style.transform = '';
setTimeout(function () { g.classList.remove('anim'); }, 320);
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
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), graph.edges.reduce(function (m, x) { return Math.max(m, edgeValue(x)); }, 0));
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>';
// 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 head = '<div class="d-flex justify-content-between gap-2"><b>' + esc(n.label) + '</b><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');
@@ -317,7 +559,7 @@
svg.addEventListener('mouseleave', function () { tip.classList.add('d-none'); });
function toggleGroup(n) {
if (n.kind === 'zone' || n.kind === 'world') { if (expanded[n.zone]) delete expanded[n.zone]; else expanded[n.zone] = true; }
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 return false;
@@ -328,16 +570,25 @@
}
function onNode(ev) {
if (ev.target.closest('input')) 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.dataset.node];
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) {
@@ -371,13 +622,17 @@
svg.addEventListener('dblclick', function (ev) {
var el = ev.target.closest('[data-node]');
var n = el && graphNodes[el.getAttribute('data-node')];
if (!n || (n.kind === 'zone' && !(n.instances > 1))) return;
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].forEach(function (el) {
el.addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onNode(ev); } });
[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) {
@@ -417,15 +672,15 @@
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>';
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.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>';
}).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];
@@ -454,15 +709,15 @@
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();
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</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>';
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) : '';
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) {

View File

@@ -61,17 +61,46 @@ same(graph.nodes.find((n) => n.id === 'web').websockets, 2, 'live dashboard page
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
// Opening the zone: its instances are rows of its box, each with its own links, and the box has none of its own
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');
const zoneBox = open.nodes.find((n) => n.id === 'zone:1200');
same(zoneBox.open, true, 'the zone is open');
same(zoneBox.members.map((m) => m.id), ['world:1200:1', 'world:1200:2'], 'its instances are its rows, in order');
same(zoneBox.shown, ['world:1200:1', 'world:1200:2'], 'all shown without a filter');
same(open.nodes.filter((n) => n.kind === 'world').length, 0, 'no box per instance');
same(open.edges.find((e) => e.id === 'clients>world:1200:2').fwd.packets, 9, 'an instance\'s own link');
same(open.edges.find((e) => e.id === 'world:1200:1>chat').fwd.packets, 1, 'an instance\'s chat link');
same(open.edges.some((e) => e.from === 'zone:1200' || e.to === 'zone:1200'), false, 'no links from the open zone\'s header');
same(open.members['world:1200:2'].label, '#2', 'a row is its instance');
same(G.build(summary, { expanded: { 1200: true }, filters: { 'zone:1200': '#2' } }).nodes.find((n) => n.id === 'zone:1200').shown, ['world:1200:2'], 'filtered by instance');
// 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 peers = [{ address: 'peer-0001', kind: 'game', account: 'alice', character: 'Alice', servers: [{ server: 'world:1200:1', label: 'World 1200 Nimbus Station #1', packets_in: 5, packets_out: 9, bytes_in: 500, bytes_out: 900, ping_ms: 40 }] },
{ address: '198.51.100.7', kind: 'web', account_id: 7, user: 'mod-alice', servers: [{ server: 'dashboard', http: true, packets_in: 1, packets_out: 1, bytes_in: 200, bytes_out: 3000 }] },
{ address: '198.51.100.7', kind: 'web', account_id: 9, user: 'mod-bob', servers: [{ server: 'dashboard', http: true, packets_in: 2, packets_out: 2, bytes_in: 100, bytes_out: 1000 }] },
{ address: '203.0.113.9', kind: 'web', servers: [{ server: 'ugc', http: true, packets_in: 1, packets_out: 1, bytes_in: 10, bytes_out: 5000 }] }];
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');
const clientsBox = withPeers.nodes.find((n) => n.id === 'clients');
same(clientsBox.open, true, 'game clients open');
same(clientsBox.members.map((m) => m.label), ['203.0.113.9', 'Alice'], 'players, and HTTP clients of the UGC server only, are game clients');
same(withPeers.nodes.find((n) => n.id === 'web').open, false, 'web clients stay closed');
same(withPeers.edges.find((e) => e.id === 'peer:clients:peer-0001>zone:1200:peer').back.bytes, 900, 'a client\'s own link');
same(withPeers.edges.some((e) => e.from === 'clients' || e.to === 'clients'), false, 'no summed links from the open header');
same(withPeers.edges.some((e) => e.id === 'web>dashboard:http'), true, 'the closed web clients keep theirs');
// Game clients open while the list is loading: the box keeps its summed links
same(G.build(summary, { clientsExpanded: true }).edges.some((e) => e.from === 'clients'), true, 'summed links until the list is there');
// Web clients: a row per signed-in dashboard user, even on one address, filtered by name, user and (only when shown) address
const webOpen = G.build(summary, { webExpanded: true, peers });
same(webOpen.nodes.find((n) => n.id === 'web').members.map((m) => m.label), ['mod-alice', 'mod-bob'], 'one row per user');
same(webOpen.edges.filter((e) => e.from.startsWith('peer:web:')).map((e) => e.fwd.packets), [1, 2], 'each user\'s own requests');
same(G.build(summary, { webExpanded: true, peers, filters: { web: 'BOB' } }).nodes.find((n) => n.id === 'web').shown.length, 1, 'filter by user, any case');
same(G.build(summary, { webExpanded: true, peers, filters: { web: 'bob' } }).edges.filter((e) => e.from.startsWith('peer:web:')).length, 1, 'only the shown rows have links');
same(G.build(summary, { webExpanded: true, peers, filters: { web: '198.51' } }).nodes.find((n) => n.id === 'web').shown.length, 0, 'addresses are not searched without network_ips');
same(G.build(summary, { webExpanded: true, peers, addressesShown: true, filters: { web: '198.51' } }).nodes.find((n) => n.id === 'web').shown.length, 2, 'and are with it');
same(G.build(summary, { clientsExpanded: true, peers, filters: { clients: 'nimbus' } }).nodes.find((n) => n.id === 'clients').shown.length, 1, 'filter by the instance a player is on');
same(G.matches({ search: 'alice nimbus station' }, 'nim ali'), true, 'every word matches');
// No servers: nothing to draw but nodes with nothing behind them are left out
same(G.build({ servers: {} }, {}).nodes.length, 0, 'empty summary');
@@ -83,7 +112,7 @@ 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
// Layout: columns left to right, every link a path each way (the lane offset moves an end by up to 3.5 px)
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 && L.nodes.dashboard.x < L.nodes.web.x, true, 'columns, web clients right of the dashboard');
@@ -97,6 +126,44 @@ same(L.nodes.clients.x < L.nodes.auth.x && L.nodes.auth.x < L.nodes.master.x &&
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');
// Open groups grow by their rows, and their rows are the link ends
{
const many = peers.concat(Array.from({ length: 10 }, (_, i) => ({ address: 'peer-1' + i, kind: 'web', account_id: 100 + i, user: 'user' + String(i).padStart(2, '0'),
servers: [{ server: 'dashboard', http: true, packets_in: 1, packets_out: 1, bytes_in: 1, bytes_out: 1 }] })));
const g = G.build(summary, { webExpanded: true, peers: many });
const box = g.nodes.find((n) => n.id === 'web');
same(box.shown.length, 12, 'twelve web users');
const O = G.layout(g, 1000);
const closedHeight = L.nodes.web.h;
same(closedHeight, L.nodeHeight, 'a closed box is its usual height');
same(O.nodes.web.h, G.groupHeight(12), 'the open box grows');
same(G.groupHeight(12), G.groupHeight(8), 'up to eight rows, the rest scroll');
same(G.groupHeight(3) > G.groupHeight(2) && G.groupHeight(2) > closedHeight, true, 'taller with more rows');
same(G.layout(G.build(summary, { webExpanded: true, peers: many, filters: { web: 'mod-' } }), 1000).nodes.web.h, G.groupHeight(2), 'shrinks to what the filter shows');
const inView = box.shown.filter((id) => O.anchors[id]);
same(inView, box.shown.slice(0, 8), 'the first eight rows are anchors');
same(O.anchors[box.shown[1]].y - O.anchors[box.shown[0]].y, G.GROUP.row, 'a row apart');
same(O.anchors[box.shown[0]].y > O.nodes.web.y && O.anchors[box.shown[0]].y > O.lists.web.top, true, 'rows are under the header and the filter');
same(Object.keys(O.paths).some((id) => id.startsWith('web>') || id.includes('>web')), false, 'no links from the open header');
const rowEdge = g.edges.find((e) => e.from === box.shown[0]);
const startX = Number(/^M([\d.]+),/.exec(O.paths[rowEdge.id].fwd)[1]);
same(Math.abs(startX - (O.nodes.web.x - O.nodeWidth / 2)) <= 3.5, true, 'a row\'s link leaves from the box\'s side');
same(g.edges.filter((e) => e.from === box.shown[10]).every((e) => !O.paths[e.id]), true, 'rows scrolled out of view have no links');
const S = G.layout(g, 1000, {}, { web: 4 * G.GROUP.row });
same(box.shown.filter((id) => S.anchors[id]), box.shown.slice(4, 12), 'scrolled by four rows');
same(G.layout(g, 1000, {}, { web: 1e6 }).lists.web.scroll, 4 * G.GROUP.row, 'scrolling stops at the end');
same(O.nodes.dashboard, L.nodes.dashboard, 'other columns stay put');
}
{
// An open zone pushes the boxes under it down by what it grew
const Z = G.layout(G.build(summary, { expanded: { 1200: true } }), 1000);
same(Z.nodes['service:99:0:0'].y - L.nodes['service:99:0:0'].y, G.groupHeight(2) - L.nodeHeight, 'the box under the zone moves down');
same(Z.nodes.auth.y, L.nodes.auth.y, 'the boxes above stay');
same(Z.height >= Z.nodes['zone:1200'].y - Z.nodeHeight / 2 + Z.nodes['zone:1200'].h, true, 'the canvas fits the open box');
same(['world:1200:1', 'world:1200:2'].every((id) => Z.anchors[id]), true, 'instance rows are anchors');
same(Z.paths['clients>world:1200:1'] && Z.paths['world:1200:1>chat'] ? true : false, true, 'instance rows are linked on both sides');
}
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);