diff --git a/README.md b/README.md index cfedb66..ad25798 100644 --- a/README.md +++ b/README.md @@ -185,12 +185,13 @@ 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. -- **Drawn to scale from the real gear**: UniFi devices use their faceplates (the same drawings as the - 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, +- **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 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 diff --git a/docs/img/topology.png b/docs/img/topology.png index 5a3cf04..6bfdb07 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 db0f172..91dc0c6 100644 --- a/static/style.css +++ b/static/style.css @@ -1417,7 +1417,7 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } /* ── Topology page ─────────────────────────────────────────────────── */ .tp-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 8px 0 10px; } .tp-filters { display: flex; flex-wrap: wrap; gap: 6px 14px; } -.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; } +.tp-filter { display: inline-flex; align-items: center; gap: 6px; font-size: .78em; color: var(--text-dim); cursor: pointer; white-space: nowrap; } .tp-zoom { display: inline-flex; gap: 4px; } .tp-updated { margin-left: auto; font-size: .72em; color: var(--text-muted); } .tp-warnings { border: 1px solid var(--amber); color: var(--amber); background: var(--amber-dim); padding: 8px 12px; margin-bottom: 10px; font-size: .8em; } @@ -1504,7 +1504,7 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .tp-strip { fill: #1b1f24; stroke: #2f363f; stroke-width: 3; } .tp-nic-rj45, .tp-nic-sfp { fill: #0b0d10; stroke: #59626e; stroke-width: 3; } .tp-nic-sfp-in { fill: #23272d; } -.tp-nic-name { font-family: var(--font); font-size: 18px; fill: #aab4bf; } +.tp-nic-name { font-family: var(--font); font-size: 17px; fill: #aab4bf; } .tp-nic-led { fill: #3a3f45; } .tp-nic-up .tp-nic-led { fill: #2fd36b; } .tp-nic-up .tp-nic-rj45, .tp-nic-up .tp-nic-sfp { stroke: #8f9aa6; } @@ -1597,3 +1597,20 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; } .tp-key-down { stroke: #e0312b; stroke-dasharray: 6 4; } .tp-key-ghost { stroke: #e0312b; stroke-dasharray: 1 5; } .tp-key-warn { color: #ffb300; font-weight: 700; } + +/* Topology cards */ +.tp-card { fill: #121821; stroke: #2a3440; stroke-width: 3; } +[data-theme="light"] .tp-card { fill: #ffffff; stroke: #c9d1da; } +.tp-band { fill: #0a0e13; stroke: #1c242e; stroke-width: 2; } +[data-theme="light"] .tp-band { fill: #eef1f4; stroke: #dde3ea; } +.tp-card-name { font-family: var(--font); font-size: 44px; font-weight: 700; fill: var(--text); letter-spacing: .01em; } +.tp-card-sub { font-family: var(--font); font-size: 28px; fill: var(--text-muted); } +.tp-card-stripe { fill: #6b737d; } +.tp-stripe-up { fill: #2fd36b; } +.tp-stripe-degraded { fill: #ffb300; } +.tp-stripe-down { fill: #e0312b; } +.tp-node-link:hover .tp-card, .tp-node-link:focus-visible .tp-card { stroke: var(--amber); } +.tp-kind-stub .tp-card, .tp-kind-group .tp-card { stroke-dasharray: 14 10; } +.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; } diff --git a/static/topology.js b/static/topology.js index 457823b..af7b528 100644 --- a/static/topology.js +++ b/static/topology.js @@ -1,16 +1,11 @@ 'use strict'; /* global FACEPLATES, faceplateSvg, faceplatePortAnchor, RACK_FRONTS, SHELF_GEAR, DESK_FACEPLATES, CHASSIS_FACEPLATES, rackFrontSvg */ -// Topology page: every device drawn as its real faceplate or chassis, with each cable leaving -// the exact port it is plugged into. Data comes from /api/topology (topology.build_graph). -// -// World units: a 19" panel is 1100 wide (as on the rack page), so everything is to scale. +// Topology page: a card per device with its real faceplate or chassis, and each cable leaving the +// exact port it is plugged into. Data comes from /api/topology (topology.build_graph). const esc = s => lt.escHtml(s == null ? '' : String(s)); const PER_MM = 1100 / 482.6; -const NODE_GAP = 120; -const LABEL_H = 104; -const STRIP_H = 104; -const SOCKET_W = 118; +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 }; @@ -107,7 +102,15 @@ function portTitle(port, idx, role) { function faceplateDrawing(node, model, width, opts = {}) { const spec = FACEPLATES[model]; - const box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height]; + let box = (opts.rack && spec.rackView) || [0, 0, spec.width, spec.height]; + if (opts.crop && spec.ports.length > 2) { + // Just the port area, so the ports are big enough to read in a card + const left = spec.ports.map(q => (q.type === 'sfp' ? q.x : q.x - 20 * (q.scale || 1))); + const right = spec.ports.map(q => (q.type === 'sfp' ? q.x + q.w : q.x + 20 * (q.scale || 1))); + const x0 = Math.max(0, Math.min(...left) - 24); + const x1 = Math.min(spec.width, Math.max(...right) + 24); + box = [x0, 0, x1 - x0, spec.height]; + } const k = width / box[2]; const h = box[3] * k; // A rack view (the UPS's front without its rear) is a viewBox onto part of the drawing @@ -115,7 +118,7 @@ function faceplateDrawing(node, model, width, opts = {}) { swName: node.name, portMap: portMapOf(node), outlets: node.outlets || [], online: node.status === 'up', rack: !!opts.rack, titleFor: portTitle, }, `x="${opts.offsetX || 0}" y="0" width="${width}" height="${h}"`).svg; - const drawn = nested(svg); + const drawn = nested(svg).replace(/viewBox="[^"]*"/, `viewBox="${box.join(' ')}"`); return { w: width, h, svg: drawn, anchor(end) { @@ -253,86 +256,128 @@ function stubDrawing() { }; } -// A host: its drawn front (from the cutsheet's Front drawing) under a strip of its NIC sockets, -// one per physical interface, so each cable lands on the NIC it is plugged into. -function hostDrawing(node, linksByIface) { - let front; +// A host's drawn front, from its cutsheet item's Front drawing +function hostFront(node) { const ctx = { name: node.name, status: node.status, interfaces: node.interfaces || [] }; if (RACK_FRONTS[node.chassis]) { const spec = RACK_FRONTS[node.chassis]; - front = { - w: spec.width, h: spec.height, - svg: nested(rackFrontSvg(node.chassis, ctx)).replace('${esc(`${node.name} · ${gear.label}`)}${gear.draw(ctx)}`, - }; - } else if (CHASSIS_FACEPLATES[node.chassis]) { - front = faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100); - } else { - front = { - w: 560, h: 150, - svg: ` - - server - ${esc(node.name)}: no front drawing on the cutsheet`, - }; + return { w: spec.width, h: spec.height, + svg: nested(rackFrontSvg(node.chassis, ctx)).replace(' { - if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' }); - }); - const stripW = Math.max(nics.length * SOCKET_W + 24, 200); - const w = Math.max(front.w, stripW); - const sx = (w - stripW) / 2; - const fx = (w - front.w) / 2; - const pos = {}; - const sockets = nics.map((nic, i) => { - const link = linksByIface[nic.name]; - const cx = sx + 12 + i * SOCKET_W + SOCKET_W / 2; - pos[nic.name] = cx; - const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium); - let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down'); - if (link && !link.up) st = 'down'; - const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`; - const shape = sfp - ? `` - : ``; - return `${esc(title)} - ${shape} - ${esc(nic.name.length > 11 ? `${nic.name.slice(0, 10)}…` : nic.name)}`; - }).join(''); - return { - w, h: STRIP_H + 14 + front.h, - svg: `${sockets}${front.svg}`, - anchor: end => (end.iface && pos[end.iface] != null ? { x: pos[end.iface], y: 14, face: 'top' } : null), - }; + if (SHELF_GEAR[node.chassis]) { + const gear = SHELF_GEAR[node.chassis]; + return { w: gear.width, h: gear.height, + svg: `${gear.draw(ctx)}` }; + } + if (CHASSIS_FACEPLATES[node.chassis]) return faceplateDrawing(node, CHASSIS_FACEPLATES[node.chassis], 1100); + return null; } -function drawingFor(node, linksByIface) { +// The picture inside a card, for everything but hosts +function artFor(node) { const spec = FACEPLATES[node.model]; switch (node.kind) { case 'internet': return internetDrawing(); 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: if (spec && DESK_FACEPLATES[node.model]) return deskFaceplate(node); - if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups' }); + if (spec) return faceplateDrawing(node, node.model, 1100, { rack: node.kind === 'ups', crop: !node.power }); if (node.kind === 'switch' || node.kind === 'router') return genericSwitch(node); return boxDrawing(node, node.model || node.kind); } } +// ── Cards ─────────────────────────────────────────────────────────────── +// Every device is a card of the same height: its name and state on top, and below that its real +// faceplate (or, for a server, its front and a socket per NIC) shrunk to fit. Cables meet the card +// edge right above or below the port they are plugged into. +const CARD_W = 640; +const CARD_SMALL_W = 460; +const CARD_H = 300; +const BAND_Y = 132; +const BAND_H = 140; +const NIC_W = 104; + +function fitArt(art, x, y, w, h) { + const k = Math.min(w / art.w, h / art.h); + return { k, ox: x + (w - art.w * k) / 2, oy: y + (h - art.h * k) / 2 }; +} + +function nicSockets(node, linksByIface, x0) { + const nics = (node.interfaces || []).slice(); + Object.keys(linksByIface).forEach(iface => { + if (iface && !nics.some(n => n.name === iface)) nics.push({ name: iface, up: true, unused: false, alert: '' }); + }); + const pos = {}; + const svg = nics.map((nic, i) => { + const link = linksByIface[nic.name]; + const cx = x0 + i * NIC_W + NIC_W / 2; + const cy = BAND_Y + 40; + pos[nic.name] = { x: cx, y: cy - 20 }; + const sfp = link && ['dac', 'fibre', 'sfp'].includes(link.medium); + let st = nic.unused ? 'unused' : (nic.up ? 'up' : 'down'); + if (link && !link.up) st = 'down'; + const title = `${nic.name} · ${nic.unused ? 'marked unused' : (nic.up ? 'up' : 'down')}${link ? ` · ${speedText(link.speed_mbps)} ${MEDIUM_TEXT[link.medium] || ''}` : ' · no cable seen'}`; + const shape = sfp + ? `` + : ``; + const label = nic.name.length > 9 ? `${nic.name.slice(0, 8)}…` : nic.name; + return `${esc(title)} + ${shape} + ${esc(label)}`; + }).join(''); + return { svg, pos, w: nics.length * NIC_W }; +} + +function cardFor(node, linksByIface) { + let w, band, anchorAt; + if (node.kind === 'host') { + const front = hostFront(node); + const sockets0 = nicSockets(node, linksByIface, 0); + const frontW = front ? 220 : 0; + w = Math.max(CARD_W, 56 + frontW + (front ? 24 : 0) + sockets0.w, Math.min(node.name.length * 27 + 130, 980)); + const sx = 28 + (front ? frontW + 24 : (w - 56 - sockets0.w) / 2); + const sockets = nicSockets(node, linksByIface, sx); + let art = ''; + if (front) { + const f = fitArt(front, 28, BAND_Y, frontW, BAND_H); + art = `${front.svg}`; + } + band = `${art}${sockets.svg}`; + anchorAt = end => (end.iface && sockets.pos[end.iface]) || null; + } else { + const small = ['internet', 'modem', 'stub', 'group', 'ap', 'strip'].includes(node.kind); + w = Math.max(small ? CARD_SMALL_W : CARD_W, Math.min(node.name.length * 27 + 130, 980)); + const art = artFor(node); + const f = fitArt(art, 28, BAND_Y, w - 56, BAND_H); + band = `${art.svg}`; + anchorAt = end => { + const a = art.anchor(end); + return a ? { x: f.ox + a.x * f.k, y: f.oy + a.y * f.k } : null; + }; + } + const maxChars = Math.floor((w - 130) / 27); + const name = node.name.length > maxChars ? `${node.name.slice(0, maxChars - 1)}…` : node.name; + let sub; + if (node.kind === 'group') sub = `on ${node.switch}`; + else if (node.kind === 'stub') sub = node.ip ? `${node.switch} port ${node.port_idx}` : (node.mac || `${node.switch} port ${node.port_idx}`); + else if (node.kind === 'host') sub = node.status === 'up' ? 'server' : `server · ${node.status}`; + else if (node.kind === 'internet') sub = node.status === 'up' ? 'connected' : node.status; + else sub = [node.model || node.kind, node.status !== 'up' ? node.status : ''].filter(Boolean).join(' · '); + const svg = ` + + ${esc(name)} + ${esc(sub)} + + + ${band}`; + return { w, h: CARD_H, svg, anchorAt }; +} + // ── Layout ────────────────────────────────────────────────────────────── // 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 @@ -376,27 +421,32 @@ function visibleGraph(data, filters, expanded) { return { nodes, links: links.filter(l => ids.has(l.a.node) && ids.has(l.b.node)) }; } +// The internet, modem and gateway share the top row, left to right; then a row per hop, with +// unknown devices beside the gear on their switch; servers along the bottom function rowKey(node) { if (node.kind === 'host') return 1e6; if (node.depth == null) return 1e5; - return (node.kind === 'stub' || node.kind === 'group') ? node.depth + 0.5 : node.depth; + if (['internet', 'modem', 'router'].includes(node.kind) && node.depth <= 2) return 0; + return node.depth; } +// Where a cable meets a card: on its bottom edge (leaving) or top edge (arriving), right above or +// below the port it is plugged into, which is given as `port` for the short tail drawn to it function anchorOf(lay, end, asParent) { const p = lay.pos[end.node]; 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 (p.rail) return { x: p.x, y: p.y + d.h / 2, face: 'left', port: null }; + let a = null; 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 xs = end.ports.map(idx => d.anchorAt({ port_idx: idx })).filter(Boolean); + if (xs.length) a = { x: xs.reduce((t, q) => t + q.x, 0) / xs.length, y: Math.max(...xs.map(q => q.y)) }; + } else { + a = d.anchorAt(end); } - 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 }; + const x = p.x + (a ? a.x : d.w / 2); + return { x, y: p.y + (asParent ? d.h : 0), face: asParent ? 'bottom' : 'top', + port: a ? { x, y: p.y + a.y } : null }; } // Give each horizontal run in a tray a lane: fastest cables on the upper lanes, and two runs @@ -439,10 +489,9 @@ function layout(data, filters, expanded) { 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; })); - const d = drawingFor(n, byIface); + const d = cardFor(n, byIface); drawings[n.id] = d; - const labelW = Math.min(Math.max(n.name.length * 25, 120), 900); - sizes[n.id] = { w: Math.max(d.w, labelW), h: d.h + LABEL_H }; + sizes[n.id] = { w: d.w, h: d.h }; }); const lay = { pos: {}, drawings, nodes, links, byId, routes: {} }; @@ -471,6 +520,7 @@ function layout(data, filters, expanded) { }); let last = 0; items.forEach(it => { if (it.bary == null) it.bary = last; else last = it.bary; }); + if (r === 0) items.forEach(it => { it.bary = (it.n.depth || 0) * 1000; }); items.sort((a, b) => a.bary - b.bary || a.n.name.localeCompare(b.n.name)); let right = -Infinity; items.forEach(it => { @@ -491,7 +541,7 @@ function layout(data, filters, expanded) { } } } - lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: (it.w - drawings[it.n.id].w) / 2 }; + lay.pos[it.n.id] = { x, y: 0, w: it.w, dx: 0 }; right = x + it.w; }); }); @@ -508,6 +558,10 @@ function layout(data, filters, expanded) { if (!lay.pos[l.a.node]) return; const xa = ax(l.a); const toRail = byId[l.b.node].power; + if (!toRail && rowOf[l.a.node] === rowOf[l.b.node]) { + plan[l.id] = { across: true }; + return; + } let r, xb; if (toRail) { r = Math.min(rowOf[l.a.node] + 1, rows.length); @@ -552,6 +606,15 @@ function layout(data, filters, expanded) { links.forEach(l => { const p = plan[l.id]; if (!p) return; + if (p.across) { + // Side by side in the top row: straight across from one card to the next + const [pa, pb] = [lay.pos[l.a.node], lay.pos[l.b.node]]; + const left = pa.x < pb.x; + const A = { x: left ? pa.x + pa.w : pa.x, y: pa.y + CARD_H / 2, port: null }; + const B = { x: left ? pb.x : pb.x + pb.w, y: pb.y + CARD_H / 2, port: null }; + lay.routes[l.id] = { d: roundedPath([A, B]), mid: { x: (A.x + B.x) / 2, y: A.y - 40 }, A, B }; + 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; @@ -626,9 +689,13 @@ function renderLink(link, lay) { warn = `${esc(text)} !`; } - const plugs = ['dac', 'fibre', 'sfp'].includes(link.medium) - ? `` - : ``; + // A short tail from the card edge to the port on the picture, ending in the plug + const plug = P => (['dac', 'fibre', 'sfp'].includes(link.medium) + ? `` + : ``); + const tail = E => (E.port + ? `${plug(E.port)}` : ''); + const plugs = `${tail(A)}${tail(B)}`; const attrs = `class="${cls.join(' ')}" data-link="${esc(link.id)}"`; return { // The cable runs behind the devices; its plugs, traffic and warning sit on top @@ -644,13 +711,9 @@ function renderNode(node, lay) { const p = lay.pos[node.id]; const d = lay.drawings[node.id]; const href = nodeHref(node); - 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 - ? ` - ${node.alert_count}` + ? ` + ${node.alert_count}` : ''; 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}` : ''}`; @@ -660,12 +723,8 @@ function renderNode(node, lay) { const close = open.startsWith('' : ''; return ` ${open}${esc(label)} - + ${d.svg} ${ring} - ${d.svg} - ${node.kind === 'internet' ? '' : ``} - ${node.kind === 'internet' ? '' : `${esc(node.name)}`} - ${esc(sub)} ${close} `; } @@ -702,6 +761,7 @@ function render() { document.getElementById('tp-loading').hidden = true; applyFilterClasses(); if (!state.fitted) { + sizeCanvas(); fit(); state.fitted = true; } else { @@ -814,6 +874,19 @@ function highlight(id) { const K_MIN = 0.03; const K_MAX = 3; +// On a narrow screen the diagram is much wider than tall: size the canvas to it instead of +// leaving most of a tall canvas empty +function sizeCanvas() { + const canvas = document.getElementById('tp-canvas'); + if (!state.layout || canvas.clientWidth > 700) { + canvas.style.height = ''; + return; + } + const { x0, y0, x1, y1 } = state.layout.bounds; + const want = canvas.clientWidth * (y1 - y0) / (x1 - x0); + canvas.style.height = `${Math.round(Math.max(440, Math.min(want + 24, window.innerHeight * 0.75)))}px`; +} + function fit() { if (!state.layout) return; const canvas = document.getElementById('tp-canvas'); diff --git a/templates/topology.html b/templates/topology.html index eba55e8..db33363 100644 --- a/templates/topology.html +++ b/templates/topology.html @@ -5,7 +5,7 @@

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, or a group of unknown devices to open it.

+

Every device and cable as the switches see them right now. 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.