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 += `
${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() {
place… `).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; }