Topology: route cables through trays, group unknown devices
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m44s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 49s
Test / Python Tests (pytest) (push) Successful in 1m9s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m40s
Test / Python Tests (pytest) (pull_request) Successful in 2m50s
Lint / JS (eslint) (push) Successful in 8s
Lint / Python (flake8) (push) Successful in 1m44s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m48s
Lint / Python (flake8) (pull_request) Successful in 49s
Test / Python Tests (pytest) (push) Successful in 1m9s
Lint / JS (eslint) (pull_request) Successful in 8s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (pull_request) Successful in 2m40s
Test / Python Tests (pytest) (pull_request) Successful in 2m50s
The curved cables piled on top of each other. Cables now run like cable management: straight down from the port into the tray above the row they go to, along a lane of their own (10G on the upper lanes), then straight down into the socket. Nothing overlaps and crossings are square. Devices sit clear of cables passing down through their row, under their fastest cable. Each switch's unknown devices are one block with a bundled cable; click it to open them, or show them one by one or hide them from the toolbar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 149 KiB |
@@ -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; }
|
||||
|
||||
+248
-69
@@ -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 `<path class="tp-stub-jack${m.status === 'up' ? '' : ' tp-off'}" d="M${x + 8} ${y + 10} h12 v-6 h16 v6 h12 v26 h-40 Z"/>`;
|
||||
}).join('');
|
||||
return {
|
||||
w, h,
|
||||
svg: `<rect class="tp-stub-body tp-group-body" width="${w}" height="${h}" rx="12"/>${jacks}
|
||||
<text class="tp-group-hint" x="${w / 2}" y="${h - 14}" text-anchor="middle">click to show</text>`,
|
||||
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
|
||||
? `<path class="tp-flow" d="${d}" style="animation-duration:${flowDur}s"/>` : '';
|
||||
const label = link.ghost
|
||||
const label = link.bundle
|
||||
? `<g class="tp-tag tp-tag-bundle" transform="translate(${mid.x} ${mid.y})"><rect x="-100" y="-26" width="200" height="44" rx="22"/><text y="6" text-anchor="middle">${link.count} cables</text></g>`
|
||||
: link.ghost
|
||||
? `<g class="tp-tag tp-tag-ghost" transform="translate(${mid.x} ${mid.y})"><rect x="-110" y="-26" width="220" height="44" rx="22"/><text y="6" text-anchor="middle">last seen</text></g>`
|
||||
: (link.up ? `<g class="tp-tag tp-rate" transform="translate(${mid.x} ${mid.y})"><rect x="-190" y="-26" width="380" height="44" rx="22"/>
|
||||
<text y="6" text-anchor="middle">↓ ${esc(rateText(link.a_to_b_bps))} ↑ ${esc(rateText(link.b_to_a_bps))}</text></g>` : '');
|
||||
@@ -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 ? `<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 = href ? '</a>' : '</g>';
|
||||
const open = node.kind === 'group'
|
||||
? `<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})">
|
||||
${open}<title>${esc(label)}</title>
|
||||
<rect class="tp-node-hit" x="${p.dx - 10}" y="-10" width="${d.w + 20}" height="${d.h + LABEL_H}" rx="12"/>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Topology</h1>
|
||||
<p class="g-page-sub">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.</p>
|
||||
<p class="g-page-sub">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.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp-toolbar no-print" role="toolbar" aria-label="Topology view">
|
||||
<div class="tp-filters" role="group" aria-label="Show">
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="stubs"> Unknown devices</label>
|
||||
<label class="tp-filter">Unknown devices
|
||||
<select class="lt-select tp-select" data-filter="stubs" aria-label="Unknown devices">
|
||||
<option value="grouped">grouped per switch</option>
|
||||
<option value="all">one by one</option>
|
||||
<option value="none">hidden</option>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user