3D rooms: cables as busbars, switches in the rack elevation
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 43s

Single thin cables were lost at room distance. Per rack and switch the
cables now run as one bus up the back of the rack, thicker the more
cables it carries, with a short stub from each port into it and a comb
under the switch with a tail into each switch port; a bus to a switch in
another rack crosses over the tops as a tray, to one in another room it
rises to the ceiling. Each switch's bus has its own lane behind the
rack. Everything is drawn as a few instanced meshes, picking resolves
the instance (a bus names its cable count, a stub its port).

Switches now show in the side panel's rack elevation at their unit (the
3D room's placement, estimates marked), their body runs deeper into the
rack, and on the selected rack they carry a short name. The panel is
drawn again once the switches are placed, also with a rack selected.
A switch placed by hand mid-rack (place…) gets its bus there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-11-01 14:39:54 +01:00
1 parent dc1ca9f2c6
commit 3c7af23be2
5 files changed
+109 -30

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ publish a tag that has no section here.
cards' ports. Drag to turn, double click to go to a rack; selection is shared with the floor plan.
- Cabling from LLDP: `GET /api/v1/cabling` (each machine's ports and the switch port they lead to),
switches placed from NetBox or by hand (`POST`/`DELETE /api/v1/switches`), cables drawn in the 3D
room from port to switch port, coloured per switch, and listed per rack in the side panel.
room like busbars (one bus per rack and switch up the back, thicker with more cables, a stub per
port, a comb into the switch, trays over the tops), coloured per switch, and listed per rack in
the side panel; switches also show in the rack elevation.
Where LLDP sees nothing, cables recorded in NetBox (through patch panels) or entered by hand
(`POST`/`DELETE /api/v1/cables`); the BMC's port to the out-of-band switch that way, OOB
switches marked; NetBox cables that disagree with LLDP are shown.
+4
View File
@@ -635,6 +635,7 @@ const worstState = ms => ms.map(x => x.state).sort((x, y) => ['bad', 'warn', 'ok
function rackElev(rk) {
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`;
const { ms, H, noU, top, covered, clash } = rackBlocks(rk);
const swAt = typeof switchesAt === 'function' ? switchesAt(rk) : {};
let rows = '';
for (let u = H; u >= 1; u--) {
rows += `<div class="u">${u}</div>`;
@@ -645,6 +646,9 @@ function rackElev(rk) {
const label = b.node ? `${b.ms.length} node${b.ms.length > 1 ? 's' : ''}: ${b.ms.map(nameOf).join(', ')}` : nameOf(m);
const tip = b.ms.map(x => `${nameOf(x)} · ${x.state} · ${(x.manufacturer || '') + ' ' + (x.product || '')}`).join('\n') + (b.known ? `\n${b.h}U${b.node ? ' chassis' : ''} (${(m.size || {}).source})` : '\nheight unknown: set rack_units for this model');
rows += `<div class="slot m ${esc(worst)}${b.node ? ' node' : ''}" data-id="${esc(m.id)}" style="grid-row:span ${span}" title="${esc(tip)}">${esc(label)} <span class="muted">${b.node ? '' : esc(m.os || m.profile || '')}${b.known ? '' : ' ?U'}</span></div>`;
} else if (!covered.has(u) && swAt[u]) { // a switch the cabling puts here (sites.js)
const s = swAt[u];
rows += `<div class="slot sw" style="border-left-color:${esc(s.color || 'var(--accent)')}" title="${esc(s.name + (s.model ? ' · ' + s.model : '') + (s.estimated ? ' · place estimated' : ''))}">⇄ ${esc(s.name)} <span class="muted">${s.role === 'oob' ? 'OOB ' : ''}switch${s.estimated ? ' ?' : ''}</span></div>`;
} else if (!covered.has(u)) rows += '<div class="slot"></div>';
}
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
+92 -29
View File
@@ -68,7 +68,7 @@ async function room3d(site, roomArg, racks) {
if (sig !== R3.sig) {
build(site, room, racks, blocks); R3.sig = sig;
const sw = JSON.stringify(R3.switches); // the side panel lists them: draw it again once they are known
if (sw !== R3.swSig) { R3.swSig = sw; if (!SITE.sel) { render(); return; } }
if (sw !== R3.swSig) { R3.swSig = sw; render(); return; } // the switch list and the rack's elevation show them
}
if (!R3.orb) fit();
size(); draw();
@@ -228,48 +228,108 @@ function placeSwitches(site, room, cab, rackAt, portAt) {
function switchPortNo(name) { const m = /(\d+)\D*$/.exec(name || ''); return m ? Number(m[1]) : 0; }
function drawSwitchIn(sw, ra, keep, mat, colorOf) {
const T = R3.T;
const T = R3.T, css = getComputedStyle(document.body);
const max = Math.max(48, ...((S.cabling || {}).machines || []).flatMap(mp => mp.ports.filter(p => p.switch === sw.name).map(p => switchPortNo(p.switch_port))));
const count = Math.min(128, Math.ceil(max / 2) * 2), d = drawSwitch(count, colorOf), tex = keep(new T.CanvasTexture(d.canvas));
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
const side = mat('swside', () => new T.MeshStandardMaterial({ color: 0x15171a, roughness: 0.7 }));
const top = sw.at.unit > ra.H, y = top ? ra.h + U_M / 2 + 0.005 : PLINTH + (sw.at.unit - 1) * U_M + U_M / 2;
const look = [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 })), side];
const m = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.3)), look);
m.position.set(0, y, TILE + 0.006 - 0.15);
const m = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.45)), look);
m.position.set(0, y, TILE + 0.006 - 0.225);
if (SITE.sel && lc(SITE.sel) === lc(ra.r.name)) { // names on the selected rack only: switches sit a unit apart, labels would pile up
const tag = labelSprite(sw.name.split('.')[0], sw.color, css.getPropertyValue('--panel').trim());
tag.scale.set(0.5, 0.125, 1); tag.position.set((sw.at.unit % 2 ? 1 : -1) * 0.42, y, TILE + 0.1); ra.g.add(tag);
}
const edge = new T.LineSegments(keep(new T.EdgesGeometry(m.geometry)), mat('swl' + sw.color, () => new T.LineBasicMaterial({ color: new T.Color(sw.color) })));
edge.position.copy(m.position);
m.userData = { rack: ra.r.name, tip: `${sw.name}${sw.role === 'oob' ? ' (out-of-band)' : ''}${sw.model ? ' · ' + sw.model : ''} · ${sw.here} cables here · ${sw.at.estimated ? 'place estimated: ' + (sw.source ? 'no unit' : 'not in NetBox') + ', set it in the panel' : 'U' + sw.at.unit + ' (' + sw.source + ')'}` };
ra.g.add(m, edge); R3.pick.push(m);
sw.y = y;
sw.port = name => { const n = switchPortNo(name), p = d.at[n] || d.at['u' + (n % 6)]; return { lx: -RAIL / 2 + RAIL * p.x / MM.W, ly: y + (U_M - 0.004) / 2 - (U_M - 0.004) * p.y / (MM.U - 1) }; };
}
// cables: from each port with an LLDP neighbour out of the back, along the
// rack's side, over the top to the switch's rack when it stands elsewhere,
// into the switch port. One colour per switch; a port without link is grey;
// with a rack selected, the other racks' cables fade.
// cables, drawn like busbars so they read from across the room: per rack
// and switch, one bus up the back of the rack (thicker with more cables),
// a short stub from each port into it, a comb under the switch with a tail
// into each switch port; to a switch in another rack the bus crosses over
// the tops as a tray, to one in another room it rises to the ceiling. One
// colour per switch; a port without link has a grey stub; with a rack
// selected, the other racks' buses fade. All of it is a handful of
// instanced meshes (thousands of stubs would otherwise be thousands of draws).
function cables(cab, rackAt, portAt, keep, mat) {
const T = R3.T, OUT = TILE + 0.07, byName = Object.fromEntries(R3.switches.map(s => [s.name, s]));
const T = R3.T, byName = Object.fromEntries(R3.switches.map(s => [s.name, s]));
const W = (ra, x, y, z) => ra.g.localToWorld(new T.Vector3(x, y, z));
const look = (col, dim) => mat('bus' + col + dim, () => new T.MeshStandardMaterial({ color: new T.Color(col), emissive: new T.Color(col), emissiveIntensity: 0.4, roughness: 0.45, metalness: 0.2, transparent: dim, opacity: dim ? 0.12 : 1 }));
const bins = new Map(), dummy = new T.Object3D();
const bar = (a, b, t, m, data) => { // a square bar from a to b (world), t thick, overrunning the joints
const len = a.distanceTo(b); if (len < 1e-4) return;
dummy.position.copy(a).add(b).multiplyScalar(0.5); dummy.lookAt(b); dummy.scale.set(t, t, len + t); dummy.updateMatrix();
if (!bins.has(m)) bins.set(m, { m, mats: [], data: [] });
bins.get(m).mats.push(dummy.matrix.clone()); bins.get(m).data.push(data);
};
// the cables of each rack to each switch
const groups = new Map();
for (const mp of cab.machines) for (const pt of mp.ports) {
const a = pt.switch && portAt.get(mp.id + '|' + pt.name), sw = byName[pt.switch]; if (!a || !sw) continue;
const lane = 0.012 * (R3.switches.indexOf(sw) % 4); // each switch's cables up their own lane, side by side
const ra = rackAt[a.rack], side = a.lx >= 0 ? RAIL / 2 + 0.03 + lane : -RAIL / 2 - 0.03 - lane, lift = 0.004 * (switchPortNo(pt.name) % 5);
const pts = [W(ra, a.lx, a.ly, TILE + 0.01), W(ra, a.lx, a.ly - 0.01, OUT), W(ra, side, a.ly - 0.01, OUT)];
if (sw.at) {
const rb = rackAt[sw.at.rack], b = sw.port(pt.switch_port), sideB = b.lx >= 0 ? RAIL / 2 + 0.03 + lane : -RAIL / 2 - 0.03 - lane;
if (rb !== ra) { pts.push(W(ra, side, ra.h + 0.08 + lift, OUT), W(rb, sideB, rb.h + 0.08 + lift, OUT)); }
if (rb === ra) pts.push(W(ra, side, b.ly + 0.012, OUT)); // down its own side to the switch, then across
else pts.push(W(rb, sideB, b.ly + 0.012, OUT));
pts.push(W(rb, b.lx, b.ly + 0.012, OUT), W(rb, b.lx, b.ly, TILE + 0.012));
} else pts.push(W(ra, side, ra.h + 0.08 + lift, OUT), W(ra, side * 0.5, 2.9, OUT)); // to a switch in another room: up through the ceiling
const dim = SITE.sel && lc(SITE.sel) !== a.rack && !(sw.at && lc(SITE.sel) === sw.at.rack);
const col = pt.link === false ? '#7a7f86' : sw.color;
const m = new T.Mesh(keep(new T.TubeGeometry(new T.CatmullRomCurve3(pts, false, 'catmullrom', 0.02), pts.length * 12, pt.kind === 'bmc' ? 0.004 : 0.0055, 6, false)),
mat('cab' + col + dim, () => new T.MeshStandardMaterial({ color: new T.Color(col), emissive: new T.Color(col), emissiveIntensity: 0.45, roughness: 0.45, transparent: dim, opacity: dim ? 0.15 : 1 })));
const m0 = S.machines.find(x => x.id === mp.id);
m.userData = { rack: ra.r.name, id: mp.id, tip: `${m0 ? nameOf(m0) : mp.id} ${pt.kind === 'bmc' ? 'BMC' : pt.name}${pt.speed ? ' ' + pt.speed : ''} → ${pt.switch}${sw.role === 'oob' ? ' (OOB)' : ''} ${pt.switch_port}${pt.vlan ? ' · VLAN ' + pt.vlan : ''}${pt.link === false ? ' · no link' : ''}${sw.away ? ' (in ' + sw.away + ')' : ''} · ${pt.source === 'lldp' ? 'seen by LLDP' : pt.source === 'netbox' ? 'NetBox cable' : 'entered by hand'}${pt.netbox_says ? ' · NetBox says ' + pt.netbox_says : ''}` };
R3.scene.add(m); if (!dim) R3.pick.push(m);
const k = a.rack + '|' + sw.name;
if (!groups.has(k)) groups.set(k, { ra: rackAt[a.rack], sw, ports: [] });
groups.get(k).ports.push({ mp, pt, a });
}
// lanes behind each rack: its switches alternate left and right, further back each pair
const lanes = {};
const laneOf = (ra, sw) => {
const k = lc(ra.r.name), names = lanes[k] || (lanes[k] = [...new Set([...groups.values()].filter(g => g.ra === ra).map(g => g.sw.name)
.concat(R3.switches.filter(s => s.at && s.at.rack === k).map(s => s.name)))].sort());
const i = Math.max(0, names.indexOf(sw.name));
return { x: (i % 2 ? 1 : -1) * 0.255, z: TILE + 0.06 + 0.04 * Math.floor(i / 2) };
};
const combs = new Map(); // switch -> the ports reaching it, for its comb
for (const g of groups.values()) {
const { ra, sw, ports } = g, L = laneOf(ra, sw), n = ports.length;
const t = Math.min(0.045, 0.018 + 0.0035 * Math.sqrt(n));
const dim = SITE.sel && lc(SITE.sel) !== lc(ra.r.name) && !(sw.at && lc(SITE.sel) === sw.at.rack);
const busM = look(sw.color, dim), busData = dim ? null : { rack: ra.r.name, tip: `${n} cable${n > 1 ? 's' : ''} from ${ra.r.name} to ${sw.name}${sw.role === 'oob' ? ' (OOB)' : ''}${sw.away ? ' in ' + sw.away : ''}` };
// stubs: out of the back of each port, sideways into the bus
for (const p of ports) {
const m0 = S.machines.find(x => x.id === p.mp.id), pt = p.pt;
const data = dim ? null : { rack: ra.r.name, id: p.mp.id, tip: `${m0 ? nameOf(m0) : p.mp.id} ${pt.kind === 'bmc' ? 'BMC' : pt.name}${pt.speed ? ' ' + pt.speed : ''} → ${pt.switch}${sw.role === 'oob' ? ' (OOB)' : ''} ${pt.switch_port}${pt.vlan ? ' · VLAN ' + pt.vlan : ''}${pt.link === false ? ' · no link' : ''}${sw.away ? ' (in ' + sw.away + ')' : ''} · ${pt.source === 'lldp' ? 'seen by LLDP' : pt.source === 'netbox' ? 'NetBox cable' : 'entered by hand'}${pt.netbox_says ? ' · NetBox says ' + pt.netbox_says : ''}` };
const sm = look(pt.link === false ? '#8a8f96' : sw.color, dim);
bar(W(ra, p.a.lx, p.a.ly, TILE + 0.008), W(ra, p.a.lx, p.a.ly, L.z), 0.007, sm, data);
bar(W(ra, p.a.lx, p.a.ly, L.z), W(ra, L.x, p.a.ly, L.z), 0.007, sm, data);
if (sw.at) { const c = combs.get(sw) || []; c.push({ pt, dim, data }); combs.set(sw, c); }
}
const y0 = Math.min(...ports.map(p => p.a.ly));
const rb = sw.at && rackAt[sw.at.rack];
if (rb === ra) { // up the back to the comb under the switch
bar(W(ra, L.x, y0, L.z), W(ra, L.x, sw.y - U_M / 2 - 0.02, L.z), t, busM, busData);
continue;
}
const yT = Math.max(ra.h, rb ? rb.h : 0) + 0.14 + 0.03 * (R3.switches.indexOf(sw) % 5); // the tray over the tops
bar(W(ra, L.x, y0, L.z), W(ra, L.x, yT, L.z), t, busM, busData);
if (!rb) { bar(W(ra, L.x, yT, L.z), W(ra, L.x, 2.9, L.z), t, busM, busData); continue; } // another room: through the ceiling
const LB = laneOf(rb, sw);
bar(W(ra, L.x, yT, L.z), W(rb, LB.x, yT, LB.z), t, busM, busData);
bar(W(rb, LB.x, yT, LB.z), W(rb, LB.x, sw.y - U_M / 2 - 0.02, LB.z), t, busM, busData);
}
// combs: under each switch from its lane to its farthest port, a tail up into each port
for (const [sw, list] of combs) {
const rb = rackAt[sw.at.rack], L = laneOf(rb, sw), yc = sw.y - U_M / 2 - 0.02;
const xs = list.map(c => sw.port(c.pt.switch_port).lx), far = L.x < 0 ? Math.max(...xs) : Math.min(...xs);
const dim = list.every(c => c.dim), t = Math.min(0.035, 0.014 + 0.003 * Math.sqrt(list.length));
bar(W(rb, L.x, yc, L.z), W(rb, far, yc, L.z), t, look(sw.color, dim), dim ? null : { rack: rb.r.name, tip: `${sw.name}: ${list.length} cables here` });
for (const c of list) {
const p = sw.port(c.pt.switch_port), m = look(c.pt.link === false ? '#8a8f96' : sw.color, c.dim);
bar(W(rb, p.lx, yc, L.z), W(rb, p.lx, p.ly, L.z), 0.006, m, c.data);
bar(W(rb, p.lx, p.ly, L.z), W(rb, p.lx, p.ly, TILE + 0.012), 0.006, m, c.data);
}
}
const geo = keep(new T.BoxGeometry(1, 1, 1));
for (const b of bins.values()) {
const im = new T.InstancedMesh(geo, b.m, b.mats.length);
b.mats.forEach((mx, i) => im.setMatrixAt(i, mx));
im.userData = { items: b.data }; R3.scene.add(im);
if (b.data.some(Boolean)) R3.pick.push(im);
}
}
@@ -319,8 +379,10 @@ function draw() {
function hitAt(ev) {
const rc = R3.gl.domElement.getBoundingClientRect();
R3.ray.setFromCamera(new R3.T.Vector2((ev.clientX - rc.left) / rc.width * 2 - 1, -(ev.clientY - rc.top) / rc.height * 2 + 1), R3.camera);
const h = R3.ray.intersectObjects(R3.pick, false)[0];
return h ? { ...h.object.userData, point: h.point, obj: h.object } : null;
const h = R3.ray.intersectObjects(R3.pick, false).find(x => !x.object.isInstancedMesh || x.object.userData.items[x.instanceId]);
if (!h) return null;
const d = h.object.isInstancedMesh ? h.object.userData.items[h.instanceId] : h.object.userData; // a bus or stub: what it carries
return { ...d, point: h.point, obj: h.object };
}
// controls: drag turns, right-drag or shift-drag moves, wheel or pinch zooms;
@@ -378,7 +440,8 @@ function controls(cv) {
cv.addEventListener('dblclick', ev => {
clearTimeout(R3.clickT);
const h = hitAt(ev), o = R3.orb; if (!h || !o) return;
const p = new R3.T.Vector3(); h.obj.parent.getWorldPosition(p);
const p = new R3.T.Vector3(); (h.obj.parent === R3.scene ? h.obj : h.obj.parent).getWorldPosition(p);
if (h.obj.isInstancedMesh) p.copy(h.point); // a cable: close in where it was clicked
o.t.set(p.x, 1, p.z); o.r = 3.2; draw();
});
}
+9
View File
@@ -340,6 +340,15 @@ function switchTable() {
<button class="btn sm" data-swplace="${esc(s.name)}">place…</button></div>`).join('')}</div>
<p class="muted">A switch's place comes from NetBox (its rack and position) or is set here; without either it is drawn where most of its cables are.</p>`;
}
// switchesAt: the switches in a rack by unit, as the 3D room placed them
// (estimates included) or as stored, for the rack's elevation
function switchesAt(rk) {
const out = {}, k = lc(rk.name);
const placed = (typeof R3 !== 'undefined' && R3.switches && R3.switches.length) ? R3.switches.filter(s => s.at && s.at.rack === k && s.at.unit <= (rk.height || 42)).map(s => ({ ...s, unit: s.at.unit, estimated: s.at.estimated }))
: ((S.cabling || {}).switches || []).filter(s => s.unit && lc(s.rack) === k && lc(s.room || '') === lc(rk.room || '')).map(s => ({ ...s }));
for (const s of placed) out[s.unit] = s;
return out;
}
const rackLabel = k => ((S.racks.find(r => lc(r.name) === k) || {}).name || k);
async function placeSwitch(site, room, name) {
+1
View File
@@ -243,3 +243,4 @@ svg.floor .rk text { font-size: 9px; fill: var(--fg); pointer-events: none; font
.page-head .right .nb-link { margin-right: 6px; }
.cab-m { margin-top: 8px; border-top: 1px solid var(--line); padding-top: 6px; }
.cab-p { display: grid; grid-template-columns: 58px minmax(0, 1fr) 14px; gap: 6px; font-size: 12px; padding: 1px 0; } .cab-p > span { overflow-wrap: anywhere; }
.elev .slot.sw { background: var(--hover); border-left: 3px solid var(--accent); padding: 0 6px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }