From 3dce602938cf23747fe43e3ce2ac42b715d4f322 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 14 Mar 2026 22:22:19 -0400 Subject: [PATCH] Redesign topology diagram with dual-homed bus layout and improve inspector chassis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flat topology with tiered bus-bar layout: Internet → UDM-Pro → SVG fork → USW-Agg + Pro 24 PoE → dual-homed servers - Show 10G VLAN90 (Ceph) bus from USW-Agg and 1G DHCP management bus from Pro 24 PoE per host - Add per-host drop wires (solid 10G + dashed 1G) with correct rack positions - Mark large1 as off-rack (dashed border), ZimaBoards as off-rack mon-01/mon-02 - Add topology legend, inter-switch 10G ISL indicator - Add recently resolved events section (last 24h) to dashboard - Add last_seen column and relative timestamps to events table - Add stale data banner when monitoring data >15 min old - Improve inspector chassis with port speed labels, LLDP neighbor info, mounting ears, chassis legend - Add duplex/speed mismatch warnings and carrier changes to path debug panel - Bump updateTopology() to handle both topo-v2-status-* and topo-status-* classes Co-Authored-By: Claude Sonnet 4.6 --- static/app.js | 2 + static/style.css | 433 +++++++++++++++++++++++++++++++++++++++ templates/index.html | 205 +++++++++++------- templates/inspector.html | 65 +++++- 4 files changed, 620 insertions(+), 85 deletions(-) diff --git a/static/app.js b/static/app.js index 38d1280..38cc979 100644 --- a/static/app.js +++ b/static/app.js @@ -109,7 +109,9 @@ function updateTopology(hosts) { const name = node.dataset.host; const host = hosts[name]; if (!host) return; + node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, ''); node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, ''); + node.classList.add(`topo-v2-status-${host.status}`); node.classList.add(`topo-status-${host.status}`); const badge = node.querySelector('.topo-badge'); if (badge) { diff --git a/static/style.css b/static/style.css index 3cdf235..713d386 100644 --- a/static/style.css +++ b/static/style.css @@ -1534,6 +1534,439 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); } text-shadow: var(--glow-cyan); } +/* ── Topology v2 – professional network diagram ──────────────────── */ + +/* Outer wrapper */ +.topo-v2 { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + min-width: 860px; + padding: 20px 24px 24px; + position: relative; +} + +/* Each tier row */ +.topo-tier { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +/* ── Vertical connector section between tiers ── */ +.topo-vc { + display: flex; + justify-content: center; + align-items: flex-start; + width: 100%; + position: relative; + height: 40px; +} + +/* Single centered vertical wire */ +.topo-vc-wire { + position: absolute; + left: 50%; + top: 0; + transform: translateX(-50%); + width: 2px; + height: 100%; + background: linear-gradient(to bottom, var(--cyan), var(--green)); + opacity: .7; +} + +/* Labeled vertical connector */ +.topo-vc-label { + position: absolute; + left: calc(50% + 6px); + top: 50%; + transform: translateY(-50%); + font-size: .58em; + color: var(--amber); + text-shadow: var(--glow-amber); + white-space: nowrap; + letter-spacing: .06em; + font-family: var(--font); +} + +/* ── WAN tier node (Internet + Router side by side) ── */ +.topo-tier-wan { + gap: 0; + flex-direction: column; + align-items: center; +} + +/* ── Individual node boxes ── */ +.topo-v2-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 8px 16px; + border: 1px solid var(--border); + background: var(--bg3); + position: relative; + font-size: .75em; + font-family: var(--font); + min-width: 110px; + text-align: center; + transition: border-color .2s, box-shadow .2s; +} +.topo-v2-node::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; } +.topo-v2-node::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; } + +.topo-v2-icon { font-size:1.3em; line-height:1; } +.topo-v2-label { font-weight:bold; letter-spacing:.04em; } +.topo-v2-sub { font-size:.58em; color:var(--text-muted); letter-spacing:.02em; } +.topo-v2-vlan { font-size:.54em; color:var(--cyan); opacity:.75; letter-spacing:.02em; } + +/* Node type colours */ +.topo-v2-internet { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); } +.topo-v2-router { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); } +.topo-v2-switch { border-color:var(--amber); color:var(--amber); text-shadow:var(--glow-amber); } +.topo-v2-host { border-color:var(--border); color:var(--text); cursor:default; } + +/* ── Switch tier: both switches with inter-switch link ── */ +.topo-switch-pair { + display: flex; + align-items: center; + gap: 0; +} + +.topo-isl { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 12px; + gap: 4px; +} +.topo-isl-wire { + width: 80px; + height: 2px; + background: linear-gradient(to right, var(--amber), var(--amber)); + opacity: .6; + position: relative; +} +.topo-isl-wire::before, +.topo-isl-wire::after { + content: ''; + position: absolute; + top: -3px; + width: 2px; + height: 8px; + background: var(--amber); + opacity: .6; +} +.topo-isl-wire::before { left: 0; } +.topo-isl-wire::after { right: 0; } +.topo-isl-label { + font-size: .54em; + color: var(--amber); + opacity: .75; + white-space: nowrap; + letter-spacing: .05em; + font-family: var(--font); +} + +/* ── Dual-home bus section ── */ +/* This is the complex section linking two switches to N hosts */ +.topo-bus-section { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + margin-top: 0; +} + +/* Bus bar row: the horizontal rail that distributes to hosts */ +.topo-bus-bars { + display: flex; + flex-direction: column; + align-items: stretch; + position: relative; + width: 100%; +} + +/* The two drop buses: 10G (green) and 1G mgmt (amber dashed) */ +.topo-bus-10g { + display: flex; + align-items: center; + position: relative; + height: 20px; +} +.topo-bus-10g-line { + flex: 1; + height: 2px; + background: var(--green); + opacity: .45; + margin: 0 4px; +} +.topo-bus-10g-label { + font-size: .56em; + color: var(--green); + text-shadow: var(--glow); + white-space: nowrap; + letter-spacing: .05em; + font-family: var(--font); + opacity: .85; + padding: 0 8px; +} + +.topo-bus-1g { + display: flex; + align-items: center; + position: relative; + height: 18px; +} +.topo-bus-1g-line { + flex: 1; + height: 0; + border-top: 2px dashed var(--amber); + opacity: .35; + margin: 0 4px; +} +.topo-bus-1g-label { + font-size: .56em; + color: var(--amber); + text-shadow: var(--glow-amber); + white-space: nowrap; + letter-spacing: .05em; + font-family: var(--font); + opacity: .8; + padding: 0 8px; +} + +/* ── Host row ── */ +.topo-v2-hosts { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + padding-top: 4px; + width: 100%; +} + +/* Host status colouring */ +.topo-v2-status-up { border-color:var(--green); box-shadow:0 0 8px rgba(0,255,65,.2); } +.topo-v2-status-down { border-color:var(--red); box-shadow:0 0 8px rgba(255,68,68,.35); animation:pulse-glow 2s infinite; } +.topo-v2-status-degraded{ border-color:var(--orange); box-shadow:0 0 8px rgba(255,140,0,.2); } +.topo-v2-status-unknown { border-color:var(--border); } + +/* Off-rack host: dashed border */ +.topo-v2-offrack { border-style: dashed !important; } + +/* ── Legend row ── */ +.topo-legend { + display: flex; + gap: 18px; + align-items: center; + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid rgba(0,255,65,.12); + flex-wrap: wrap; + justify-content: center; +} +.topo-legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: .58em; + color: var(--text-muted); + font-family: var(--font); +} +.topo-legend-line-10g { + width: 24px; height: 2px; + background: var(--green); + display: inline-block; +} +.topo-legend-line-1g { + width: 24px; height: 0; + border-top: 2px dashed var(--amber); + display: inline-block; +} +.topo-legend-line-isl { + width: 24px; height: 2px; + background: var(--amber); + display: inline-block; +} +.topo-legend-line-wan { + width: 24px; height: 2px; + background: linear-gradient(to right, var(--cyan), var(--green)); + display: inline-block; +} + +/* ── Drop-wire stubs for host dual-homing ── */ +/* Wrapper that sits above each host showing its two connections */ +.topo-v2-host-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; +} + +.topo-v2-host-wires { + display: flex; + gap: 6px; + height: 28px; + align-items: flex-start; +} +.topo-v2-wire-10g { + width: 2px; + height: 100%; + background: var(--green); + opacity: .55; +} +.topo-v2-wire-1g { + width: 0; + height: 100%; + border-left: 2px dashed var(--amber); + opacity: .45; +} + +/* host badge */ +.topo-v2-badge { + font-size: .65em; + padding: 1px 5px; + border: 1px solid; + letter-spacing: .03em; + margin-top: 2px; +} +.topo-v2-badge-up { color:var(--green); border-color:var(--green); text-shadow:var(--glow); } +.topo-v2-badge-down { color:var(--red); border-color:var(--red); animation:pulse-glow 1.5s infinite; } +.topo-v2-badge-degraded{ color:var(--orange); border-color:var(--orange); } +.topo-v2-badge-unknown { color:var(--text-muted); border-color:var(--border); } + +/* vertical connector from router to switch tier */ +.topo-v2-fork { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + position: relative; + height: 50px; +} + +/* Fork tree SVG lines from UDM-Pro down to two switches */ +.topo-v2-fork svg { + width: 100%; + height: 100%; + overflow: visible; +} + +/* ── Improved chassis legend ── */ +.chassis-legend { + display: flex; + gap: 16px; + align-items: center; + padding: 7px 16px 8px; + border-top: 1px solid rgba(0,255,65,.1); + background: var(--bg2); + flex-wrap: wrap; +} +.chassis-legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: .58em; + color: var(--text-muted); + font-family: var(--font); + letter-spacing: .04em; + text-transform: uppercase; +} +.chassis-legend-swatch { + width: 14px; + height: 14px; + border: 1px solid; + flex-shrink: 0; + display: inline-block; +} +.cls-down { background:var(--bg3); border-color:rgba(0,255,65,.15); } +.cls-up { background:rgba(0,255,65,.06); border-color:var(--green-muted); } +.cls-poe { background:var(--amber-dim); border-color:var(--amber); } +.cls-uplink { background:var(--cyan-dim); border-color:var(--cyan); } + +/* ── Port block v2: flex-col with speed sub-label ── */ +.switch-port-block { + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1px; + padding: 2px 1px; +} +.port-num { + line-height: 1; + font-weight: bold; +} +.port-speed { + font-size: .72em; + opacity: .7; + line-height: 1; + font-weight: normal; +} +.port-lldp { + font-size: .62em; + opacity: .65; + line-height: 1; + max-width: 32px; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + font-weight: normal; +} + +/* US24PRO port group separators (every 2 ports = 1 pair gap) */ +.chassis-row.us24pro-row .switch-port-block:nth-child(2n+1):not(:first-child) { + margin-left: 6px; +} + +/* SFP block: taller and narrower cage look */ +.switch-port-block.sfp-block { + width: 36px; + height: 38px; + font-size: .55em; + letter-spacing: .04em; + border-left-width: 3px; +} +/* SFP port in rows */ +.switch-port-block.sfp-port { + width: 26px; + height: 40px; + font-size: .55em; + border-left-width: 2px; +} + +/* Chassis mounting ears */ +.chassis-body { + position: relative; +} +.chassis-ear-l, +.chassis-ear-r { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 36px; + background: var(--bg3); + border: 1px solid var(--border); +} +.chassis-ear-l { left: -9px; border-right: none; } +.chassis-ear-r { right: -9px; border-left: none; } +.chassis-ear-l::before, +.chassis-ear-r::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--border); +} + /* ── Responsive ───────────────────────────────────────────────────── */ @media (max-width: 768px) { .host-grid { grid-template-columns:1fr; } diff --git a/templates/index.html b/templates/index.html index 11d9b03..b29a1f9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -29,96 +29,149 @@
+
- -
-
- - Internet -
-
-
-
-
+ {%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%} - -
-
- - UDM-Pro - Dream Machine Pro · RU24 -
-
-
-
-
- - -
-
-
- - USW-Agg - 8×10G SFP+ · RU22 - VLAN90 · 10.10.90.x -
- -
- - Pro 24 PoE - 24×1G PoE · RU23 - DHCP mgmt -
+ +
+
+ + INTERNET + WAN uplink
- -
-
+ +
+
+ WAN · 10G SFP+ +
- -
-
-
-
-
-
-
+ +
+
+ + UDM-Pro + Dream Machine Pro + RU24 +
+
+ + +
+ + + + + + + + + + + 10G DAC + +
+ + +
+
+ + +
+ + USW-Agg + Aggregation · RU22 + 8 × 10G SFP+ + VLAN90 · 10.10.90.x/24
- - -
- ← 1G DHCP mgmt (Pro 24 PoE) → -
+ +
+
+ 10G SFP+
-
- {%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%} - {%- set all_defs = [ - ('compute-storage-gpu-01', 'csg-01', 'RU4–12 · VLAN90', False), - ('compute-storage-01', 'cs-01', 'RU14–17 · VLAN90', False), - ('storage-01', 'storage-01','rack · VLAN90', False), - ('monitor-01', 'monitor-01','ZimaBoard · VLAN90', False), - ('monitor-02', 'monitor-02','ZimaBoard · VLAN90', False), - ('large1', 'large1', 'table · VLAN90', True), - ] -%} - {%- for hname, hlabel, hsub, off_rack in all_defs -%} - {%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%} -
- - {{ hlabel }} - {{ hsub }} + +
+ + Pro 24 PoE + 24-Port · RU23 + 24 × 1G PoE + DHCP · mgmt +
+ +
+
+ + +
+ + +
+ ← USW-Agg · 10G SFP+ · VLAN90 → +
+
+ + +
+ ← Pro 24 PoE · 1G · DHCP mgmt → +
+
+ + +
+ {%- set all_defs = [ + ('compute-storage-gpu-01', 'csg-01', 'RU4–12', 'Ceph · VLAN90', False), + ('compute-storage-01', 'cs-01', 'RU14–17', 'Ceph · VLAN90', False), + ('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False), + ('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False), + ('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False), + ('large1', 'large1', 'off-rack', 'table', True), + ] -%} + {%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%} + {%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%} +
+ +
+
+
+
+ +
+ + {{ hlabel }} + {{ hsub }} + {{ hvlan }} {{ st if st != 'unknown' else '–' }}
- {%- endfor -%}
- + {%- endfor -%}
-
+ +
+ + +
+
WAN / uplink
+
ISL / inter-switch
+
10G SFP+ (Ceph / VLAN90)
+
1G DHCP (mgmt)
+
dashed border = off-rack
+
+ +
diff --git a/templates/inspector.html b/templates/inspector.html index 5caed04..014e1bf 100644 --- a/templates/inspector.html +++ b/templates/inspector.html @@ -84,16 +84,46 @@ function portBlockState(d) { return 'up'; } +// ── Speed label helper ─────────────────────────────────────────────────── +function portSpeedLabel(port) { + if (!port || !port.up) return ''; + const spd = port.speed; // speed in Mbps from UniFi API + if (!spd) return ''; + if (spd >= 10000) return '10G'; + if (spd >= 1000) return '1G'; + if (spd >= 100) return '100M'; + return spd + 'M'; +} + // ── Render a single port block element ────────────────────────────────── function portBlockHtml(idx, port, swName, sfpBlock) { - const state = portBlockState(port); - const label = sfpBlock ? 'SFP' : idx; - const title = port ? escHtml(port.name) : `Port ${idx}`; - const sfpCls = sfpBlock ? ' sfp-block' : ''; + const state = portBlockState(port); + const numLabel = sfpBlock ? 'SFP' : idx; + const title = port ? escHtml(port.name) : `Port ${idx}`; + const sfpCls = sfpBlock ? ' sfp-block' : ''; + const speedTxt = portSpeedLabel(port); + // LLDP neighbor: first 6 chars of hostname + const lldpName = (port && port.lldp_table && port.lldp_table.length) + ? escHtml((port.lldp_table[0].chassis_id_subtype === 'local' + ? port.lldp_table[0].chassis_id + : port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6)) + : ''; + const lldpHtml = lldpName ? `${lldpName}` : ''; + const speedHtml = speedTxt ? `${speedTxt}` : ''; return `
${label}
`; + onclick="selectPort(this)">${numLabel}${speedHtml}${lldpHtml}
`; +} + +// ── Chassis legend HTML ────────────────────────────────────────────────── +function chassisLegendHtml() { + return `
+
down
+
up
+
poe active
+
uplink
+
`; } // ── Render one switch chassis ──────────────────────────────────────────── @@ -107,26 +137,38 @@ function renderChassis(swName, sw) { const downCount = totCount - upCount; const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · '); + // Is this a US24PRO? Used to add group-separator class + const isUs24Pro = (model === 'US24PRO'); + let chassisHtml = ''; if (layout) { - const sfpPortSet = new Set(layout.sfp_ports || []); + const sfpPortSet = new Set(layout.sfp_ports || []); const sfpSectionSet = new Set(layout.sfp_section || []); // Main port rows chassisHtml += '
'; for (const row of layout.rows) { - chassisHtml += '
'; + const rowCls = isUs24Pro ? ' us24pro-row' : ''; + chassisHtml += `
`; for (const idx of row) { const port = portMap[idx]; const isSfp = sfpPortSet.has(idx); const sfpCls = isSfp ? ' sfp-port' : ''; const state = portBlockState(port); const title = port ? escHtml(port.name) : `Port ${idx}`; + const speedTxt = portSpeedLabel(port); + const lldpName = (port && port.lldp_table && port.lldp_table.length) + ? escHtml((port.lldp_table[0].chassis_id_subtype === 'local' + ? port.lldp_table[0].chassis_id + : port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6)) + : ''; + const speedHtml = speedTxt ? `${speedTxt}` : ''; + const lldpHtml = lldpName ? `${lldpName}` : ''; chassisHtml += `
${idx}
`; + onclick="selectPort(this)">${idx}${speedHtml}${lldpHtml}
`; } chassisHtml += '
'; } @@ -158,7 +200,12 @@ function renderChassis(swName, sw) { ${sw.ip ? `${escHtml(sw.ip)}` : ''} ${escHtml(meta)}
-
${chassisHtml}
+
+
+
+ ${chassisHtml} +
+ ${chassisLegendHtml()}
`; }