Topology: route cables through trays, group unknown devices
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m44s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 49s
Test / Python Tests (pytest) (push) Successful in 1m9s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m40s
Test / Python Tests (pytest) (pull_request) Successful in 2m50s

The curved cables piled on top of each other. Cables now run like cable
management: straight down from the port into the tray above the row they
go to, along a lane of their own (10G on the upper lanes), then straight
down into the socket. Nothing overlaps and crossings are square. Devices
sit clear of cables passing down through their row, under their fastest
cable.

Each switch's unknown devices are one block with a bundled cable; click
it to open them, or show them one by one or hide them from the toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 19:44:44 -05:00
co-authored by Claude Opus 5.5
parent aa7341af06
commit abe88df3a4
5 changed files with 270 additions and 73 deletions
+8 -2
View File
@@ -189,13 +189,19 @@ servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on t
port inspector and rack page), and servers use the **Front drawing** of their cutsheet item. Servers
without one are a plain box. Above each server is a strip of its physical NICs, so every cable lands
on the NIC it is plugged into; NICs marked unused are dimmed.
- **Cables run like cable management**: each drops straight down from its port into a tray above the
row it goes to, runs along its own lane (10G on the upper lanes), and drops straight into its socket,
so cables never lie on top of each other and only cross at right angles. Devices are placed clear of
the cables passing down through their row, and each sits under its fastest cable.
- **One line per physical cable**, leaving the exact port: a switch-to-switch cable is reported by both
switches and drawn once. A server with two links (10G and 1G) has two cables. Colour is the cable
type (DAC, multimode fibre, the provider's fibre, an SFP+ module that doesn't say which, copper) and
thickness is the speed. A cable that is down is dashed red.
- **Last seen**: a device with no live cable is drawn on the port it was last seen on, dotted red.
- **Unknown devices**: something plugged into a switch port that Gandalf can't name is a grey stub,
labelled with the port name (or its address), linking to that port.
labelled with the port name (or its address), linking to that port. By default each switch's unknown
devices are one block with one bundled cable ("10 unknown devices"); click it to show them one by
one, or pick "one by one" or "hidden" in the toolbar.
- **Port names**: a ⚠ marks a port named after one device with a different one plugged in (a port
called "Large1 Management" with the AP on it). Generic names ("Port 4", "SFP+ 2", "Uplink") and
"Downlink to X" with X on the other end never flag.
@@ -205,7 +211,7 @@ servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on t
both ends, speed, cable type and module, traffic each way and how busy it is, errors and drops, and
its cutsheet record. Hovering a device traces its path to the internet.
- Drag to pan, scroll or pinch to zoom, **Fit** (or `0`) to see everything, `+` and `-` to zoom. Show
or hide unknown devices, power gear, traffic, cables slower than 10G, and everything without an
unknown devices grouped, one by one or not at all, and show or hide power gear, traffic, cables slower than 10G, and everything without an
alert. The filters are remembered per browser. Refreshes every 30 seconds without moving the view.
- API: `GET /api/topology` returns `{nodes, links, warnings, updated}` (see `topology.py`).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+7
View File
@@ -1494,6 +1494,11 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.tp-stub-jack { fill: #15181c; }
.tp-stub-q { font-family: var(--font); font-size: 30px; font-weight: 700; fill: #9aa3ad; }
.tp-st-down .tp-stub-body { stroke: #e0312b; }
.tp-stub-jack.tp-off { opacity: .4; }
.tp-group-body { stroke-dasharray: 10 8; stroke: #5d6670; }
.tp-group-hint { font-family: var(--font); font-size: 20px; fill: #9aa3ad; }
.tp-select { font-size: .95em; padding: 1px 4px; margin-left: 4px; }
.tp-tag-bundle rect { stroke: #3b82f6; }
/* Host NIC sockets */
.tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; }
@@ -1524,6 +1529,8 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.tp-m-wanfibre .tp-casing { stroke: #8a6a00; } .tp-m-wanfibre .tp-core { stroke: #f2c230; }
.tp-m-sfp .tp-casing { stroke: #23272c; } .tp-m-sfp .tp-core { stroke: #69717c; }
.tp-m-copper .tp-casing { stroke: #173f80; } .tp-m-copper .tp-core { stroke: #3b82f6; }
/* A group's cables, bundled: one thick run */
.tp-link.tp-bundle .tp-casing { stroke-width: 28; stroke: #0f2b57; } .tp-link.tp-bundle .tp-core { stroke-width: 20; }
.tp-down .tp-casing { stroke: transparent; }
.tp-down .tp-core { stroke: #e0312b; stroke-dasharray: 28 18; }
.tp-ghost { opacity: .8; }
+248 -69
View File
@@ -7,15 +7,14 @@
const esc = s => lt.escHtml(s == null ? '' : String(s));
const PER_MM = 1100 / 482.6;
const ROW_GAP = 330;
const NODE_GAP = 120;
const LABEL_H = 104;
const RAIL_GAP = 520;
const STRIP_H = 104;
const SOCKET_W = 118;
const REFRESH_MS = 30000;
const FILTER_KEY = 'gandalf.topology.filters';
const DEFAULT_FILTERS = { stubs: true, power: true, traffic: true, tenG: false, alerts: false };
const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, tenG: false, alerts: false };
const STUB_MODES = ['grouped', 'all', 'none'];
const state = {
data: null,
@@ -23,12 +22,15 @@ const state = {
view: { k: 1, tx: 0, ty: 0 },
fitted: false,
filters: loadFilters(),
expanded: new Set(), // switches whose unknown devices are shown one by one
selected: null,
};
function loadFilters() {
try {
return Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped';
return saved;
} catch (_) {
return Object.assign({}, DEFAULT_FILTERS);
}
@@ -59,6 +61,7 @@ function deviceHref(name) {
}
function nodeHref(node) {
if (node.kind === 'group') return '';
if (node.kind === 'stub') return `${deviceHref(node.switch)}#port-${encodeURIComponent(node.port_idx)}`;
if (node.kind === 'internet') return '';
return deviceHref(node.name);
@@ -213,6 +216,22 @@ function stripDrawing(node) {
};
}
function groupDrawing(node) {
const n = node.members.length;
const cols = Math.min(n, 7);
const w = Math.max(360, cols * 64 + 60), h = 150;
const jacks = node.members.slice(0, 14).map((m, i) => {
const x = 30 + (i % cols) * 64, y = 22 + Math.floor(i / cols) * 46;
return `<path class="tp-stub-jack${m.status === 'up' ? '' : ' tp-off'}" d="M${x + 8} ${y + 10} h12 v-6 h16 v6 h12 v26 h-40 Z"/>`;
}).join('');
return {
w, h,
svg: `<rect class="tp-stub-body tp-group-body" width="${w}" height="${h}" rx="12"/>${jacks}
<text class="tp-group-hint" x="${w / 2}" y="${h - 14}" text-anchor="middle">click to show</text>`,
anchor: () => ({ x: w / 2, y: 4, face: 'top' }),
};
}
function boxDrawing(node, text) {
const w = 460, h = 150;
return {
@@ -303,6 +322,7 @@ function drawingFor(node, linksByIface) {
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:
@@ -314,17 +334,52 @@ function drawingFor(node, linksByIface) {
}
// ── Layout ──────────────────────────────────────────────────────────────
function visibleGraph(data, filters) {
const nodes = data.nodes.filter(n => (filters.stubs || n.kind !== 'stub') && (filters.power || !n.power));
// 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
// there, then drops straight into its socket. Lanes never share, so cables never lie on top of one
// another and only ever cross at right angles. Power gear hangs in a rail on the right.
const TRAY_PAD = 70;
const LANE = 30;
const TRAY_MIN = 200;
const CORRIDOR_PAD = 60;
const CORNER = 26;
// Unknown devices on one switch are grouped into a single block with one bundled cable, unless
// the viewer opens that switch's group (or shows them all)
function visibleGraph(data, filters, expanded) {
const mode = filters.stubs;
let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
let links = data.links.slice();
if (mode === 'grouped') {
const bySwitch = {};
nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n));
Object.entries(bySwitch).forEach(([sw, stubs]) => {
if (stubs.length < 2 || expanded.has(sw)) return;
const ids = new Set(stubs.map(n => n.id));
const cables = links.filter(l => ids.has(l.b.node));
const id = `group:${sw}`;
nodes = nodes.filter(n => !ids.has(n.id)).concat([{
id, name: `${stubs.length} unknown devices`, kind: 'group', status: 'up', depth: stubs[0].depth,
switch: sw, members: stubs, alerts: [], alert_count: 0, severity: '', power: false,
}]);
links = links.filter(l => !ids.has(l.b.node)).concat([{
id: `bundle:${sw}`, bundle: true, count: cables.length, ghost: false, wan: false, name_mismatch: null,
a: { node: sw, port_idx: null, port_name: '', iface: '', ports: cables.map(l => l.a.port_idx) },
b: { node: id, port_idx: null, port_name: '', iface: '' },
up: cables.some(l => l.up), medium: 'copper', speed_mbps: Math.max(...cables.map(l => l.speed_mbps || 0)),
a_to_b_bps: cables.reduce((t, l) => t + (l.a_to_b_bps || 0), 0),
b_to_a_bps: cables.reduce((t, l) => t + (l.b_to_a_bps || 0), 0),
}]);
});
}
const ids = new Set(nodes.map(n => n.id));
const links = data.links.filter(l => ids.has(l.a.node) && ids.has(l.b.node));
return { nodes, links };
return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) };
}
function rowKey(node) {
if (node.kind === 'host') return 1e6;
if (node.depth == null) return 1e5;
return node.kind === 'stub' ? node.depth + 0.5 : node.depth;
return (node.kind === 'stub' || node.kind === 'group') ? node.depth + 0.5 : node.depth;
}
function anchorOf(lay, end, asParent) {
@@ -332,12 +387,52 @@ function anchorOf(lay, end, asParent) {
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 (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 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 };
}
function layout(data, filters) {
const { nodes, links } = visibleGraph(data, filters);
// Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs
// share a lane only when they don't overlap
function assignLanes(runs) {
const lanes = [];
runs.sort((a, b) => (b.speed - a.speed) || (a.x0 - b.x0));
runs.forEach(run => {
let i = lanes.findIndex(lane => lane.every(o => run.x1 + 40 < o.x0 || run.x0 > o.x1 + 40));
if (i < 0) {
lanes.push([]);
i = lanes.length - 1;
}
lanes[i].push(run);
run.lane = i;
});
return lanes.length;
}
// Straight runs with rounded corners
function roundedPath(points) {
const pts = points.filter((p, i) => i === 0 || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 0.5);
let d = `M${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length - 1; i++) {
const [p, c, n] = [pts[i - 1], pts[i], pts[i + 1]];
const r = Math.min(CORNER, Math.hypot(c.x - p.x, c.y - p.y) / 2, Math.hypot(n.x - c.x, n.y - c.y) / 2);
const u = (q, s) => ({ x: c.x + (q.x - c.x) / Math.hypot(q.x - c.x, q.y - c.y) * s, y: c.y + (q.y - c.y) / Math.hypot(q.x - c.x, q.y - c.y) * s });
const a = u(p, r), b = u(n, r);
d += ` L${a.x} ${a.y} Q${c.x} ${c.y} ${b.x} ${b.y}`;
}
const last = pts[pts.length - 1];
return `${d} L${last.x} ${last.y}`;
}
function layout(data, filters, expanded) {
const { nodes, links } = visibleGraph(data, filters, expanded);
const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
const drawings = {};
const sizes = {};
@@ -350,7 +445,7 @@ function layout(data, filters) {
sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H };
});
const lay = { pos: {}, drawings, nodes, links, byId };
const lay = { pos: {}, drawings, nodes, links, byId, routes: {} };
const rowsMap = new Map();
nodes.filter(n => !n.power).forEach(n => {
const key = rowKey(n);
@@ -358,13 +453,21 @@ function layout(data, filters) {
rowsMap.get(key).push(n);
});
const rows = [...rowsMap.keys()].sort((a, b) => a - b).map(k => rowsMap.get(k));
const rowOf = {};
rows.forEach((row, r) => row.forEach(n => { rowOf[n.id] = r; }));
const ax = end => anchorOf(lay, end, true).x;
let y = 0;
rows.forEach(row => {
// ── Across: each row in order of the ports feeding it, clear of cables passing down through it
rows.forEach((row, r) => {
const corridors = links
.filter(l => rowOf[l.a.node] < r && rowOf[l.b.node] > r && lay.pos[l.a.node])
.map(l => ax(l.a)).sort((a, b) => a - b);
const items = row.map(n => {
const xs = links.filter(l => (l.b.node === n.id && lay.pos[l.a.node]) || (l.a.node === n.id && lay.pos[l.b.node]))
.map(l => anchorOf(lay, l.b.node === n.id ? l.a : l.b, true).x);
return { n, w: sizes[n.id].w, bary: xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : null };
// Line a device up under its fastest cable, so that one drops straight in
const feeds = links.filter(l => l.b.node === n.id && lay.pos[l.a.node])
.sort((a, b) => (b.speed_mbps || 0) - (a.speed_mbps || 0));
const xs = feeds.length ? [ax(feeds[0].a)] : [];
return { n, w: sizes[n.id].w, bary: xs.length ? xs[0] : null };
});
let last = 0;
items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; });
@@ -374,59 +477,112 @@ function layout(data, filters) {
it.x = Math.max(it.bary - it.w / 2, right + NODE_GAP);
right = it.x + it.w;
});
const shift = items.reduce((s, it) => s + (it.bary - (it.x + it.w / 2)), 0) / items.length;
const rowH = Math.max(...items.map(it => sizes[it.n.id].h));
const shift = items.reduce((t, it) => t + (it.bary - (it.x + it.w / 2)), 0) / items.length;
right = -Infinity;
items.forEach(it => {
lay.pos[it.n.id] = { x: it.x + shift, y, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 };
let x = Math.max(it.x + shift, right + NODE_GAP);
let moved = true;
while (moved) {
moved = false;
for (const c of corridors) {
if (c > x - CORRIDOR_PAD && c < x + it.w + CORRIDOR_PAD) {
x = c + CORRIDOR_PAD;
moved = true;
}
}
}
lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 };
right = x + it.w;
});
y += rowH + ROW_GAP;
});
// Power gear: a rail down the right, each unit level with the port that feeds it
// ── Trays: the runs in each one, and their lanes
const placed = Object.values(lay.pos);
const maxX = placed.length ? Math.max(...placed.map(p => p.x + p.w)) : 0;
const top = placed.length ? Math.min(...placed.map(p => p.y)) : 0;
const feederY = n => {
const l = links.find(k => k.b.node === n.id && lay.pos[k.a.node]);
return l ? anchorOf(lay, l.a, true).y : Infinity;
};
let railY = top;
nodes.filter(n => n.power).map(n => ({ n, f: feederY(n) }))
.sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => {
const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY;
const y = Math.max(want, railY);
lay.pos[n.id] = { x: maxX + RAIL_GAP, y, w: sizes[n.id].w, dx: 0, rail: true };
railY = y + sizes[n.id].h + 120;
});
const railNodes = nodes.filter(n => n.power);
const gutter0 = maxX + 160;
const trays = Array.from({ length: rows.length + 1 }, () => []);
const plan = {};
let gutterLane = 0;
links.forEach(l => {
if (!lay.pos[l.a.node]) return;
const xa = ax(l.a);
const toRail = byId[l.b.node].power;
let r, xb;
if (toRail) {
r = Math.min(rowOf[l.a.node] + 1, rows.length);
xb = gutter0 + (gutterLane++) * LANE;
} else {
if (rowOf[l.b.node] == null) return;
r = rowOf[l.b.node];
xb = anchorOf(lay, l.b, false).x;
}
const run = { id: l.id, x0: Math.min(xa, xb), x1: Math.max(xa, xb), speed: l.speed_mbps || 0, straight: Math.abs(xa - xb) < 2 };
plan[l.id] = { r, xa, xb, toRail, run };
if (!run.straight) trays[r].push(run);
});
const trayH = trays.map(runs => Math.max(TRAY_MIN, TRAY_PAD * 2 + assignLanes(runs) * LANE));
// ── Down: rows and trays stacked
const rowTop = [];
let y = 0;
rows.forEach((row, r) => {
if (r > 0) y += trayH[r];
rowTop[r] = y;
row.forEach(n => { lay.pos[n.id].y = y; });
y += Math.max(...row.map(n => sizes[n.id].h));
});
const trayTop = r => (r < rows.length ? rowTop[r] - trayH[r] : y);
// Power rail: each unit level with the lane its cable arrives on
const railX = gutter0 + Math.max(gutterLane, 1) * LANE + 260;
let railY = rows.length ? rowTop[0] : 0;
railNodes.map(n => {
const l = links.find(k => k.b.node === n.id && plan[k.id]);
const f = l ? trayTop(plan[l.id].r) + TRAY_PAD + (plan[l.id].run.lane || 0) * LANE : Infinity;
return { n, f };
}).sort((a, b) => a.f - b.f || a.n.name.localeCompare(b.n.name)).forEach(({ n, f }) => {
const want = Number.isFinite(f) ? f - drawings[n.id].h / 2 : railY;
const top = Math.max(want, railY);
lay.pos[n.id] = { x: railX, y: top, w: sizes[n.id].w, dx: 0, rail: true };
railY = top + sizes[n.id].h + 120;
});
// ── Routes
links.forEach(l => {
const p = plan[l.id];
if (!p) 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;
let pts;
if (p.toRail) pts = [A, { x: A.x, y: laneY }, { x: p.xb, y: laneY }, { x: p.xb, y: B.y }, B];
else if (p.run.straight) pts = [A, B];
else pts = [A, { x: A.x, y: laneY }, { x: B.x, y: laneY }, B];
// Traffic and "last seen" tags sit on the longest run
let mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 };
let best = -1;
for (let i = 1; i < pts.length; i++) {
const len = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
if (len > best) {
best = len;
mid = { x: (pts[i].x + pts[i - 1].x) / 2, y: (pts[i].y + pts[i - 1].y) / 2 };
}
}
lay.routes[l.id] = { d: roundedPath(pts), mid, A, B };
});
const all = Object.entries(lay.pos);
lay.bounds = {
x0: Math.min(...all.map(([, p]) => p.x)) - 80,
y0: Math.min(...all.map(([, p]) => p.y)) - 80,
x1: Math.max(...all.map(([, p]) => p.x + p.w)) + 80,
y1: Math.max(...all.map(([id, p]) => p.y + sizes[id].h)) + 80,
x0: Math.min(...all.map(([, q]) => q.x)) - 80,
y0: Math.min(...all.map(([, q]) => q.y)) - 80,
x1: Math.max(...all.map(([, q]) => q.x + q.w)) + 80,
y1: Math.max(...all.map(([id, q]) => q.y + sizes[id].h)) + 80,
};
return lay;
}
// ── Rendering ───────────────────────────────────────────────────────────
// A cable leaves each end square to the face it plugs into: up or down from a faceplate, or
// sideways into the power rail
function cablePath(A, B) {
const dir = (P, Q) => {
if (P.face === 'left') return [-1, 0];
if (P.face === 'right') return [1, 0];
return [0, Q.y >= P.y ? 1 : -1];
};
const c = Math.min(900, Math.max(90, Math.hypot(B.x - A.x, B.y - A.y) * 0.4));
const [ax, ay] = dir(A, B);
const [bx, by] = dir(B, A);
const c1 = { x: A.x + ax * c, y: A.y + ay * c };
const c2 = { x: B.x + bx * c, y: B.y + by * c };
const mid = { x: (A.x + 3 * c1.x + 3 * c2.x + B.x) / 8, y: (A.y + 3 * c1.y + 3 * c2.y + B.y) / 8 };
return { d: `M${A.x} ${A.y} C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${B.x} ${B.y}`, mid };
}
function linkTitle(link, byId) {
const end = e => {
const n = byId[e.node];
@@ -441,10 +597,11 @@ function linkTitle(link, byId) {
}
function renderLink(link, lay) {
const A = anchorOf(lay, link.a, true);
const B = anchorOf(lay, link.b, false);
const { d, mid } = cablePath(A, B);
const route = lay.routes[link.id];
if (!route) return { under: '', over: '' };
const { d, mid, A, B } = route;
const cls = ['tp-link', mediumClass(link), speedClass(link.speed_mbps)];
if (link.bundle) cls.push('tp-bundle');
if (link.ghost) cls.push('tp-ghost');
else if (!link.up) cls.push('tp-down');
if (link.wan) cls.push('tp-wan');
@@ -455,7 +612,9 @@ function renderLink(link, lay) {
const flowDur = Math.max(0.6, 6 - Math.log10(Math.max(busy, 1)) * 0.8).toFixed(2);
const flow = link.up && !link.ghost && busy > 1000
? `<path class="tp-flow" d="${d}" style="animation-duration:${flowDur}s"/>` : '';
const label = link.ghost
const label = link.bundle
? `<g class="tp-tag tp-tag-bundle" transform="translate(${mid.x} ${mid.y})"><rect x="-100" y="-26" width="200" height="44" rx="22"/><text y="6" text-anchor="middle">${link.count} cables</text></g>`
: link.ghost
? `<g class="tp-tag tp-tag-ghost" transform="translate(${mid.x} ${mid.y})"><rect x="-110" y="-26" width="220" height="44" rx="22"/><text y="6" text-anchor="middle">last seen</text></g>`
: (link.up ? `<g class="tp-tag tp-rate" transform="translate(${mid.x} ${mid.y})"><rect x="-190" y="-26" width="380" height="44" rx="22"/>
<text y="6" text-anchor="middle">↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}</text></g>` : '');
@@ -485,7 +644,8 @@ function renderNode(node, lay) {
const p = lay.pos[node.id];
const d = lay.drawings[node.id];
const href = nodeHref(node);
const sub = node.kind === 'stub'
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
@@ -494,8 +654,10 @@ function renderNode(node, lay) {
: '';
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}` : ''}`;
const open = href ? `<a class="tp-node-link" href="${esc(href)}" aria-label="${esc(label)}">` : `<g class="tp-node-link" tabindex="0" aria-label="${esc(label)}">`;
const close = href ? '</a>' : '</g>';
const open = node.kind === 'group'
? `<g class="tp-node-link" tabindex="0" role="button" data-expand="${esc(node.switch)}" aria-label="${esc(`${node.name} on ${node.switch}: show them`)}">`
: (href ? `<a class="tp-node-link" href="${esc(href)}" aria-label="${esc(label)}">` : `<g class="tp-node-link" tabindex="0" aria-label="${esc(label)}">`);
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"/>
@@ -521,13 +683,16 @@ function applyFilterClasses() {
canvas.classList.toggle('tp-no-traffic', !state.filters.traffic);
canvas.classList.toggle('tp-only-10g', state.filters.tenG);
canvas.classList.toggle('tp-alerts-only', state.filters.alerts);
document.querySelectorAll('[data-filter]').forEach(cb => { cb.checked = !!state.filters[cb.dataset.filter]; });
document.querySelectorAll('[data-filter]').forEach(el => {
if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter];
else el.checked = !!state.filters[el.dataset.filter];
});
}
function render() {
const data = state.data;
if (!data) return;
state.layout = layout(data, state.filters);
state.layout = layout(data, state.filters, state.expanded);
const lay = state.layout;
const world = document.getElementById('tp-world');
const cables = lay.links.map(l => renderLink(l, lay));
@@ -764,7 +929,13 @@ function initEvents() {
window.location.href = `${deviceHref(port.dataset.switch)}#port-${encodeURIComponent(port.dataset.portIdx)}`;
return;
}
const group = e.target.closest('[data-expand]');
const link = e.target.closest('.tp-link');
if (group || (link && link.dataset.link.startsWith('bundle:'))) {
state.expanded.add(group ? group.dataset.expand : link.dataset.link.slice('bundle:'.length));
render();
return;
}
if (link) {
const found = state.data.links.find(l => l.id === link.dataset.link);
if (found) showPanel(found);
@@ -774,6 +945,13 @@ function initEvents() {
});
svg.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const group = e.target.closest('[data-expand]');
if (group) {
e.preventDefault();
state.expanded.add(group.dataset.expand);
render();
return;
}
const link = e.target.closest('.tp-link');
if (link) {
e.preventDefault();
@@ -794,11 +972,12 @@ function initEvents() {
if (e.target.closest('[data-action="close-panel"]')) hidePanel();
});
document.querySelectorAll('[data-filter]').forEach(cb => {
cb.addEventListener('change', () => {
state.filters[cb.dataset.filter] = cb.checked;
document.querySelectorAll('[data-filter]').forEach(el => {
el.addEventListener('change', () => {
state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked;
saveFilters();
if (cb.dataset.filter === 'stubs' || cb.dataset.filter === 'power') render();
if (el.dataset.filter === 'stubs') state.expanded.clear();
if (el.dataset.filter === 'stubs' || el.dataset.filter === 'power') render();
else applyFilterClasses();
});
});
+7 -2
View File
@@ -5,13 +5,18 @@
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Topology</h1>
<p class="g-page-sub">Every device and cable as the switches see them right now, drawn from the real faceplates. Drag to pan, scroll or pinch to zoom. Hover a device to trace its path to the internet; click a cable for its details.</p>
<p class="g-page-sub">Every device and cable as the switches see them right now, drawn from the real faceplates. Drag to pan, scroll or pinch to zoom. Hover a device to trace its path to the internet; click a cable for its details, or a group of unknown devices to open it.</p>
</div>
</div>
<div class="tp-toolbar no-print" role="toolbar" aria-label="Topology view">
<div class="tp-filters" role="group" aria-label="Show">
<label class="tp-filter"><input type="checkbox" data-filter="stubs"> Unknown devices</label>
<label class="tp-filter">Unknown devices
<select class="lt-select tp-select" data-filter="stubs" aria-label="Unknown devices">
<option value="grouped">grouped per switch</option>
<option value="all">one by one</option>
<option value="none">hidden</option>
</select></label>
<label class="tp-filter"><input type="checkbox" data-filter="power"> Power gear</label>
<label class="tp-filter"><input type="checkbox" data-filter="traffic"> Traffic</label>
<label class="tp-filter"><input type="checkbox" data-filter="tenG"> 10G only</label>