From 3c7af23be2ad1c53884aa44f0f784b9e294342da Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 1 Nov 2026 14:39:54 +0100 Subject: [PATCH] 3D rooms: cables as busbars, switches in the rack elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 +- cmd/rollcall/web/app.js | 4 ++ cmd/rollcall/web/room3d.js | 121 ++++++++++++++++++++++++++++--------- cmd/rollcall/web/sites.js | 9 +++ cmd/rollcall/web/style.css | 1 + 5 files changed, 109 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34691f..be10bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index f329c07..8a28792 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -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 += `
${u}
`; @@ -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 += `
${esc(label)} ${b.node ? '' : esc(m.os || m.profile || '')}${b.known ? '' : ' ?U'}
`; + } else if (!covered.has(u) && swAt[u]) { // a switch the cabling puts here (sites.js) + const s = swAt[u]; + rows += `
⇄ ${esc(s.name)} ${s.role === 'oob' ? 'OOB ' : ''}switch${s.estimated ? ' ?' : ''}
`; } else if (!covered.has(u)) rows += '
'; } return `

${esc(rk.name)} ${esc([rk.building, rk.room].filter(Boolean).join(' / '))}${ms.length} · ${H}U ✎

diff --git a/cmd/rollcall/web/room3d.js b/cmd/rollcall/web/room3d.js index da75f53..5d94f44 100644 --- a/cmd/rollcall/web/room3d.js +++ b/cmd/rollcall/web/room3d.js @@ -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(); }); } diff --git a/cmd/rollcall/web/sites.js b/cmd/rollcall/web/sites.js index 4c5f23b..9d5dad0 100644 --- a/cmd/rollcall/web/sites.js +++ b/cmd/rollcall/web/sites.js @@ -340,6 +340,15 @@ function switchTable() {
`).join('')}

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.

`; } +// 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) { diff --git a/cmd/rollcall/web/style.css b/cmd/rollcall/web/style.css index 6e2d8c2..7a260f3 100644 --- a/cmd/rollcall/web/style.css +++ b/cmd/rollcall/web/style.css @@ -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; }