diff --git a/README.md b/README.md index ad25798..ab44251 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/docs/img/topology.png b/docs/img/topology.png index 6bfdb07..a101532 100644 Binary files a/docs/img/topology.png and b/docs/img/topology.png differ diff --git a/static/style.css b/static/style.css index 91dc0c6..04d3e4b 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/static/topology.js b/static/topology.js index af7b528..9764d8d 100644 --- a/static/topology.js +++ b/static/topology.js @@ -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: ` ${esc(linkTitle(link, lay.byId))} - ${flow} + ${problem ? `` : ''}${flow} `, over: ``, }; @@ -721,7 +794,7 @@ function renderNode(node, lay) { ? `` : (href ? `` : ``); const close = open.startsWith('' : ''; - return ` + return ` ${open}${esc(label)} ${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 = `✓ All clear${devices} devices and ${cables} cables, nothing down or mislabelled`; + return; + } + box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`; + box.innerHTML = `⚠ ${problems.length} to look at + ${problems.map(p => ``).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 => `
${esc(w)}
`).join(''); diff --git a/templates/topology.html b/templates/topology.html index db33363..04eef79 100644 --- a/templates/topology.html +++ b/templates/topology.html @@ -19,7 +19,7 @@ - +
@@ -31,6 +31,7 @@
+