Topology: show what needs a look, quiet everything else
Lint / JS (eslint) (push) Successful in 17s
Lint / Python (flake8) (push) Successful in 2m2s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 1m47s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m57s
Security / Python Security (bandit) (pull_request) Successful in 1m54s
Test / Python Tests (pytest) (push) Successful in 2m26s
Test / Python Tests (pytest) (pull_request) Successful in 2m27s

- A bar above the diagram lists what needs attention: devices down,
  degraded or alerting, cables down or last seen elsewhere, mislabelled
  ports, cables with errors. Click one to zoom to it; a missing device
  is one entry, with where it was last seen. All clear says so.
- Problem cables glow and are always drawn, whatever the filters; problem
  cards get a red or amber border. Healthy cables are drawn quietly.
- Each server's slower second cable (its 1G management link) is hidden
  by default while healthy: "Server 1G links" shows them. That was most
  of the clutter. "10G only" is gone, since this covers it.
- Power gear is off by default until the power map (part 2) gives the
  rail something to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 20:05:43 -05:00
co-authored by Claude Opus 5.5
parent 7dba1750ee
commit 20822d000e
5 changed files with 166 additions and 13 deletions
+10 -4
View File
@@ -185,14 +185,18 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
The whole network as the switches see it now, top down: the internet, the modem, the gateway, then
each switch by how many hops it is from the gateway, the gear and unknown devices below them, and the
servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on the right.
- **What needs a look** is listed above the diagram: devices down, degraded or with active alerts,
cables down or last seen elsewhere, ports named after the wrong device, and cables with errors.
Click one to jump to it. With nothing to report it says all clear. Problem cables are drawn in full
colour with a glow and always shown, whatever the filters; healthy cables are drawn quietly.
- **A card per device**, all the same height so the whole network reads at a glance: name, state
(coloured edge and dot), and a picture: UniFi gear shows its real faceplate (the same drawings as the
port inspector and rack page), cropped to the ports; servers show the **Front drawing** of their
cutsheet item and a socket per physical NIC, so every cable lands on the NIC it is plugged into
(NICs marked unused are dimmed). The internet, modem and gateway sit side by side along the top.
- **Cables run like cable management**: each leaves the card edge right below its port (a short tail
shows the port), drops 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
shows the port), drops 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
@@ -212,8 +216,10 @@ 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
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.
unknown devices grouped, one by one or not at all. **Server 1G links** (off by default) adds each
server's slower second cable, its management link; **Power gear** (off by default) adds the PDU, UPS
and power strip. Traffic labels and dimming everything without an alert can be switched too. 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`).
### Health check (`/health`)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 163 KiB

+23 -1
View File
@@ -1556,7 +1556,6 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
/* Filters and the traced path */
.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; }
.tp-only-10g .tp-slow { display: none; }
.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; }
.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; }
.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; }
@@ -1614,3 +1613,26 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.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; }
/* Healthy cables are quiet; problems are loud */
.tp-links .tp-link:not(.tp-problem):not(.tp-hot):not(.tp-selected) { opacity: .6; }
.tp-halo { stroke: rgba(224,49,43,.28); stroke-width: 46; fill: none; stroke-linecap: round; }
.tp-problem-mismatch .tp-halo, .tp-problem-errors .tp-halo { stroke: rgba(255,179,0,.28); }
.tp-node-problem .tp-card { stroke-width: 5; }
.tp-node-problem.tp-st-degraded .tp-card { stroke: #ffb300; }
.tp-node-problem.tp-st-down .tp-card { stroke: #e0312b; }
.tp-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; padding: 8px 12px;
border: 1px solid var(--border-color); background: var(--bg2); font-size: .8em; }
.tp-summary:empty { display: none; }
.tp-summary-head { font-weight: 700; letter-spacing: .04em; }
.tp-summary-ok { border-color: var(--green-muted); }
.tp-summary-ok .tp-summary-head { color: var(--green); }
.tp-summary-text { color: var(--text-dim); }
.tp-summary-bad { border-color: var(--red); }
.tp-summary-bad .tp-summary-head { color: var(--red); }
.tp-summary-warn { border-color: var(--amber); }
.tp-summary-warn .tp-summary-head { color: var(--amber); }
.tp-problem-chip { font: inherit; font-size: .95em; padding: 3px 9px; border: 1px solid; background: transparent; cursor: pointer; text-align: left; }
.tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); }
.tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); }
.tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; }
+131 -7
View File
@@ -8,7 +8,7 @@ const PER_MM = 1100 / 482.6;
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 };
const DEFAULT_FILTERS = { stubs: 'grouped', power: false, traffic: true, mgmt: false, alerts: false };
const STUB_MODES = ['grouped', 'all', 'none'];
const state = {
@@ -25,6 +25,7 @@ function loadFilters() {
try {
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';
delete saved.tenG;
return saved;
} catch (_) {
return Object.assign({}, DEFAULT_FILTERS);
@@ -62,6 +63,66 @@ function nodeHref(node) {
return deviceHref(node.name);
}
// ── Problems ────────────────────────────────────────────────────────────
// Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed
// above the diagram. Healthy cables are drawn quietly.
function linkProblem(link) {
if (link.bundle) return '';
if (link.ghost) return 'ghost';
if (!link.up) return 'down';
if (link.name_mismatch) return 'mismatch';
if ((link.errors || 0) > 0) return 'errors';
return '';
}
function nodeProblem(node) {
if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : '';
if (node.status === 'down' || node.status === 'degraded') return node.status;
return node.alert_count ? 'alert' : '';
}
function endText(e, byId) {
const n = byId[e.node];
const name = n ? n.name : e.node;
if (e.port_idx != null) return `${name} port ${e.port_idx}`;
return e.iface ? `${name} ${e.iface}` : name;
}
function problemsOf(data) {
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
const out = [];
data.nodes.forEach(n => {
const p = nodeProblem(n);
if (!p) return;
const bits = [];
if (n.status === 'down' || n.status === 'degraded') bits.push(n.status);
if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`);
out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn',
text: `${n.name}: ${bits.join(', ')}` });
});
data.links.forEach(l => {
const p = linkProblem(l);
if (!p) return;
// A missing device is one problem, not two: say where it was last seen on its own entry
const listed = out.find(o => o.kind === 'node' && o.id === l.b.node);
if (p === 'ghost' && listed) {
listed.text += `, last seen on ${endText(l.a, byId)}`;
return;
}
const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null;
let text;
if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`;
else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`;
else if (p === 'mismatch') {
const m = l.name_mismatch;
const end = m.end === 'b' ? l.b : l.a;
text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`;
} else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`;
out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text });
});
return out;
}
function speedClass(mbps) {
if (mbps >= 10000) return 'tp-s-10g';
if (mbps >= 1000) return 'tp-s-1g';
@@ -393,8 +454,19 @@ const CORNER = 26;
// the viewer opens that switch's group (or shows them all)
function visibleGraph(data, filters, expanded) {
const mode = filters.stubs;
const all = Object.fromEntries(data.nodes.map(n => [n.id, n]));
let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
let links = data.links.slice();
if (!filters.mgmt) {
// A server's slower second link (its 1G management cable) is hidden while it is healthy and
// the server has a faster one; a problem cable is always drawn
links = links.filter(l => {
const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host');
if (!host || linkProblem(l)) return true;
const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0));
return !faster;
});
}
if (mode === 'grouped') {
const bySwitch = {};
nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n));
@@ -488,7 +560,7 @@ function layout(data, filters, expanded) {
const sizes = {};
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; }));
data.links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
const d = cardFor(n, byIface);
drawings[n.id] = d;
sizes[n.id] = { w: d.w, h: d.h };
@@ -668,7 +740,8 @@ function renderLink(link, lay) {
if (link.ghost) cls.push('tp-ghost');
else if (!link.up) cls.push('tp-down');
if (link.wan) cls.push('tp-wan');
if (!link.wan && link.speed_mbps < 10000) cls.push('tp-slow');
const problem = linkProblem(link);
if (problem) cls.push('tp-problem', `tp-problem-${problem}`);
const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0);
const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count);
if (alerting) cls.push('tp-touches-alert');
@@ -701,7 +774,7 @@ function renderLink(link, lay) {
// The cable runs behind the devices; its plugs, traffic and warning sit on top
under: `<g ${attrs} tabindex="0" role="button" aria-label="${esc(linkTitle(link, lay.byId))}">
<title>${esc(linkTitle(link, lay.byId))}</title>
<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
${problem ? `<path class="tp-halo" d="${d}"/>` : ''}<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
</g>`,
over: `<g ${attrs} aria-hidden="true">${plugs}${label}${warn}</g>`,
};
@@ -721,7 +794,7 @@ function renderNode(node, lay) {
? `<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})">
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}${nodeProblem(node) ? ' tp-node-problem' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
${open}<title>${esc(label)}</title>
${d.svg}
${ring}
@@ -740,7 +813,6 @@ function applyView() {
function applyFilterClasses() {
const canvas = document.getElementById('tp-canvas');
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(el => {
if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter];
@@ -1041,6 +1113,12 @@ function initEvents() {
svg.addEventListener('mouseleave', () => highlight(null));
svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); });
document.getElementById('tp-summary').addEventListener('click', e => {
const chip = e.target.closest('.tp-problem-chip');
if (!chip) return;
if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode);
else focusOn('link', chip.dataset.focusLink);
});
document.getElementById('tp-panel').addEventListener('click', e => {
if (e.target.closest('[data-action="close-panel"]')) hidePanel();
});
@@ -1050,7 +1128,7 @@ function initEvents() {
state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked;
saveFilters();
if (el.dataset.filter === 'stubs') state.expanded.clear();
if (el.dataset.filter === 'stubs' || el.dataset.filter === 'power') render();
if (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) render();
else applyFilterClasses();
});
});
@@ -1067,6 +1145,51 @@ function initEvents() {
});
}
// ── Problem summary ─────────────────────────────────────────────────────
function renderSummary() {
const box = document.getElementById('tp-summary');
const problems = problemsOf(state.data);
const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length;
const cables = state.data.links.length;
if (!problems.length) {
box.className = 'tp-summary tp-summary-ok';
box.innerHTML = `<span class="tp-summary-head">✓ All clear</span><span class="tp-summary-text">${devices} devices and ${cables} cables, nothing down or mislabelled</span>`;
return;
}
box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`;
box.innerHTML = `<span class="tp-summary-head">⚠ ${problems.length} to look at</span>
${problems.map(p => `<button type="button" class="tp-problem-chip tp-chip-${p.severity}" data-focus-${p.kind}="${esc(p.id)}">${esc(p.text)}</button>`).join('')}`;
}
// Centre the view on a device or cable and show what is wrong with it
function focusOn(kind, id) {
const lay = state.layout;
const canvas = document.getElementById('tp-canvas');
let x, y;
if (kind === 'node') {
const p = lay.pos[id];
if (!p) return;
x = p.x + p.w / 2;
y = p.y + lay.drawings[id].h / 2;
} else {
const r = lay.routes[id];
if (!r) return;
x = r.mid.x;
y = r.mid.y;
}
const k = Math.max(state.view.k, 0.55);
state.view = { k, tx: canvas.clientWidth / 2 - x * k, ty: canvas.clientHeight / 2 - y * k };
applyView();
if (kind === 'link') {
const link = state.data.links.find(l => l.id === id);
if (link) showPanel(link);
} else {
hidePanel();
highlight(id);
}
canvas.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Data ────────────────────────────────────────────────────────────────
async function refresh() {
try {
@@ -1075,6 +1198,7 @@ async function refresh() {
// The layout follows from the devices and cables alone, so a redraw with the same ones keeps
// everything where it was; the view (pan and zoom) is kept either way
render();
renderSummary();
const warn = document.getElementById('tp-warnings');
warn.hidden = !(data.warnings && data.warnings.length);
warn.innerHTML = (data.warnings || []).map(w => `<div>${esc(w)}</div>`).join('');
+2 -1
View File
@@ -19,7 +19,7 @@
</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>
<label class="tp-filter" title="Each server's slower second cable. A problem cable is always shown."><input type="checkbox" data-filter="mgmt"> Server 1G links</label>
<label class="tp-filter"><input type="checkbox" data-filter="alerts"> Alerts only</label>
</div>
<div class="tp-zoom" role="group" aria-label="Zoom">
@@ -31,6 +31,7 @@
</div>
<div class="tp-warnings" id="tp-warnings" role="status" hidden></div>
<div class="tp-summary" id="tp-summary" aria-live="polite"></div>
<div class="tp-canvas" id="tp-canvas" tabindex="0" aria-label="Network topology diagram. Use plus, minus and zero to zoom, and Tab to move between devices and cables.">
<svg class="tp-svg" id="tp-svg" xmlns="http://www.w3.org/2000/svg" role="group" aria-label="Network topology">