diff --git a/README.md b/README.md index 7a48efb..cfedb66 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/docs/img/topology.png b/docs/img/topology.png index d631607..5a3cf04 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 7e8e4d2..db0f172 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/static/topology.js b/static/topology.js index b03857c..457823b 100644 --- a/static/topology.js +++ b/static/topology.js @@ -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 ``; + }).join(''); + return { + w, h, + svg: `${jacks} + click to show`, + 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 ? `` : ''; - const label = link.ghost + const label = link.bundle + ? `${link.count} cables` + : link.ghost ? `last seen` : (link.up ? ` ↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}` : ''); @@ -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 ? `` : ``; - const close = href ? '' : ''; + const open = node.kind === 'group' + ? `` + : (href ? `` : ``); + const close = open.startsWith('' : ''; return ` ${open}${esc(label)} @@ -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(); }); }); diff --git a/templates/topology.html b/templates/topology.html index 9fa79c5..eba55e8 100644 --- a/templates/topology.html +++ b/templates/topology.html @@ -5,13 +5,18 @@

Topology

-

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.

+

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.