Topology: a readable card per device
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m16s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m42s
Lint / Python (flake8) (pull_request) Successful in 1m41s
Test / Python Tests (pytest) (push) Successful in 2m28s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m22s
Test / Python Tests (pytest) (pull_request) Successful in 1m37s
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 1m16s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m42s
Lint / Python (flake8) (pull_request) Successful in 1m41s
Test / Python Tests (pytest) (push) Successful in 2m28s
Lint / JS (eslint) (pull_request) Successful in 10s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 1m22s
Test / Python Tests (pytest) (pull_request) Successful in 1m37s
Drawn to true scale, the 4U servers dwarfed the switches and no label could be read without zooming. Every device is now a card of the same height: its name and state on top, and below that its real faceplate (cropped to the ports) or, for a server, its front and a socket per NIC. Cables meet the card edge right at the port, with a short tail to it. The internet, modem and gateway sit side by side along the top, and unknown devices share the row of the gear on their switch, so the whole network fits a normal screen. On a phone the canvas takes the diagram's shape instead of leaving most of it empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+19
-2
@@ -1417,7 +1417,7 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
/* ── Topology page ─────────────────────────────────────────────────── */
|
||||
.tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; }
|
||||
.tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
||||
.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; }
|
||||
.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; white-space: nowrap; }
|
||||
.tp-zoom { display: inline-flex; gap: 4px; }
|
||||
.tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); }
|
||||
.tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; }
|
||||
@@ -1504,7 +1504,7 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; }
|
||||
.tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; }
|
||||
.tp-nic-sfp-in { fill: #23272d; }
|
||||
.tp-nic-name { font-family: var(--font); font-size: 18px; fill: #aab4bf; }
|
||||
.tp-nic-name { font-family: var(--font); font-size: 17px; fill: #aab4bf; }
|
||||
.tp-nic-led { fill: #3a3f45; }
|
||||
.tp-nic-up .tp-nic-led { fill: #2fd36b; }
|
||||
.tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; }
|
||||
@@ -1597,3 +1597,20 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; }
|
||||
.tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; }
|
||||
.tp-key-warn { color: #ffb300; font-weight: 700; }
|
||||
|
||||
/* Topology cards */
|
||||
.tp-card { fill: #121821; stroke: #2a3440; stroke-width: 3; }
|
||||
[data-theme="light"] .tp-card { fill: #ffffff; stroke: #c9d1da; }
|
||||
.tp-band { fill: #0a0e13; stroke: #1c242e; stroke-width: 2; }
|
||||
[data-theme="light"] .tp-band { fill: #eef1f4; stroke: #dde3ea; }
|
||||
.tp-card-name { font-family: var(--font); font-size: 44px; font-weight: 700; fill: var(--text); letter-spacing: .01em; }
|
||||
.tp-card-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); }
|
||||
.tp-card-stripe { fill: #6b737d; }
|
||||
.tp-stripe-up { fill: #2fd36b; }
|
||||
.tp-stripe-degraded { fill: #ffb300; }
|
||||
.tp-stripe-down { fill: #e0312b; }
|
||||
.tp-node-link:hover .tp-card, .tp-node-link:focus-visible .tp-card { stroke: var(--amber); }
|
||||
.tp-kind-stub .tp-card, .tp-kind-group .tp-card { stroke-dasharray: 14 10; }
|
||||
.tp-kind-group .tp-node-link { cursor: pointer; }
|
||||
.tp-pigtail { stroke-width: 5 !important; stroke-dasharray: none !important; }
|
||||
[data-theme="light"] .tp-nic-name { fill: #4b5562; }
|
||||
|
||||
+170
-97
@@ -1,16 +1,11 @@
|
||||
'use strict';
|
||||
/* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */
|
||||
// Topology page: every device drawn as its real faceplate or chassis, with each cable leaving
|
||||
// the exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
|
||||
//
|
||||
// World units: a 19" panel is 1100 wide (as on the rack page), so everything is to scale.
|
||||
// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the
|
||||
// exact port it is plugged into. Data comes from /api/topology (topology.build_graph).
|
||||
|
||||
const esc = s => lt.escHtml(s == null ? '' : String(s));
|
||||
const PER_MM = 1100 / 482.6;
|
||||
const NODE_GAP = 120;
|
||||
const LABEL_H = 104;
|
||||
const STRIP_H = 104;
|
||||
const SOCKET_W = 118;
|
||||
const NODE_GAP = 90;
|
||||
const REFRESH_MS = 30000;
|
||||
const FILTER_KEY = 'gandalf.topology.filters';
|
||||
const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, tenG: false, alerts: false };
|
||||
@@ -107,7 +102,15 @@ function portTitle(port, idx, role) {
|
||||
|
||||
function faceplateDrawing(node, model, width, opts = {}) {
|
||||
const spec = FACEPLATES[model];
|
||||
const box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height];
|
||||
let box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height];
|
||||
if (opts.crop && spec.ports.length > 2) {
|
||||
// Just the port area, so the ports are big enough to read in a card
|
||||
const left = spec.ports.map(q => (q.type === 'sfp' ? q.x : q.x - 20 * (q.scale || 1)));
|
||||
const right = spec.ports.map(q => (q.type === 'sfp' ? q.x + q.w : q.x + 20 * (q.scale || 1)));
|
||||
const x0 = Math.max(0, Math.min(...left) - 24);
|
||||
const x1 = Math.min(spec.width, Math.max(...right) + 24);
|
||||
box = [x0, 0, x1 - x0, spec.height];
|
||||
}
|
||||
const k = width / box[2];
|
||||
const h = box[3] * k;
|
||||
// A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing
|
||||
@@ -115,7 +118,7 @@ function faceplateDrawing(node, model, width, opts = {}) {
|
||||
swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up',
|
||||
rack: !!opts.rack, titleFor: portTitle,
|
||||
}, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg;
|
||||
const drawn = nested(svg);
|
||||
const drawn = nested(svg).replace(/viewBox="[^"]*"/, `viewBox="${box.join(' ')}"`);
|
||||
return {
|
||||
w: width, h, svg: drawn,
|
||||
anchor(end) {
|
||||
@@ -253,86 +256,128 @@ function stubDrawing() {
|
||||
};
|
||||
}
|
||||
|
||||
// A host: its drawn front (from the cutsheet's Front drawing) under a strip of its NIC sockets,
|
||||
// one per physical interface, so each cable lands on the NIC it is plugged into.
|
||||
function hostDrawing(node, linksByIface) {
|
||||
let front;
|
||||
// A host's drawn front, from its cutsheet item's Front drawing
|
||||
function hostFront(node) {
|
||||
const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] };
|
||||
if (RACK_FRONTS[node.chassis]) {
|
||||
const spec = RACK_FRONTS[node.chassis];
|
||||
front = {
|
||||
w: spec.width, h: spec.height,
|
||||
svg: nested(rackFrontSvg(node.chassis, ctx)).replace('<svg ', `<svg width="${spec.width}" height="${spec.height}" `),
|
||||
};
|
||||
} else if (SHELF_GEAR[node.chassis]) {
|
||||
const gear = SHELF_GEAR[node.chassis];
|
||||
const w = gear.mm * PER_MM;
|
||||
const h = gear.height * (w / gear.width);
|
||||
front = {
|
||||
w, h,
|
||||
svg: `<svg width="${w}" height="${h}" viewBox="0 0 ${gear.width} ${gear.height}"><title>${esc(`${node.name} · ${gear.label}`)}</title>${gear.draw(ctx)}</svg>`,
|
||||
};
|
||||
} else if (CHASSIS_FACEPLATES[node.chassis]) {
|
||||
front = faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
|
||||
} else {
|
||||
front = {
|
||||
w: 560, h: 150,
|
||||
svg: `<rect class="tp-host-box" width="560" height="150" rx="8"/>
|
||||
<circle class="tp-host-led tp-led-${node.status === 'up' ? 'on' : (node.status === 'down' ? 'bad' : 'off')}" cx="520" cy="75" r="12"/>
|
||||
<text class="tp-box-text" x="250" y="88" text-anchor="middle">server</text>
|
||||
<title>${esc(node.name)}: no front drawing on the cutsheet</title>`,
|
||||
};
|
||||
return { w: spec.width, h: spec.height,
|
||||
svg: nested(rackFrontSvg(node.chassis, ctx)).replace('<svg ', `<svg width="${spec.width}" height="${spec.height}" `) };
|
||||
}
|
||||
|
||||
const nics = (node.interfaces || []).slice();
|
||||
Object.keys(linksByIface).forEach(iface => {
|
||||
if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' });
|
||||
});
|
||||
const stripW = Math.max(nics.length * SOCKET_W + 24, 200);
|
||||
const w = Math.max(front.w, stripW);
|
||||
const sx = (w - stripW) / 2;
|
||||
const fx = (w - front.w) / 2;
|
||||
const pos = {};
|
||||
const sockets = nics.map((nic, i) => {
|
||||
const link = linksByIface[nic.name];
|
||||
const cx = sx + 12 + i * SOCKET_W + SOCKET_W / 2;
|
||||
pos[nic.name] = cx;
|
||||
const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium);
|
||||
let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down');
|
||||
if (link && !link.up) st = 'down';
|
||||
const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`;
|
||||
const shape = sfp
|
||||
? `<rect class="tp-nic-sfp" x="${cx - 30}" y="14" width="60" height="34" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="20" width="44" height="22" rx="2"/>`
|
||||
: `<path class="tp-nic-rj45" d="M${cx - 28} 20 h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
|
||||
return `<g class="tp-nic tp-nic-${st}${nic.alert ? ` tp-nic-alert-${esc(nic.alert)}` : ''}"><title>${esc(title)}</title>
|
||||
${shape}<circle class="tp-nic-led" cx="${cx + 22}" cy="60" r="5"/>
|
||||
<text class="tp-nic-name" x="${cx}" y="88" text-anchor="middle">${esc(nic.name.length > 11 ? `${nic.name.slice(0, 10)}…` : nic.name)}</text></g>`;
|
||||
}).join('');
|
||||
return {
|
||||
w, h: STRIP_H + 14 + front.h,
|
||||
svg: `<rect class="tp-strip" x="${sx}" y="0" width="${stripW}" height="${STRIP_H}" rx="10"/>${sockets}<g transform="translate(${fx} ${STRIP_H + 14})">${front.svg}</g>`,
|
||||
anchor: end => (end.iface && pos[end.iface] != null ? { x: pos[end.iface], y: 14, face: 'top' } : null),
|
||||
};
|
||||
if (SHELF_GEAR[node.chassis]) {
|
||||
const gear = SHELF_GEAR[node.chassis];
|
||||
return { w: gear.width, h: gear.height,
|
||||
svg: `<svg width="${gear.width}" height="${gear.height}" viewBox="0 0 ${gear.width} ${gear.height}">${gear.draw(ctx)}</svg>` };
|
||||
}
|
||||
if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100);
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawingFor(node, linksByIface) {
|
||||
// The picture inside a card, for everything but hosts
|
||||
function artFor(node) {
|
||||
const spec = FACEPLATES[node.model];
|
||||
switch (node.kind) {
|
||||
case 'internet': return internetDrawing();
|
||||
case 'modem': return modemDrawing(node);
|
||||
case 'host': return hostDrawing(node, linksByIface);
|
||||
case 'stub': return stubDrawing();
|
||||
case 'group': return groupDrawing(node);
|
||||
case 'ap': return apDrawing(node);
|
||||
case 'strip': return stripDrawing(node);
|
||||
default:
|
||||
if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node);
|
||||
if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups' });
|
||||
if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups', crop: !node.power });
|
||||
if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node);
|
||||
return boxDrawing(node, node.model || node.kind);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cards ───────────────────────────────────────────────────────────────
|
||||
// Every device is a card of the same height: its name and state on top, and below that its real
|
||||
// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card
|
||||
// edge right above or below the port they are plugged into.
|
||||
const CARD_W = 640;
|
||||
const CARD_SMALL_W = 460;
|
||||
const CARD_H = 300;
|
||||
const BAND_Y = 132;
|
||||
const BAND_H = 140;
|
||||
const NIC_W = 104;
|
||||
|
||||
function fitArt(art, x, y, w, h) {
|
||||
const k = Math.min(w / art.w, h / art.h);
|
||||
return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 };
|
||||
}
|
||||
|
||||
function nicSockets(node, linksByIface, x0) {
|
||||
const nics = (node.interfaces || []).slice();
|
||||
Object.keys(linksByIface).forEach(iface => {
|
||||
if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' });
|
||||
});
|
||||
const pos = {};
|
||||
const svg = nics.map((nic, i) => {
|
||||
const link = linksByIface[nic.name];
|
||||
const cx = x0 + i * NIC_W + NIC_W / 2;
|
||||
const cy = BAND_Y + 40;
|
||||
pos[nic.name] = { x: cx, y: cy - 20 };
|
||||
const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium);
|
||||
let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down');
|
||||
if (link && !link.up) st = 'down';
|
||||
const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`;
|
||||
const shape = sfp
|
||||
? `<rect class="tp-nic-sfp" x="${cx - 30}" y="${cy - 20}" width="60" height="38" rx="3"/><rect class="tp-nic-sfp-in" x="${cx - 22}" y="${cy - 13}" width="44" height="24" rx="2"/>`
|
||||
: `<path class="tp-nic-rj45" d="M${cx - 28} ${cy - 12} h16 v-8 h24 v8 h16 v32 h-56 Z"/>`;
|
||||
const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name;
|
||||
return `<g class="tp-nic tp-nic-${st}${nic.alert ? ` tp-nic-alert-${esc(nic.alert)}` : ''}"><title>${esc(title)}</title>
|
||||
${shape}<circle class="tp-nic-led" cx="${cx + 22}" cy="${cy + 28}" r="5"/>
|
||||
<text class="tp-nic-name" x="${cx}" y="${cy + 70}" text-anchor="middle">${esc(label)}</text></g>`;
|
||||
}).join('');
|
||||
return { svg, pos, w: nics.length * NIC_W };
|
||||
}
|
||||
|
||||
function cardFor(node, linksByIface) {
|
||||
let w, band, anchorAt;
|
||||
if (node.kind === 'host') {
|
||||
const front = hostFront(node);
|
||||
const sockets0 = nicSockets(node, linksByIface, 0);
|
||||
const frontW = front ? 220 : 0;
|
||||
w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980));
|
||||
const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2);
|
||||
const sockets = nicSockets(node, linksByIface, sx);
|
||||
let art = '';
|
||||
if (front) {
|
||||
const f = fitArt(front, 28, BAND_Y, frontW, BAND_H);
|
||||
art = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${front.svg}</g>`;
|
||||
}
|
||||
band = `${art}${sockets.svg}`;
|
||||
anchorAt = end => (end.iface && sockets.pos[end.iface]) || null;
|
||||
} else {
|
||||
const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind);
|
||||
w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980));
|
||||
const art = artFor(node);
|
||||
const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H);
|
||||
band = `<g transform="translate(${f.ox} ${f.oy}) scale(${f.k})">${art.svg}</g>`;
|
||||
anchorAt = end => {
|
||||
const a = art.anchor(end);
|
||||
return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null;
|
||||
};
|
||||
}
|
||||
const maxChars = Math.floor((w - 130) / 27);
|
||||
const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name;
|
||||
let sub;
|
||||
if (node.kind === 'group') sub = `on ${node.switch}`;
|
||||
else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`);
|
||||
else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`;
|
||||
else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status;
|
||||
else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
|
||||
const svg = `<rect class="tp-card" width="${w}" height="${CARD_H}" rx="18"/>
|
||||
<rect class="tp-card-stripe tp-stripe-${esc(node.status)}" x="0" y="18" width="8" height="${CARD_H - 36}" rx="4"/>
|
||||
<text class="tp-card-name" x="34" y="66">${esc(name)}</text>
|
||||
<text class="tp-card-sub" x="34" y="108">${esc(sub)}</text>
|
||||
<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${w - 42}" cy="52" r="13"/>
|
||||
<rect class="tp-band" x="20" y="${BAND_Y - 8}" width="${w - 40}" height="${BAND_H + 16}" rx="10"/>
|
||||
${band}`;
|
||||
return { w, h: CARD_H, svg, anchorAt };
|
||||
}
|
||||
|
||||
// ── Layout ──────────────────────────────────────────────────────────────
|
||||
// Rows top down by hops from the internet. Cables are routed like cable management: each drops
|
||||
// straight down from its port into the tray above the row it is going to, runs along its own lane
|
||||
@@ -376,27 +421,32 @@ function visibleGraph(data, filters, expanded) {
|
||||
return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) };
|
||||
}
|
||||
|
||||
// The internet, modem and gateway share the top row, left to right; then a row per hop, with
|
||||
// unknown devices beside the gear on their switch; servers along the bottom
|
||||
function rowKey(node) {
|
||||
if (node.kind === 'host') return 1e6;
|
||||
if (node.depth == null) return 1e5;
|
||||
return (node.kind === 'stub' || node.kind === 'group') ? node.depth + 0.5 : node.depth;
|
||||
if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0;
|
||||
return node.depth;
|
||||
}
|
||||
|
||||
// Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or
|
||||
// below the port it is plugged into, which is given as `port` for the short tail drawn to it
|
||||
function anchorOf(lay, end, asParent) {
|
||||
const p = lay.pos[end.node];
|
||||
const d = lay.drawings[end.node];
|
||||
// Power gear sits in a rail on the right: its cable comes in from the left
|
||||
if (p.rail) return { x: p.x + p.dx, y: p.y + d.h / 2, face: 'left' };
|
||||
if (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null };
|
||||
let a = null;
|
||||
if (end.ports) {
|
||||
// A bundle leaves from the middle of the ports it gathers
|
||||
const xs = end.ports.map(idx => d.anchor({ port_idx: idx })).filter(Boolean);
|
||||
if (xs.length) {
|
||||
return { x: p.x + p.dx + xs.reduce((t, a) => t + a.x, 0) / xs.length,
|
||||
y: p.y + Math.max(...xs.map(a => a.y)), face: 'bottom' };
|
||||
}
|
||||
const xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean);
|
||||
if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) };
|
||||
} else {
|
||||
a = d.anchorAt(end);
|
||||
}
|
||||
const a = d.anchor(end) || { x: d.w / 2, y: asParent ? d.h : 0, face: asParent ? 'bottom' : 'top' };
|
||||
return { x: p.x + p.dx + a.x, y: p.y + a.y, face: a.face };
|
||||
const x = p.x + (a ? a.x : d.w / 2);
|
||||
return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top',
|
||||
port: a ? { x, y: p.y + a.y } : null };
|
||||
}
|
||||
|
||||
// Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs
|
||||
@@ -439,10 +489,9 @@ function layout(data, filters, expanded) {
|
||||
nodes.forEach(n => {
|
||||
const byIface = {};
|
||||
links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
|
||||
const d = drawingFor(n, byIface);
|
||||
const d = cardFor(n, byIface);
|
||||
drawings[n.id] = d;
|
||||
const labelW = Math.min(Math.max(n.name.length * 25, 120), 900);
|
||||
sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H };
|
||||
sizes[n.id] = { w: d.w, h: d.h };
|
||||
});
|
||||
|
||||
const lay = { pos: {}, drawings, nodes, links, byId, routes: {} };
|
||||
@@ -471,6 +520,7 @@ function layout(data, filters, expanded) {
|
||||
});
|
||||
let last = 0;
|
||||
items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
|
||||
if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; });
|
||||
items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name));
|
||||
let right = -Infinity;
|
||||
items.forEach(it => {
|
||||
@@ -491,7 +541,7 @@ function layout(data, filters, expanded) {
|
||||
}
|
||||
}
|
||||
}
|
||||
lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 };
|
||||
lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 };
|
||||
right = x + it.w;
|
||||
});
|
||||
});
|
||||
@@ -508,6 +558,10 @@ function layout(data, filters, expanded) {
|
||||
if (!lay.pos[l.a.node]) return;
|
||||
const xa = ax(l.a);
|
||||
const toRail = byId[l.b.node].power;
|
||||
if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) {
|
||||
plan[l.id] = { across: true };
|
||||
return;
|
||||
}
|
||||
let r, xb;
|
||||
if (toRail) {
|
||||
r = Math.min(rowOf[l.a.node] + 1, rows.length);
|
||||
@@ -552,6 +606,15 @@ function layout(data, filters, expanded) {
|
||||
links.forEach(l => {
|
||||
const p = plan[l.id];
|
||||
if (!p) return;
|
||||
if (p.across) {
|
||||
// Side by side in the top row: straight across from one card to the next
|
||||
const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]];
|
||||
const left = pa.x < pb.x;
|
||||
const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null };
|
||||
const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null };
|
||||
lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B };
|
||||
return;
|
||||
}
|
||||
const A = anchorOf(lay, l.a, true);
|
||||
const B = anchorOf(lay, l.b, false);
|
||||
const laneY = trayTop(p.r) + TRAY_PAD + (p.run.lane || 0) * LANE;
|
||||
@@ -626,9 +689,13 @@ function renderLink(link, lay) {
|
||||
warn = `<g class="tp-warn" transform="translate(${at.x + 34} ${at.y + (at.face === 'bottom' ? 40 : -40)})"><title>${esc(text)}</title>
|
||||
<path d="M0 -26 L26 20 H-26 Z"/><text y="14" text-anchor="middle">!</text></g>`;
|
||||
}
|
||||
const plugs = ['dac', 'fibre', 'sfp'].includes(link.medium)
|
||||
? `<circle class="tp-plug" cx="${A.x}" cy="${A.y}" r="9"/><circle class="tp-plug" cx="${B.x}" cy="${B.y}" r="9"/>`
|
||||
: `<rect class="tp-boot" x="${A.x - 9}" y="${A.y - 9}" width="18" height="18" rx="3"/><rect class="tp-boot" x="${B.x - 9}" y="${B.y - 9}" width="18" height="18" rx="3"/>`;
|
||||
// A short tail from the card edge to the port on the picture, ending in the plug
|
||||
const plug = P => (['dac', 'fibre', 'sfp'].includes(link.medium)
|
||||
? `<circle class="tp-plug" cx="${P.x}" cy="${P.y}" r="7"/>`
|
||||
: `<rect class="tp-boot" x="${P.x - 7}" y="${P.y - 7}" width="14" height="14" rx="3"/>`);
|
||||
const tail = E => (E.port
|
||||
? `<path class="tp-core tp-pigtail" d="M${E.x} ${E.y} L${E.port.x} ${E.port.y}"/>${plug(E.port)}` : '');
|
||||
const plugs = `${tail(A)}${tail(B)}`;
|
||||
const attrs = `class="${cls.join(' ')}" data-link="${esc(link.id)}"`;
|
||||
return {
|
||||
// The cable runs behind the devices; its plugs, traffic and warning sit on top
|
||||
@@ -644,13 +711,9 @@ function renderNode(node, lay) {
|
||||
const p = lay.pos[node.id];
|
||||
const d = lay.drawings[node.id];
|
||||
const href = nodeHref(node);
|
||||
const sub = node.kind === 'group' ? `on ${node.switch} ports ${node.members.map(m => m.port_idx).join(', ')}`
|
||||
: node.kind === 'stub'
|
||||
? [node.ip ? '' : (node.mac || ''), `${node.switch} port ${node.port_idx}`].filter(Boolean).join(' · ')
|
||||
: [node.kind === 'host' ? node.status : (node.model || node.kind), node.kind !== 'host' && node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · ');
|
||||
const ring = node.severity
|
||||
? `<rect class="tp-ring tp-ring-${esc(node.severity)}" x="${p.dx - 18}" y="-18" width="${d.w + 36}" height="${d.h + 36}" rx="16"/>
|
||||
<g class="tp-badge tp-badge-${esc(node.severity)}" transform="translate(${p.dx + d.w + 10} -14)"><circle r="30"/><text y="11" text-anchor="middle">${node.alert_count}</text></g>`
|
||||
? `<rect class="tp-ring tp-ring-${esc(node.severity)}" x="-10" y="-10" width="${d.w + 20}" height="${d.h + 20}" rx="24"/>
|
||||
<g class="tp-badge tp-badge-${esc(node.severity)}" transform="translate(${d.w - 6} 6)"><circle r="30"/><text y="11" text-anchor="middle">${node.alert_count}</text></g>`
|
||||
: '';
|
||||
const alertText = (node.alerts || []).map(a => `${a.severity}: ${a.type.replace(/_/g, ' ')}${a.detail ? ` ${a.detail}` : ''}`).join('; ');
|
||||
const label = `${node.name}${node.kind === 'stub' ? ' (not identified)' : ''} · ${node.status}${alertText ? ` · ${alertText}` : ''}`;
|
||||
@@ -660,12 +723,8 @@ function renderNode(node, lay) {
|
||||
const close = open.startsWith('<a') ? '</a>' : '</g>';
|
||||
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
|
||||
${open}<title>${esc(label)}</title>
|
||||
<rect class="tp-node-hit" x="${p.dx - 10}" y="-10" width="${d.w + 20}" height="${d.h + LABEL_H}" rx="12"/>
|
||||
${d.svg}
|
||||
${ring}
|
||||
<g transform="translate(${p.dx} 0)">${d.svg}</g>
|
||||
${node.kind === 'internet' ? '' : `<circle class="tp-dot tp-dot-${esc(node.status)}" cx="${p.w / 2 - node.name.length * 12 - 34}" cy="${d.h + 46}" r="10"/>`}
|
||||
${node.kind === 'internet' ? '' : `<text class="tp-name" x="${p.w / 2}" y="${d.h + 58}" text-anchor="middle">${esc(node.name)}</text>`}
|
||||
<text class="tp-sub" x="${p.w / 2}" y="${d.h + 96}" text-anchor="middle">${esc(sub)}</text>
|
||||
${close}
|
||||
</g>`;
|
||||
}
|
||||
@@ -702,6 +761,7 @@ function render() {
|
||||
document.getElementById('tp-loading').hidden = true;
|
||||
applyFilterClasses();
|
||||
if (!state.fitted) {
|
||||
sizeCanvas();
|
||||
fit();
|
||||
state.fitted = true;
|
||||
} else {
|
||||
@@ -814,6 +874,19 @@ function highlight(id) {
|
||||
const K_MIN = 0.03;
|
||||
const K_MAX = 3;
|
||||
|
||||
// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of
|
||||
// leaving most of a tall canvas empty
|
||||
function sizeCanvas() {
|
||||
const canvas = document.getElementById('tp-canvas');
|
||||
if (!state.layout || canvas.clientWidth > 700) {
|
||||
canvas.style.height = '';
|
||||
return;
|
||||
}
|
||||
const { x0, y0, x1, y1 } = state.layout.bounds;
|
||||
const want = canvas.clientWidth * (y1 - y0) / (x1 - x0);
|
||||
canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`;
|
||||
}
|
||||
|
||||
function fit() {
|
||||
if (!state.layout) return;
|
||||
const canvas = document.getElementById('tp-canvas');
|
||||
|
||||
Reference in New Issue
Block a user