fix(dashboard): Network page in six columns with chat in its own, boxes that leave room, links spread along each side

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 23:14:40 -05:00
parent bc45cd444d
commit f86f173cae
3 changed files with 52 additions and 16 deletions

View File

@@ -12,13 +12,14 @@
* (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
* layout(graph, width, moved, scroll) places the nodes in six 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
var COLUMNS = [0, 1, 2, 3, 4, 5]; // game clients | auth, worlds, others | chat | master | dashboard, UGC | web clients
// Chat has a column of its own between the worlds and master: every world has a link to it
// 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: 58, filter: 30, row: 30, maxRows: 8, foot: 6 }; // head: a name on up to two lines and one line under it
@@ -30,10 +31,10 @@
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 };
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 'CHAT': return { id: 'chat', kind: 'chat', label: 'Chat', column: 2, order: 0 };
case 'MASTER': return { id: 'master', kind: 'master', label: 'Master', column: 3, order: 0 };
case 'DASHBOARD': return { id: 'dashboard', kind: 'dashboard', label: 'Dashboard', column: 4, order: 0 };
case 'UGC': return { id: 'ugc', kind: 'ugc', label: 'UGC server', column: 4, order: 1 };
case 'WORLD': {
var zone = num(server.zone);
// "World 1200 Nimbus Station #3" -> "World 1200 Nimbus Station"
@@ -105,7 +106,7 @@
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 });
var web = node({ id: 'web', kind: 'web', label: 'Web clients', column: 5, order: 0 });
var hasChat = keys.some(function (k) { return servers[k].type === 'CHAT'; });
keys.forEach(function (key) {
@@ -266,13 +267,14 @@
* 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, scroll) {
var nodeWidth = Math.max(140, Math.min(240, Math.floor(totalWidth / 5.4))), nodeHeight = 68, rowHeight = 88, pad = 16;
// Boxes take at most 60% of the space between column centres, so there is always room for the links between them
var nodeWidth = Math.max(120, Math.min(230, Math.floor((totalWidth - 32) / 9))), nodeHeight = 68, rowHeight = 88, 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.28, 0.54, 0.78, 1].map(function (f) { return Math.round(pad + nodeWidth / 2 + f * usable); });
var xs = [0, 0.2, 0.4, 0.6, 0.8, 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; an open box pushes the boxes under it
@@ -311,11 +313,35 @@
});
});
// Links leaving a box's side spread down that side, ordered by where their other end is, instead of all meeting
// at one point (an open box's rows keep their own row)
var ends = {}; // "id|side" -> [{edge, end, otherY}]
var endOf = function (id) { return at[id] || anchors[id]; };
graph.edges.forEach(function (e) {
var a = endOf(e.from), b = endOf(e.to);
if (!a || !b || Math.abs(b.x - a.x) < nodeWidth * 0.75) return;
var dir = b.x > a.x ? 1 : -1;
if (!a.row) (ends[e.from + '|' + dir] = ends[e.from + '|' + dir] || []).push({ edge: e.id, end: 'a', other: b.y });
if (!b.row) (ends[e.to + '|' + (-dir)] = ends[e.to + '|' + (-dir)] || []).push({ edge: e.id, end: 'b', other: a.y });
});
var ports = {}; // edgeId -> {a: y, b: y}
Object.keys(ends).forEach(function (key) {
var list = ends[key], box = at[key.split('|')[0]];
if (!box) return;
list.sort(function (p, q) { return p.other - q.other; });
var top = box.y - nodeHeight / 2 + 12, span = Math.min(box.h, nodeHeight) - 24;
list.forEach(function (p, i) {
var y = list.length === 1 ? box.y : top + span * i / (list.length - 1);
(ports[p.edge] = ports[p.edge] || {})[p.end] = Math.round(y);
});
});
var withPort = function (p, y) { return y === undefined ? p : { x: p.x, y: y, h: p.h, column: p.column, row: p.row, port: true }; };
var paths = {};
graph.edges.forEach(function (e) {
var a = at[e.from] || anchors[e.from], b = at[e.to] || anchors[e.to];
var a = endOf(e.from), b = endOf(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) };
var pa = withPort(a, ports[e.id] && ports[e.id].a), pb = withPort(b, ports[e.id] && ports[e.id].b);
paths[e.id] = { fwd: curve(pa, pb, nodeWidth, 3.5, nodeHeight), back: curve(pb, pa, nodeWidth, 3.5, nodeHeight) };
});
return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, lists: lists, anchors: anchors, paths: paths };
}

View File

@@ -755,9 +755,10 @@ How it is counted:
### Network
**Network** (`health_view`, next to Diagnostics) draws the traffic live, from the same reports: game clients on the
left, auth, chat, the worlds (one box per zone) and any other server that reports in the middle,
then master, then the dashboard and the UGC server, and web clients (browsers and API users) right of the dashboard. Servers appear as they report, so a new kind of
**Network** (`health_view`, next to Diagnostics) draws the traffic live, from the same reports, in six columns: game clients on the
left, then auth, the worlds (one box per zone) and any other server that reports, then chat (every world links to it),
then master, then the dashboard and the UGC server, and web clients (browsers and API users) on the right. Links leaving
a side of a box spread down it in the order of their other ends, so they don't all meet at one point. Servers appear as they report, so a new kind of
server shows up without changes. Each link has a lane each way, as thick as its bytes per second, with dashes moving
faster with more packets, and coloured by its load against its own peak over the last 5 minutes; hover it for the
numbers. Boxes show connections, average ping, resends, busy workers and live dashboard pages. Clicking a box shows its

View File

@@ -39,7 +39,7 @@ const summary = {
const graph = G.build(summary, {});
const ids = graph.nodes.map((n) => n.id);
same(ids, ['clients', 'auth', 'chat', 'zone:1200', 'service:99:0:0', 'master', 'dashboard', 'ugc', 'web'], 'nodes in column order, one per zone');
same(ids, ['clients', 'auth', 'zone:1200', 'service:99:0:0', 'chat', 'master', 'dashboard', 'ugc', 'web'], 'nodes in column order, one per zone, chat in its own column');
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');
@@ -114,7 +114,7 @@ same(G.speed(1e6) < G.speed(1), true, 'busier moves faster');
// 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');
same(L.nodes.clients.x < L.nodes.auth.x && L.nodes.auth.x < L.nodes.chat.x && L.nodes.chat.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');
// A dragged box goes where it was put, its links follow, and the box grows the canvas if needed
{
@@ -169,3 +169,12 @@ if (failures) {
process.exit(1);
}
console.log('network-graph: all passed');
// Boxes never overlap the next column, and links leaving one side of a box spread down it
{
const W = G.layout(graph, 1650);
const xs = [...new Set(Object.values(W.nodes).map(n => n.x))].sort((a, b) => a - b);
for (let i = 1; i < xs.length; i++) same(xs[i] - xs[i - 1] >= W.nodeWidth + 40, true, 'room between columns ' + i);
const starts = Object.keys(W.paths).filter(id => id.includes('master')).map(id => W.paths[id].fwd);
same(new Set(starts.map(d => d.split(' ')[0])).size > 1 || starts.length < 2, true, 'links at master do not all meet at one point');
}