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; }