diff --git a/dDashboardServer/static/js/network-graph.js b/dDashboardServer/static/js/network-graph.js index 37ca5fd18..81ce18c53 100644 --- a/dDashboardServer/static/js/network-graph.js +++ b/dDashboardServer/static/js/network-graph.js @@ -31,7 +31,7 @@ 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 }; + 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 }; @@ -47,7 +47,7 @@ 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, + 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, 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 }; } @@ -144,7 +144,7 @@ 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 }); + 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; (peer.servers || []).forEach(function (entry) { var target = servers[entry.server]; @@ -200,16 +200,35 @@ 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 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 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 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 }; }); }); - Object.keys(moved || {}).forEach(function (id) { - var m = moved[id], a = at[id]; - if (!a || !(m.fx >= 0) || !(m.y >= 0)) return; - a.x = Math.round(Math.min(totalWidth - pad - nodeWidth / 2, Math.max(pad + nodeWidth / 2, m.fx * totalWidth))); - a.y = Math.round(Math.max(pad + nodeHeight / 2, m.y)); - height = Math.max(height, a.y + nodeHeight / 2 + pad); + 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 paths = {}; graph.edges.forEach(function (e) { diff --git a/dDashboardServer/templates/network.jinja2 b/dDashboardServer/templates/network.jinja2 index 1b9fe7c23..507586a74 100644 --- a/dDashboardServer/templates/network.jinja2 +++ b/dDashboardServer/templates/network.jinja2 @@ -25,9 +25,17 @@ .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 .node .toggle { cursor: pointer; } + .nf-svg .node .toggle rect { fill: var(--bs-primary-bg-subtle); stroke: var(--bs-primary-border-subtle); stroke-width: 1; } + .nf-svg .node .toggle:hover rect { fill: var(--bs-primary); } + .nf-svg .node .toggle text { fill: var(--bs-primary-text-emphasis); font-size: 11px; font-weight: 600; } + .nf-svg .node .toggle:hover text { fill: var(--bs-white); } + .nf-svg .node.anim { transition: transform .28s ease; } + .nf-svg .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 { animation: none; } } + @media (prefers-reduced-motion: reduce) { .nf-svg .flow, .nf-svg .enter, .nf-svg.settling .edge { animation: none; } .nf-svg .node.anim { 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; } @@ -49,7 +57,7 @@

Network

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; the layout is 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 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). Hover a line for its numbers, click a box for its details. History is on Diagnostics.

@@ -113,6 +121,12 @@ var PEAK_SECONDS = 300; var summary = null, peers = null, selected = null, detail = null; var expanded = {}, clientsExpanded = false, webExpanded = false, kind = '', search = ''; + try { + var open = JSON.parse(localStorage.getItem('network.open') || '{}') || {}; + expanded = open.zones || {}; clientsExpanded = !!open.clients; webExpanded = !!open.web; + } catch (e) { /* private window */ } + function saveOpen() { try { localStorage.setItem('network.open', JSON.stringify({ zones: expanded, clients: clientsExpanded, web: webExpanded })); } catch (e) { /* ignore */ } } + var animateNext = false; var view = (document.querySelector('input[name="nfView"]:checked') || {}).value || 'diagram'; var history = {}; // edge id -> [[time, value]] for the load colours var svg = document.getElementById('nfSvg'), box = document.getElementById('nfBox'), stack = document.getElementById('nfStack'); @@ -179,13 +193,18 @@ } // Boxes the viewer dragged, kept in this browser: {id: {fx, y}} - var moved = {}, lastLayout = null; + var moved = {}, lastLayout = null, zoneFirst = {}; 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); 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) { @@ -207,7 +226,9 @@ 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 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 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 += '' + @@ -217,12 +238,42 @@ html += '' + esc(line.length > chars + 4 ? line.slice(0, chars + 3) + '…' : line) + ''; }); if (n.noSplit) html += 'This server reports only totals: its links are estimated≈'; - else if (canToggle) html += '' + (open ? '−' : '+') + ''; + 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'); + html += '' + esc(tipText) + '' + + '' + + '' + esc(pill) + ''; + } html += ''; }); svg.setAttribute('viewBox', '0 0 ' + L.width + ' ' + L.height); svg.setAttribute('height', L.height); svg.innerHTML = html; + if (before) glide(before, beforeZones, L); + } + + // 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) { + 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)'; + requestAnimationFrame(function () { + g.classList.add('anim'); + g.style.transform = ''; + setTimeout(function () { g.classList.remove('anim'); }, 320); + }); + }); } // Phones: every box with its links, busiest first @@ -265,19 +316,24 @@ }); 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; } + else if (n.kind === 'clients') { clientsExpanded = !clientsExpanded; if (clientsExpanded) loadPeers(); } + else if (n.kind === 'web') { webExpanded = !webExpanded; if (webExpanded) loadPeers(); } + else return false; + saveOpen(); + animateNext = true; + render(); + return true; + } + 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; - } + if (toggle) { toggleGroup(n); return; } selected = n.id; render(); showDetail(n); @@ -311,7 +367,13 @@ } svg.addEventListener('pointerup', endDrag); svg.addEventListener('pointercancel', endDrag); - document.getElementById('nfResetLayout').addEventListener('click', function () { moved = {}; saveMoved(); render(); }); + document.getElementById('nfResetLayout').addEventListener('click', function () { moved = {}; saveMoved(); animateNext = true; render(); }); + svg.addEventListener('dblclick', function (ev) { + var el = ev.target.closest('[data-node]'); + var n = el && graphNodes[el.getAttribute('data-node')]; + if (!n || (n.kind === 'zone' && !(n.instances > 1))) return; + if (toggleGroup(n)) ev.preventDefault(); + }); svg.addEventListener('click', function (ev) { if (justDragged) { ev.stopPropagation(); return; } onNode(ev); }); stack.addEventListener('click', onNode); [svg, stack].forEach(function (el) {