mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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>
789 lines
44 KiB
Django/Jinja
789 lines
44 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); }
|
|
.nf-box { position: relative; }
|
|
.nf-svg { display: block; width: 100%; }
|
|
.nf-svg .lane { fill: none; stroke-linecap: round; opacity: .55; }
|
|
.nf-svg .flow { fill: none; stroke-linecap: round; stroke-dasharray: 2 10; animation: nf-dash linear infinite; }
|
|
.nf-svg .estimated .lane { stroke-dasharray: 6 5; }
|
|
.nf-svg .load-idle { stroke: var(--nf-idle); }
|
|
.nf-svg .load-low { stroke: var(--nf-low); }
|
|
.nf-svg .load-mid { stroke: var(--nf-mid); }
|
|
.nf-svg .load-high { stroke: var(--nf-high); }
|
|
.nf-svg .edge .hit { fill: none; stroke: transparent; stroke-width: 16; cursor: help; }
|
|
.nf-svg .node rect { fill: var(--bs-body-bg); stroke: var(--bs-border-color); stroke-width: 1.5; }
|
|
.nf-svg .node { cursor: 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. 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">
|
|
<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>
|
|
</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 = '';
|
|
// 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;
|
|
} 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'), 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 : 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) {
|
|
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 });
|
|
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);
|
|
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.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');
|
|
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.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;
|
|
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;
|
|
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 = '';
|
|
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') + '" data-edge="' + esc(e.id) + '">';
|
|
lanes.forEach(function (lane) {
|
|
var side = lane[1], value = lane[2] ? side.bytes : side.packets * 200;
|
|
var w = G.width(value), dur = G.speed(side.packets);
|
|
html += '<path class="lane load-' + (value > 0 ? load : 'idle') + '" stroke-width="' + w + '" d="' + p[lane[0]] + '"/>';
|
|
if (dur) html += '<path class="flow load-' + load + '" stroke-width="' + Math.max(1.5, w * 0.6) + '" style="animation-duration:' + dur + 's" d="' + p[lane[0]] + '"/>';
|
|
});
|
|
html += '<path class="hit" d="' + p.fwd + '"/></g>';
|
|
});
|
|
graph.nodes.forEach(function (n) {
|
|
var at = L.nodes[n.id];
|
|
var x = at.x - L.nodeWidth / 2, y = at.y - L.nodeHeight / 2;
|
|
var meta = nodeMeta(n);
|
|
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 name = n.label.length > nameChars ? n.label.slice(0, nameChars - 1) + '…' : n.label;
|
|
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 && !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>';
|
|
}
|
|
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);
|
|
}
|
|
|
|
// 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);
|
|
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 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');
|
|
tip.className = 'nf-tip d-none';
|
|
box.appendChild(tip);
|
|
svg.addEventListener('mousemove', function (ev) {
|
|
var g = ev.target.closest('[data-edge]');
|
|
if (!g || !graph) { tip.classList.add('d-none'); return; }
|
|
var e = graph.edges.filter(function (x) { return x.id === g.dataset.edge; })[0];
|
|
if (!e) return;
|
|
var peak = peakOf(e.id);
|
|
tip.innerHTML = '<div class="fw-semibold">' + esc(labelOf(e.from)) + ' ⇄ ' + esc(labelOf(e.to)) + '</div>' +
|
|
'<div>→ ' + esc(rateText(e.fwd, e, e.bytesKnown.fwd)) + '</div><div>← ' + esc(rateText(e.back, e, e.bytesKnown.back)) + '</div>' +
|
|
(peak ? '<div class="text-body-secondary">5 min peak: ' + esc(e.fwd.bytes + e.back.bytes ? bytes(peak) + '/s' : nf.format(peak) + '/s') + '</div>' : '') +
|
|
(e.exact ? '' : '<div class="text-warning-emphasis">Estimated</div>') +
|
|
e.notes.map(function (t) { return '<div class="text-body-secondary">' + esc(t) + '</div>'; }).join('') +
|
|
(e.kind === 'http' ? '<div class="text-body-secondary">HTTP: requests, and the bodies answered</div>' : '');
|
|
tip.classList.remove('d-none');
|
|
var r = box.getBoundingClientRect();
|
|
var x = ev.clientX - r.left + 14, y = ev.clientY - r.top + 10;
|
|
if (x + tip.offsetWidth > r.width) x = ev.clientX - r.left - tip.offsetWidth - 14;
|
|
tip.style.left = Math.max(0, x) + 'px';
|
|
tip.style.top = y + 'px';
|
|
});
|
|
svg.addEventListener('mouseleave', function () { tip.classList.add('d-none'); });
|
|
|
|
function 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 return false;
|
|
saveOpen();
|
|
animateNext = true;
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
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.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.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">▸ on the box lists each instance in it, each with its own links.</p>';
|
|
return;
|
|
}
|
|
var key = n.servers[0];
|
|
body.innerHTML = intro + '<p class="text-body-secondary mb-0">Loading…</p>';
|
|
api.get('/api/diagnostics/network/server?key=' + encodeURIComponent(key)).then(function (d) {
|
|
if (detail !== n.id || !d.success) return;
|
|
var gauges = d.gauges || {}, link = d.link || {};
|
|
var facts = [];
|
|
if (link.connections != null) facts.push(nf.format(link.connections) + ' connections, ' + nf.format(link.ping_ms) + ' ms average ping');
|
|
if (link.resends) facts.push(nf.format(link.resends) + ' messages resent in the last report' + (link.resend_queue ? ', ' + nf.format(link.resend_queue) + ' waiting' : ''));
|
|
if (gauges.workers_threads) facts.push('workers ' + nf.format(gauges.workers_busy || 0) + ' of ' + nf.format(gauges.workers_threads) + ' busy' + (gauges.workers_queued ? ', ' + nf.format(gauges.workers_queued) + ' queued' : ''));
|
|
if (gauges.websocket_clients) facts.push(nf.format(gauges.websocket_clients) + ' live dashboard pages');
|
|
if (gauges.http_deferred_pending) facts.push(nf.format(gauges.http_deferred_pending) + ' requests waiting on workers');
|
|
body.innerHTML = intro + (facts.length ? '<ul class="mb-2 ps-3">' + facts.map(function (f) { return '<li>' + esc(f) + '</li>'; }).join('') + '</ul>' : '') +
|
|
sparkline(d.series) +
|
|
'<h6 class="mt-3 mb-1 small fw-semibold">Busiest in <span class="text-body-secondary fw-normal">(5 min)</span></h6>' + messageTable(d.messages, 'in') +
|
|
'<h6 class="mb-1 small fw-semibold">Busiest out</h6>' + messageTable(d.messages, 'out') +
|
|
'<a href="/diagnostics?server=' + encodeURIComponent(key) + '">Open in Diagnostics</a>';
|
|
}).catch(function () {});
|
|
}
|
|
|
|
function renderPeers() {
|
|
var el = document.getElementById('nfPeers');
|
|
if (!peers) return;
|
|
document.getElementById('nfPeersNote').textContent = peers.addresses_shown ? '(each server\'s last report)' : '(addresses hidden: they need the Network addresses permission)';
|
|
var rows = peers.peers.filter(function (p) {
|
|
if (kind && p.kind !== kind) return false;
|
|
if (!search) return true;
|
|
var text = [p.address, p.account, p.character, 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 %}
|