Skip to content

Commit e7596af

Browse files
author
Paul C
committed
v17.4.0: 3D/VR WolfRouter network rack per cluster + policy map UX
3D datacenter: - Each cluster now gets a dedicated WolfRouter network rack at the end of its server row, showing physical port LEDs (green=up, red=down), bridge LEDs (cyan), and per-node switch units with hostname labels. - Network topology lazy-fetched from /api/router/topology (30s cache). - Cluster gap widened 4→5 units; cluster label centred over all racks. - Subtle cyan glow on network racks for that switch-room look. Policy map: - Pan-drag on empty canvas scrolls the map; node-drag still creates rules. - Help button opens a modal listing all interactions (replaces inline text). - Fullscreen button puts the policy tab into browser fullscreen via API. - Legend cleaned up: inline help text removed, 'implicit' swatch added. App store: - DockerTarget.seed_files: minimal config seeded into named volumes so crash-looping apps (Frigate, Prometheus, Traefik, Mosquitto, Zigbee2MQTT, Authelia) start cleanly on first install. Settings: - System Check tab: 26-point dependency audit (Docker deep-check via docker info, kernel version, QEMU, LXC, networking, storage, USB modules). AI Ask button for tailored remediation when AI is enabled.
1 parent e4e59f1 commit e7596af

2 files changed

Lines changed: 211 additions & 10 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "17.3.5"
3+
version = "17.4.0"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

web/js/app.js

Lines changed: 210 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
3241432582
function 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

Comments
 (0)