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