@@ -32411,6 +32411,174 @@ function buildServerRack(node, color) {
3241132411 return group;
3241232412}
3241332413
32414+ // ─── WolfRouter Network Rack (3D) ───
32415+ //
32416+ // A dedicated rack per cluster that visualises the network topology:
32417+ // physical ports, bridges, VLANs — one "switch unit" per cluster node.
32418+ // Looks like a real network patch-panel rack rather than a server rack.
32419+ // Data comes from the WolfRouter topology cache (`_topoNetworkCache`).
32420+
32421+ let _topoNetworkCache = null;
32422+ let _topoNetworkCacheAge = 0;
32423+
32424+ async function topoFetchNetworkTopology() {
32425+ if (_topoNetworkCache && Date.now() - _topoNetworkCacheAge < 30000) return _topoNetworkCache;
32426+ try {
32427+ const r = await fetch('/api/router/topology');
32428+ if (r.ok) {
32429+ _topoNetworkCache = await r.json();
32430+ _topoNetworkCacheAge = Date.now();
32431+ }
32432+ } catch (e) {}
32433+ return _topoNetworkCache;
32434+ }
32435+
32436+ function buildNetworkRack(clusterName, clusterNodes, color, topoData) {
32437+ const group = new THREE.Group();
32438+ const rackH = 5.5, rackW = 1.2, rackD = 2.0;
32439+ const unitH = 0.35, unitGap = 0.05;
32440+ const frontZ = -rackD / 2;
32441+
32442+ // Rack frame (same geometry as server racks for visual consistency)
32443+ const frameMat = new THREE.MeshPhongMaterial({ color: 0x1a1a20, shininess: 40, specular: 0x333344 });
32444+ const postGeo = new THREE.BoxGeometry(0.08, rackH, 0.08);
32445+ [[-rackW/2, rackH/2, frontZ], [rackW/2, rackH/2, frontZ],
32446+ [-rackW/2, rackH/2, rackD/2], [rackW/2, rackH/2, rackD/2]].forEach(p => {
32447+ const post = new THREE.Mesh(postGeo, frameMat);
32448+ post.position.set(...p); post.castShadow = true; group.add(post);
32449+ });
32450+ const railH = new THREE.BoxGeometry(rackW + 0.08, 0.06, 0.08);
32451+ const railD = new THREE.BoxGeometry(0.08, 0.06, rackD + 0.08);
32452+ [0, rackH].forEach(y => {
32453+ [frontZ, rackD/2].forEach(z => { const r = new THREE.Mesh(railH, frameMat); r.position.set(0, y, z); group.add(r); });
32454+ [-rackW/2, rackW/2].forEach(x => { const r = new THREE.Mesh(railD, frameMat); r.position.set(x, y, 0); group.add(r); });
32455+ });
32456+
32457+ // Top unit: WolfRouter banner with cluster accent
32458+ const bannerGeo = new THREE.BoxGeometry(rackW - 0.16, unitH + 0.06, rackD - 0.2);
32459+ const bannerMat = new THREE.MeshPhongMaterial({ color: 0x1e293b, shininess: 60, specular: 0x445566 });
32460+ const banner = new THREE.Mesh(bannerGeo, bannerMat);
32461+ const bannerY = 0.3;
32462+ banner.position.set(0, bannerY + unitH / 2, 0);
32463+ banner.castShadow = true;
32464+ group.add(banner);
32465+ // Accent stripe
32466+ const accentGeo = new THREE.BoxGeometry(rackW - 0.22, 0.035, 0.025);
32467+ const accentMat = new THREE.MeshBasicMaterial({ color });
32468+ const accent = new THREE.Mesh(accentGeo, accentMat);
32469+ accent.position.set(0, bannerY + unitH + 0.02, frontZ + 0.12);
32470+ group.add(accent);
32471+ // WolfRouter label
32472+ const wrLabel = makeTextSprite('WOLFROUTER', { fontSize: 20, color: '#' + color.toString(16).padStart(6, '0'), scale: 0.2 });
32473+ wrLabel.position.set(0, bannerY + unitH / 2, frontZ + 0.16);
32474+ wrLabel.userData = { isLabel: true };
32475+ group.add(wrLabel);
32476+
32477+ // Resolve topology nodes for this cluster. The topology API returns
32478+ // nodes keyed by node_id/node_name; we match by the cluster nodes'
32479+ // id or hostname.
32480+ const topoNodes = (topoData?.nodes || []).filter(tn => {
32481+ return clusterNodes.some(cn =>
32482+ cn.id === tn.node_id || cn.hostname === tn.node_name ||
32483+ cn.id === tn.node_name || cn.hostname === tn.node_id
32484+ );
32485+ });
32486+
32487+ // One "switch unit" per cluster node, each showing RJ45-port LEDs.
32488+ let slotY = bannerY + unitH + unitGap + 0.12;
32489+ const maxSlots = Math.floor((rackH - slotY - 0.2) / (unitH * 2 + unitGap));
32490+ const nodesToShow = topoNodes.slice(0, Math.max(maxSlots, 2));
32491+
32492+ nodesToShow.forEach((tn) => {
32493+ const ifaces = tn.interfaces || [];
32494+ const bridges = tn.bridges || [];
32495+
32496+ // Switch unit body (taller than a server unit — two rows)
32497+ const swH = unitH * 2;
32498+ const swGeo = new THREE.BoxGeometry(rackW - 0.16, swH, rackD - 0.2);
32499+ const swMat = new THREE.MeshPhongMaterial({ color: 0x0f172a, shininess: 30, specular: 0x334455 });
32500+ const sw = new THREE.Mesh(swGeo, swMat);
32501+ sw.position.set(0, slotY + swH / 2, 0);
32502+ sw.castShadow = true;
32503+ group.add(sw);
32504+
32505+ // Front panel
32506+ const panelW = rackW - 0.2;
32507+ const panelGeo = new THREE.BoxGeometry(panelW, swH - 0.04, 0.015);
32508+ const panelMat = new THREE.MeshPhongMaterial({ color: 0x1e293b, shininess: 20 });
32509+ const panel = new THREE.Mesh(panelGeo, panelMat);
32510+ panel.position.set(0, slotY + swH / 2, frontZ + 0.12);
32511+ group.add(panel);
32512+
32513+ // Node name label (small, left-aligned)
32514+ const nodeName = tn.node_name || tn.node_id || '?';
32515+ const nnLabel = makeTextSprite(nodeName.slice(0, 12), { fontSize: 14, color: '#94a3b8', scale: 0.12 });
32516+ nnLabel.position.set(-panelW / 2 + 0.2, slotY + swH - 0.06, frontZ + 0.16);
32517+ nnLabel.userData = { isLabel: true };
32518+ group.add(nnLabel);
32519+
32520+ // RJ45 port LEDs — small coloured dots in a row on the front.
32521+ // Green = link up, red = link down, blue = bridge.
32522+ const portStartX = -panelW / 2 + 0.08;
32523+ const portSpacing = 0.065;
32524+ const portZ = frontZ + 0.14;
32525+ const ledR = 0.022;
32526+ const portLedGeo = new THREE.SphereGeometry(ledR, 8, 8);
32527+ const portRowY = slotY + swH / 2 - 0.08;
32528+
32529+ // Physical ports — top row
32530+ ifaces.forEach((iface, idx) => {
32531+ if (idx > 14) return; // max 15 per row
32532+ const px = portStartX + idx * portSpacing;
32533+ const portColor = iface.link_up ? 0x22c55e : 0xef4444;
32534+ const led = new THREE.Mesh(portLedGeo, new THREE.MeshBasicMaterial({ color: portColor }));
32535+ led.position.set(px, portRowY, portZ);
32536+ group.add(led);
32537+ // Tiny glow for up ports
32538+ if (iface.link_up) {
32539+ const glow = new THREE.PointLight(portColor, 0.08, 0.5);
32540+ glow.position.set(px, portRowY, portZ + 0.05);
32541+ group.add(glow);
32542+ }
32543+ });
32544+
32545+ // Bridges — bottom row (cyan)
32546+ const brRowY = portRowY - portSpacing * 1.5;
32547+ bridges.forEach((br, idx) => {
32548+ if (idx > 14) return;
32549+ const px = portStartX + idx * portSpacing;
32550+ const led = new THREE.Mesh(portLedGeo, new THREE.MeshBasicMaterial({ color: 0x06b6d4 }));
32551+ led.position.set(px, brRowY, portZ);
32552+ group.add(led);
32553+ });
32554+
32555+ slotY += swH + unitGap + 0.04;
32556+ });
32557+
32558+ // If we had more topo nodes than slots, overflow indicator
32559+ if (topoNodes.length > nodesToShow.length) {
32560+ const extra = topoNodes.length - nodesToShow.length;
32561+ const overLabel = makeTextSprite(`+${extra} more`, { fontSize: 14, color: '#64748b', scale: 0.14 });
32562+ overLabel.position.set(0, slotY + 0.1, frontZ + 0.15);
32563+ overLabel.userData = { isLabel: true };
32564+ group.add(overLabel);
32565+ }
32566+
32567+ // Side accent strips (same as server racks)
32568+ const stripGeo = new THREE.BoxGeometry(0.02, rackH - 0.5, 0.02);
32569+ const stripMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.8 });
32570+ const stripL = new THREE.Mesh(stripGeo, stripMat); stripL.position.set(-rackW/2 - 0.02, rackH/2, frontZ); group.add(stripL);
32571+ const stripR = new THREE.Mesh(stripGeo, stripMat.clone()); stripR.position.set(rackW/2 + 0.02, rackH/2, frontZ); group.add(stripR);
32572+
32573+ // Subtle cyan glow — network equipment vibe
32574+ const glow = new THREE.PointLight(0x06b6d4, 0.25, 5);
32575+ glow.position.set(0, 2.5, frontZ - 0.5);
32576+ group.add(glow);
32577+
32578+ group.userData = { isRack: true, isNetworkRack: true, clusterName };
32579+ return group;
32580+ }
32581+
3241432582function buildTopologyScene() {
3241532583 if (!_topo) return;
3241632584 const { scene } = _topo;
@@ -32423,6 +32591,9 @@ function buildTopologyScene() {
3242332591 _topo.extraMeshes = [];
3242432592 _topo.connectionLines = [];
3242532593 _topo.labelSprites = [];
32594+ // Allow the network rack fetch to trigger one more rebuild if data
32595+ // arrived between clears.
32596+ if (_topoNetworkCache) _topo._networkBuilt = true;
3242632597
3242732598 if (nodes.length === 0) return;
3242832599
@@ -32437,9 +32608,10 @@ function buildTopologyScene() {
3243732608 const cNames = Object.keys(clusters);
3243832609 const cColors = [0xdc2626, 0x3b82f6, 0x10b981, 0xf59e0b, 0x8b5cf6, 0xec4899, 0x06b6d4, 0xf97316];
3243932610 const rackSpacing = 2.0;
32440- const clusterGap = 4 .0;
32611+ const clusterGap = 5 .0;
3244132612
32442- // Lay out racks in rows per cluster
32613+ // Lay out racks in rows per cluster. Each cluster gets its server
32614+ // racks then one extra WolfRouter network rack at the end.
3244332615 let offsetX = 0;
3244432616 cNames.forEach((cName, ci) => {
3244532617 const color = cColors[ci % cColors.length];
@@ -32453,21 +32625,18 @@ function buildTopologyScene() {
3245332625 scene.add(rack);
3245432626 _topo.nodeMeshes.push(rack);
3245532627
32456- // Critical warning ! above rack if any metric is critical
3245732628 if (rack.userData._hasCritical) {
3245832629 const warn = makeTextSprite('!', { fontSize: 48, color: '#ef4444', scale: 0.6 });
3245932630 warn.position.set(x, 6.8, 0);
3246032631 warn.userData = { isLabel: true, isWarning: true };
3246132632 scene.add(warn);
3246232633 _topo.labelSprites.push(warn);
32463- // Red glow above rack
3246432634 const warnLight = new THREE.PointLight(0xef4444, 0.5, 8);
3246532635 warnLight.position.set(x, 6.5, -0.5);
3246632636 scene.add(warnLight);
3246732637 _topo.extraMeshes.push(warnLight);
3246832638 }
3246932639
32470- // Server name on the side of the rack — rotated 90 degrees, small white text
3247132640 const nameLabel = makeTextSprite(node.hostname || node.id, {
3247232641 fontSize: 24,
3247332642 color: '#ccccdd',
@@ -32480,8 +32649,26 @@ function buildTopologyScene() {
3248032649 _topo.labelSprites.push(nameLabel);
3248132650 });
3248232651
32483- // Cluster name label above racks
32484- const midX = startX + (clusterNodes.length - 1) * rackSpacing / 2;
32652+ // WolfRouter network rack — placed after the last server rack
32653+ // in this cluster row, separated by one rack spacing.
32654+ const netX = offsetX + clusterNodes.length * rackSpacing;
32655+ if (_topoNetworkCache) {
32656+ const netRack = buildNetworkRack(cName, clusterNodes, color, _topoNetworkCache);
32657+ netRack.position.set(netX, 0, 0);
32658+ scene.add(netRack);
32659+ _topo.extraMeshes.push(netRack);
32660+ // Side label
32661+ const netLabel = makeTextSprite('Network', { fontSize: 20, color: '#06b6d4', scale: 0.4 });
32662+ netLabel.position.set(netX + 0.82, 2.8, 0);
32663+ netLabel.material.rotation = -Math.PI / 2;
32664+ netLabel.userData = { isLabel: true, isSideLabel: true };
32665+ scene.add(netLabel);
32666+ _topo.labelSprites.push(netLabel);
32667+ }
32668+ const totalRacksInCluster = clusterNodes.length + (_topoNetworkCache ? 1 : 0);
32669+
32670+ // Cluster name label above racks (centred over all racks incl network)
32671+ const midX = startX + (totalRacksInCluster - 1) * rackSpacing / 2;
3248532672 const clabel = makeTextSprite(cName, {
3248632673 fontSize: 32,
3248732674 color: '#' + color.toString(16).padStart(6, '0'),
@@ -32492,7 +32679,7 @@ function buildTopologyScene() {
3249232679 scene.add(clabel);
3249332680 _topo.labelSprites.push(clabel);
3249432681
32495- offsetX += clusterNodes.length * rackSpacing + clusterGap;
32682+ offsetX += totalRacksInCluster * rackSpacing + clusterGap;
3249632683 });
3249732684
3249832685 // Centre the scene
@@ -32536,11 +32723,25 @@ function buildTopologyScene() {
3253632723 Promise.all(uncached.map(n => topoFetchContainers(n.id))).then(() => {
3253732724 _topo._fetchingNames = false;
3253832725 if (_topo && !_topo.disposed && currentPage === 'topology') {
32539- buildTopologyScene(); // rebuild with real names + stats for LEDs
32726+ buildTopologyScene();
3254032727 }
3254132728 }).catch(() => { _topo._fetchingNames = false; });
3254232729 }
3254332730 }
32731+ // Lazy-fetch WolfRouter network topology for the per-cluster network racks.
32732+ // A single fetch covers all clusters (the API returns all nodes).
32733+ if (!_topo._fetchingNetwork) {
32734+ _topo._fetchingNetwork = true;
32735+ topoFetchNetworkTopology().then(data => {
32736+ _topo._fetchingNetwork = false;
32737+ if (data && !_topo._networkBuilt) {
32738+ _topo._networkBuilt = true;
32739+ if (_topo && !_topo.disposed && currentPage === 'topology') {
32740+ buildTopologyScene();
32741+ }
32742+ }
32743+ }).catch(() => { _topo._fetchingNetwork = false; });
32744+ }
3254432745}
3254532746
3254632747// ─── Render loop (XR-compatible) ───
0 commit comments