Files
ome-cockpit/ui/app.js
T
root 64208eb344 Replace Bloom with Constellation and fix viz picker grid.
Keep all layout cards in a strict 2x2 grid; constellation uses star-chart packing with neighbor edges and leader pipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:12:01 +02:00

3033 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
const $ = (sel) => document.querySelector(sel);
const canvas = $("#topo");
const ctx = canvas.getContext("2d");
const state = {
data: null,
viewMode: "status",
layoutMode: "orbit",
clusterHubs: [],
laneGuides: [],
labelAnchors: [],
bloomPetals: [],
constellationCharts: [],
subnet: "all",
model: null,
groupHint: null,
kpiFocus: null,
selectedId: null,
inventoryCache: {},
inventoryLoadingId: null,
notifiedEventKeys: new Set(),
lastPulse: 0,
filters: {
connected: false,
powered: false,
servers: true,
idrac: false,
hasPower: false,
ghostLinks: false,
pulseLinks: true,
pulseSpeed: 0.45,
pulseLineColor: "#3dffe0",
pulseGlowColor: "#00a8e8",
pulsePacketColor: "#ffffff",
minWatts: 0,
search: "",
},
cam: { x: 0, y: 0, scale: 1 },
nodes: [],
hub: null,
dragging: false,
last: null,
hoverId: null,
connectNode: null,
pulseT: 0,
modelColors: new Map(),
subnetColors: new Map(),
};
const PALETTE = [
"#00a8e8", "#3dffe0", "#ffb020", "#7ec8ff", "#3dffa0",
"#ff7a59", "#c4a7ff", "#f0e68c", "#5eead4", "#f472b6",
];
function colorFor(map, key) {
if (!map.has(key)) map.set(key, PALETTE[map.size % PALETTE.length]);
return map.get(key);
}
function statusColor(status) {
const s = String(status || "");
if (s === "1000" || s === "0") return "#3dffa0";
if (s === "2000" || s === "2") return "#ffb020";
if (s === "3000" || s === "3" || s === "4000") return "#ff5c5c";
return "#7a93a8";
}
function powerColor(watts) {
if (watts == null) return "#3a4a5a";
if (watts < 200) return "#3dffa0";
if (watts < 450) return "#00a8e8";
if (watts < 650) return "#ffb020";
return "#ff5c5c";
}
function nodeColor(n) {
switch (state.viewMode) {
case "power":
return powerColor(n.watts);
case "connection":
return n.connected ? "#3dffe0" : "#ff5c5c";
case "model":
return colorFor(state.modelColors, n.model || "?");
case "subnet":
return colorFor(state.subnetColors, n.subnet || "?");
default:
if (!n.connected) return "#5a6a7a";
return statusColor(n.status);
}
}
function visibleDevices() {
const d = state.data;
if (!d) return [];
const f = state.filters;
const q = (f.search || "").trim().toLowerCase();
// Type filters are INCLUDE toggles:
// - Servers on => include servers
// - iDRACs on => include iDRACs
// - both off => show nothing (do not silently show all)
const includeServers = !!f.servers;
const includeIdrac = !!f.idrac;
if (!includeServers && !includeIdrac) return [];
return (d.devices || []).filter((n) => {
if (state.subnet !== "all") {
const cidrs = n.map_cidrs && n.map_cidrs.length ? n.map_cidrs : [n.subnet];
if (!cidrs.includes(state.subnet)) return false;
}
if (state.model && n.model !== state.model) return false;
const isServer = !!n.is_server;
const isIdrac = !!n.is_idrac;
const typeOk =
(includeServers && isServer) ||
(includeIdrac && isIdrac);
if (!typeOk) return false;
if (f.connected && !n.connected) return false;
if (f.powered && !n.powered_on) return false;
if (f.hasPower && n.watts == null) return false;
if (f.minWatts > 0 && (n.watts == null || n.watts < f.minWatts)) return false;
if (state.kpiFocus === "offline" && (n.connected || !n.is_server)) return false;
if (state.kpiFocus === "connected" && !n.connected) return false;
if (state.kpiFocus === "powered" && !n.powered_on) return false;
if (state.kpiFocus === "idracs" && !n.is_idrac) return false;
if (state.kpiFocus === "power" && n.watts == null) return false;
if (q) {
const hay = [n.name, n.model, n.service_tag, n.ip, n.subnet]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function groupBySubnet(devices) {
const bySubnet = new Map();
for (const n of devices) {
const k = n.subnet || "unknown";
if (!bySubnet.has(k)) bySubnet.set(k, []);
bySubnet.get(k).push(n);
}
return [...bySubnet.keys()].sort().map((k) => ({ cidr: k, members: bySubnet.get(k) }));
}
function subnetLabelInfo(cidr) {
const meta = (state.data?.subnets || []).find((s) => s.cidr === cidr) || {};
const vlanBit = meta.vlan_id != null ? `VLAN ${meta.vlan_id}` : null;
return {
cidr,
label: vlanBit ? `${vlanBit} · ${cidr}` : cidr,
color: meta.vlan_color || colorFor(state.subnetColors, cidr || "?"),
};
}
function makeLabelAnchor({ x, y, cidr, localR = 28, dirX = null, dirY = null, pipeExtra = 36 }) {
const info = subnetLabelInfo(cidr);
return {
x,
y,
cidr: info.cidr,
label: info.label,
color: info.color,
localR,
pipeExtra,
dirX,
dirY,
};
}
function anchorsFromPoints(cidr, points, omeHub, opts = {}) {
if (!points.length) return null;
let sx = 0;
let sy = 0;
for (const p of points) {
sx += p.x;
sy += p.y;
}
const ax = sx / points.length;
const ay = sy / points.length;
let localR = 16;
for (const p of points) {
localR = Math.max(localR, Math.hypot(p.x - ax, p.y - ay));
}
let dirX = opts.dirX;
let dirY = opts.dirY;
if (dirX == null || dirY == null) {
if (omeHub) {
dirX = ax - omeHub.x;
dirY = ay - omeHub.y;
} else {
dirX = 0;
dirY = -1;
}
}
return makeLabelAnchor({
x: ax,
y: ay,
cidr,
localR: localR + (opts.pad || 8),
dirX,
dirY,
pipeExtra: opts.pipeExtra || 40,
});
}
function nodeRadius(n) {
return n.connected ? 8 : 5.5;
}
function setTargets(nodes) {
// preserve current x/y for lerp; first time seed from target
const prev = new Map(state.nodes.map((n) => [n.id, n]));
for (const n of nodes) {
const p = prev.get(n.id);
if (p && Number.isFinite(p.x)) {
n.x = p.x;
n.y = p.y;
} else {
n.x = n.tx;
n.y = n.ty;
}
}
state.nodes = nodes;
}
function layoutOrbit(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const ringBase = Math.min(w, h) * 0.18;
const nodes = [];
groups.forEach((g, si) => {
const ring = ringBase + si * Math.min(70, Math.max(42, 360 / Math.max(groups.length, 1)));
g.members.forEach((n, i) => {
const a = (i / Math.max(g.members.length, 1)) * Math.PI * 2 - Math.PI / 2 + si * 0.15;
const jitter = (n.id % 7) * 2.2;
nodes.push({
...n,
tx: cx + Math.cos(a) * (ring + jitter),
ty: cy + Math.sin(a) * (ring + jitter),
r: nodeRadius(n),
depth: 1,
});
});
});
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = [];
state.bloomPetals = [];
state.constellationCharts = [];
setTargets(nodes);
}
function layoutGalaxy(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const arms = Math.max(groups.length, 1);
const maxR = Math.min(w, h) * 0.42;
groups.forEach((g, si) => {
const armAngle = (si / arms) * Math.PI * 2;
g.members.forEach((n, i) => {
const t = (i + 1) / (g.members.length + 1);
const r = 70 + t * maxR;
const twist = t * 3.2 + armAngle;
const wobble = Math.sin(i * 1.7 + si) * 12;
const x = cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35;
const y = cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35;
nodes.push({
...n,
tx: x,
ty: y,
r: nodeRadius(n),
depth: 0.6 + t * 0.8,
arm: si,
});
});
});
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = [];
state.bloomPetals = [];
state.constellationCharts = [];
setTargets(nodes);
}
function layoutClusters(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const hubs = [];
const anchors = [];
const R = Math.min(w, h) * 0.32;
const omeHub = { x: cx, y: cy };
groups.forEach((g, si) => {
const a = (si / Math.max(groups.length, 1)) * Math.PI * 2 - Math.PI / 2;
const hx = cx + Math.cos(a) * R;
const hy = cy + Math.sin(a) * R;
const localR = 28 + Math.min(90, g.members.length * 4.5);
const info = subnetLabelInfo(g.cidr);
hubs.push({
x: hx,
y: hy,
label: info.label,
cidr: g.cidr,
color: info.color,
localR,
angle: a,
memberCount: g.members.length,
});
anchors.push(
makeLabelAnchor({
x: hx,
y: hy,
cidr: g.cidr,
localR,
dirX: hx - omeHub.x,
dirY: hy - omeHub.y,
pipeExtra: 36,
})
);
g.members.forEach((n, i) => {
const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2;
nodes.push({
...n,
tx: hx + Math.cos(la) * localR,
ty: hy + Math.sin(la) * localR,
r: nodeRadius(n),
depth: 1,
cluster: si,
});
});
});
state.clusterHubs = hubs;
state.laneGuides = [];
state.labelAnchors = anchors;
state.bloomPetals = [];
state.constellationCharts = [];
setTargets(nodes);
}
function layoutConstellation(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const anchors = [];
const charts = [];
const orbitR = Math.min(w, h) * 0.3;
const omeHub = { x: cx, y: cy };
const nGroups = Math.max(groups.length, 1);
const golden = Math.PI * (3 - Math.sqrt(5));
groups.forEach((g, si) => {
const a = (si / nGroups) * Math.PI * 2 - Math.PI / 2;
const hx = cx + Math.cos(a) * orbitR;
const hy = cy + Math.sin(a) * orbitR;
const info = subnetLabelInfo(g.cidr);
const localR = 22 + Math.min(72, Math.sqrt(Math.max(g.members.length, 1)) * 15);
const pts = [];
g.members.forEach((n, i) => {
const r = localR * Math.sqrt((i + 0.55) / Math.max(g.members.length, 1));
const ang = i * golden + si * 0.35;
const jx = Math.sin((n.id || i) * 0.17) * 2.8;
const jy = Math.cos((n.id || i) * 0.13) * 2.8;
const x = hx + Math.cos(ang) * r + jx;
const y = hy + Math.sin(ang) * r + jy;
pts.push({ x, y, id: n.id });
nodes.push({
...n,
tx: x,
ty: y,
r: nodeRadius(n),
depth: 1,
constellation: si,
});
});
// Constellation lines: each star to its 2 nearest neighbors
const edgeSet = new Set();
const edges = [];
for (let i = 0; i < pts.length; i++) {
const ranked = pts
.map((p, j) => ({
j,
d: i === j ? Infinity : (p.x - pts[i].x) ** 2 + (p.y - pts[i].y) ** 2,
}))
.sort((aa, bb) => aa.d - bb.d);
for (const nb of ranked.slice(0, Math.min(2, ranked.length))) {
if (!Number.isFinite(nb.d)) continue;
const lo = Math.min(i, nb.j);
const hi = Math.max(i, nb.j);
const key = lo + ":" + hi;
if (edgeSet.has(key)) continue;
edgeSet.add(key);
edges.push({
x1: pts[lo].x,
y1: pts[lo].y,
x2: pts[hi].x,
y2: pts[hi].y,
});
}
}
charts.push({
x: hx,
y: hy,
localR,
color: info.color,
cidr: g.cidr,
label: info.label,
edges,
count: g.members.length,
});
anchors.push(
makeLabelAnchor({
x: hx,
y: hy,
cidr: g.cidr,
localR,
dirX: hx - omeHub.x,
dirY: hy - omeHub.y,
pipeExtra: 34,
})
);
});
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = anchors;
state.bloomPetals = [];
state.constellationCharts = charts;
setTargets(nodes);
}
function layoutLanes(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const guides = [];
const anchors = [];
const top = 70;
const bottom = h - 50;
const span = Math.max(bottom - top, 120);
groups.forEach((g, si) => {
const y = top + (groups.length <= 1 ? span / 2 : (si / (groups.length - 1 || 1)) * span);
const info = subnetLabelInfo(g.cidr);
guides.push({ y, label: info.label, color: info.color, cidr: g.cidr });
// Pipe goes up from the left end of the lane — clear of the horizontal edge
anchors.push(
makeLabelAnchor({
x: 96,
y,
cidr: g.cidr,
localR: 6,
dirX: 0,
dirY: -1,
pipeExtra: 22,
})
);
g.members.forEach((n, i) => {
const t = g.members.length <= 1 ? 0.5 : i / (g.members.length - 1);
const x = 90 + t * (w - 160);
const bob = Math.sin(i * 0.9 + si) * 10;
nodes.push({
...n,
tx: x,
ty: y + bob,
r: nodeRadius(n),
depth: 1,
lane: si,
});
});
});
state.clusterHubs = [];
state.laneGuides = guides;
state.labelAnchors = anchors;
state.bloomPetals = [];
state.constellationCharts = [];
// hub left side
state.hub = { x: 48, y: cy, r: 26 };
setTargets(nodes);
}
function layoutHelix(devices, cx, cy, w, h) {
const list = [...devices].sort((a, b) => Number(b.connected) - Number(a.connected) || (b.watts || 0) - (a.watts || 0));
const nodes = [];
const turns = 2.4;
const byCidr = new Map();
list.forEach((n, i) => {
const t = list.length <= 1 ? 0.5 : i / (list.length - 1);
const angle = t * Math.PI * 2 * turns;
const y = 60 + t * (h - 120);
const amp = Math.min(w, h) * 0.28;
const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(angle));
const x = cx + Math.cos(angle) * amp * depth;
const ty = y + Math.sin(angle * 2) * 8;
nodes.push({
...n,
tx: x,
ty,
r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55),
depth,
helixT: t,
});
const cidr = n.subnet || "unknown";
if (!byCidr.has(cidr)) byCidr.set(cidr, []);
byCidr.get(cidr).push({ x, y: ty });
});
const anchors = [];
for (const [cidr, pts] of byCidr.entries()) {
const a = anchorsFromPoints(cidr, pts, { x: cx, y: cy }, {
dirX: 1,
dirY: 0,
pipeExtra: 52,
pad: 10,
});
if (a) anchors.push(a);
}
state.clusterHubs = [];
state.laneGuides = [];
state.labelAnchors = anchors;
state.bloomPetals = [];
state.constellationCharts = [];
setTargets(nodes);
}
function layout() {
const devices = visibleDevices();
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const cx = w / 2;
const cy = h / 2;
if (state.layoutMode !== "lanes") {
state.hub = { x: cx, y: cy, r: 28 };
}
switch (state.layoutMode) {
case "galaxy":
layoutGalaxy(devices, cx, cy, w, h);
break;
case "clusters":
layoutClusters(devices, cx, cy, w, h);
break;
case "constellation":
layoutConstellation(devices, cx, cy, w, h);
break;
case "lanes":
layoutLanes(devices, cx, cy, w, h);
break;
case "helix":
layoutHelix(devices, cx, cy, w, h);
break;
default:
layoutOrbit(devices, cx, cy, w, h);
}
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
layout();
}
function drawHub(hub, t) {
const hubGlow = 16 + 6 * Math.sin(t * 0.05);
const g = ctx.createRadialGradient(hub.x, hub.y, 4, hub.x, hub.y, hubGlow + 20);
g.addColorStop(0, "rgba(0,118,206,0.55)");
g.addColorStop(1, "rgba(0,118,206,0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(hub.x, hub.y, hubGlow + 20, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(hub.x, hub.y, hub.r, 0, Math.PI * 2);
ctx.fillStyle = "#0076ce";
ctx.fill();
const th = canvasTheme();
ctx.strokeStyle = th.hubStroke;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = th.hubText;
ctx.font = "700 11px IBM Plex Sans, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("OME", hub.x, hub.y);
}
function isLightTheme() {
return document.documentElement.getAttribute("data-theme") === "light";
}
function canvasTheme() {
if (isLightTheme()) {
return {
label: "rgba(14, 28, 44, 0.92)",
labelSoft: "rgba(30, 50, 72, 0.78)",
empty: "rgba(14, 28, 44, 0.88)",
emptyHint: "rgba(60, 85, 110, 0.95)",
hubStroke: "#0076ce",
hubText: "#ffffff",
selectRing: "#0076ce",
decor: (i) => `rgba(0, 100, 180, ${0.08 + i * 0.025})`,
spiral: "rgba(0, 100, 180, 0.16)",
helix: (i) => `rgba(0, 100, 180, ${0.1 + i * 0.04})`,
ghostLink: (a) => `rgba(70, 90, 110, ${0.22 * a})`,
clusterLabel: "rgba(14, 28, 44, 0.85)",
laneLabel: "rgba(14, 28, 44, 0.8)",
};
}
return {
label: "rgba(232,244,255,0.85)",
labelSoft: "rgba(232,244,255,0.7)",
empty: "rgba(232,244,255,0.82)",
emptyHint: "rgba(122,147,168,0.95)",
hubStroke: "#3dffe0",
hubText: "#fff",
selectRing: "#fff",
decor: (i) => `rgba(0,168,232,${0.035 + i * 0.012})`,
spiral: "rgba(0,168,232,0.08)",
helix: (i) => `rgba(0,168,232,${0.06 + i * 0.03})`,
ghostLink: (a) => `rgba(90,106,122,${0.14 * a})`,
clusterLabel: "rgba(232,244,255,0.7)",
laneLabel: "rgba(232,244,255,0.55)",
};
}
function hexToRgb(hex) {
const h = String(hex || "#3dffe0").replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h.padEnd(6, "0");
const n = parseInt(full.slice(0, 6), 16);
if (!Number.isFinite(n)) return { r: 61, g: 255, b: 224 };
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgba(hex, a) {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r},${g},${b},${a})`;
}
function drawPulseLink(x0, y0, x1, y1, connected, t, id, alphaScale) {
if (!connected && !state.filters.ghostLinks) return;
const animate = state.filters.pulseLinks !== false;
const spd = Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45);
let line = state.filters.pulseLineColor || "#3dffe0";
let glow = state.filters.pulseGlowColor || "#00a8e8";
let packet = state.filters.pulsePacketColor || "#ffffff";
if (isLightTheme()) {
// Stronger, readable pulse on light stage when using default cyan palette
if (!state.filters.pulseLineColor || line === "#3dffe0") line = "#0076ce";
if (!state.filters.pulseGlowColor || glow === "#00a8e8") glow = "#00a4e4";
if (!state.filters.pulsePacketColor || packet === "#ffffff") packet = "#0b2a44";
}
const alpha = connected
? (animate ? (0.55 + 0.35 * Math.sin(t * 0.05 * spd + id)) : 0.72) * alphaScale
: 0.06 * alphaScale;
// glow underlay for live OME links
if (connected) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.strokeStyle = rgba(glow, (animate ? 0.28 : 0.16) * alphaScale);
ctx.lineWidth = animate ? 4.5 : 3.2;
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.strokeStyle = connected ? rgba(line, alpha) : canvasTheme().ghostLink(alphaScale);
ctx.lineWidth = connected ? 2.4 : 0.8;
ctx.stroke();
if (connected && animate) {
// dual packets along the link
for (const off of [0, 0.5]) {
const u = ((t * 0.018 * spd + (id % 50) * 0.02 + off) % 1);
const px = x0 + (x1 - x0) * u;
const py = y0 + (y1 - y0) * u;
ctx.beginPath();
ctx.arc(px, py, 3.2, 0, Math.PI * 2);
ctx.fillStyle = packet;
ctx.fill();
ctx.beginPath();
ctx.arc(px, py, 5.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(line, 0.6);
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
function drawDecor(w, h, hub, t) {
const mode = state.layoutMode;
if (mode === "orbit" || mode === "galaxy") {
for (let i = 1; i <= 5; i++) {
ctx.beginPath();
ctx.arc(hub.x, hub.y, 70 * i * 0.5, 0, Math.PI * 2);
ctx.strokeStyle = canvasTheme().decor(i);
ctx.lineWidth = 1;
ctx.stroke();
}
if (mode === "galaxy") {
// faint spiral guide
ctx.beginPath();
for (let i = 0; i <= 120; i++) {
const u = i / 120;
const ang = u * Math.PI * 4;
const r = 40 + u * Math.min(w, h) * 0.4;
const x = hub.x + Math.cos(ang) * r;
const y = hub.y + Math.sin(ang) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = canvasTheme().spiral;
ctx.lineWidth = 1.2;
ctx.stroke();
}
} else if (mode === "clusters") {
for (const ch of state.clusterHubs || []) {
const ring = (ch.localR || 52) + 10;
ctx.beginPath();
ctx.arc(ch.x, ch.y, ring, 0, Math.PI * 2);
ctx.strokeStyle = (ch.color || "#00a8e8") + "33";
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.beginPath();
ctx.arc(ch.x, ch.y, 10, 0, Math.PI * 2);
ctx.fillStyle = ch.color || "#00a8e8";
ctx.globalAlpha = 0.55;
ctx.fill();
ctx.globalAlpha = 1;
drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55);
}
} else if (mode === "constellation") {
// Celestial guide rings
for (let i = 1; i <= 4; i++) {
ctx.beginPath();
ctx.arc(hub.x, hub.y, 55 * i * 0.55, 0, Math.PI * 2);
ctx.strokeStyle = canvasTheme().decor(i);
ctx.lineWidth = 1;
ctx.setLineDash(i % 2 ? [2, 6] : []);
ctx.stroke();
ctx.setLineDash([]);
}
// Radial tick marks
for (let i = 0; i < 12; i++) {
const ang = (i / 12) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(hub.x + Math.cos(ang) * 36, hub.y + Math.sin(ang) * 36);
ctx.lineTo(hub.x + Math.cos(ang) * 48, hub.y + Math.sin(ang) * 48);
ctx.strokeStyle = canvasTheme().decor(2);
ctx.lineWidth = 1;
ctx.stroke();
}
for (const ch of state.constellationCharts || []) {
const rgb = hexToRgb(ch.color || "#00a8e8");
// Soft halo around constellation
ctx.beginPath();
ctx.arc(ch.x, ch.y, (ch.localR || 40) + 14, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.2)`;
ctx.lineWidth = 1;
ctx.stroke();
// Star-chart edges
for (const e of ch.edges || []) {
ctx.beginPath();
ctx.moveTo(e.x1, e.y1);
ctx.lineTo(e.x2, e.y2);
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.38)`;
ctx.lineWidth = 1;
ctx.stroke();
}
// Dim center marker
ctx.beginPath();
ctx.arc(ch.x, ch.y, 3, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`;
ctx.fill();
drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 19, 0.4);
}
} else if (mode === "lanes") {
for (const g of state.laneGuides || []) {
ctx.beginPath();
ctx.moveTo(70, g.y);
ctx.lineTo(w - 30, g.y);
ctx.strokeStyle = (g.color || "#00a8e8") + "44";
ctx.lineWidth = 2;
ctx.stroke();
// moving dashes
const dashX = ((t * 1.8) % (w - 100)) + 70;
ctx.beginPath();
ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = g.color || "#3dffe0";
ctx.fill();
}
} else if (mode === "helix") {
// depth rails
for (let i = 0; i < 3; i++) {
ctx.beginPath();
for (let s = 0; s <= 80; s++) {
const u = s / 80;
const ang = u * Math.PI * 2 * 2.4 + i * 0.9;
const y = 60 + u * (h - 120);
const amp = Math.min(w, h) * 0.28;
const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(ang));
const x = hub.x + Math.cos(ang) * amp * depth;
if (s === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = canvasTheme().helix(i);
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
function drawSubnetLabelPipes(omeHub) {
const hubs = (state.labelAnchors && state.labelAnchors.length
? state.labelAnchors
: state.clusterHubs) || [];
if (!hubs.length || !omeHub) return;
const th = canvasTheme();
const light = isLightTheme();
for (const ch of hubs) {
const label = String(ch.label || ch.cidr || "").trim();
if (!label) continue;
let ux;
let uy;
if (ch.dirX != null && ch.dirY != null && (ch.dirX !== 0 || ch.dirY !== 0)) {
const len = Math.hypot(ch.dirX, ch.dirY) || 1;
ux = ch.dirX / len;
uy = ch.dirY / len;
} else {
let dx = ch.x - omeHub.x;
let dy = ch.y - omeHub.y;
let len = Math.hypot(dx, dy);
if (len < 1) {
dx = 0;
dy = -1;
len = 1;
}
ux = dx / len;
uy = dy / len;
}
const ring = ch.localR || 52;
const pipeStart = Math.min(12, Math.max(4, ring * 0.25));
const pipeEnd = ring + (ch.pipeExtra != null ? ch.pipeExtra : 36);
const sx = ch.x + ux * pipeStart;
const sy = ch.y + uy * pipeStart;
const ex = ch.x + ux * pipeEnd;
const ey = ch.y + uy * pipeEnd;
const lx = ch.x + ux * (pipeEnd + 14);
const ly = ch.y + uy * (pipeEnd + 14);
const color = ch.color || "#00a8e8";
const rgb = hexToRgb(color);
ctx.save();
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`;
ctx.lineWidth = 1.6;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(ex, ey);
ctx.stroke();
ctx.beginPath();
ctx.arc(ex, ey, 2.4, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`;
ctx.fill();
ctx.restore();
ctx.font = "600 10px IBM Plex Mono, monospace";
const tw = ctx.measureText(label).width;
const padX = 8;
const bw = tw + padX * 2;
const bh = 18;
const bx = lx - bw / 2;
const by = ly - bh / 2;
ctx.beginPath();
const r = 6;
ctx.moveTo(bx + r, by);
ctx.arcTo(bx + bw, by, bx + bw, by + bh, r);
ctx.arcTo(bx + bw, by + bh, bx, by + bh, r);
ctx.arcTo(bx, by + bh, bx, by, r);
ctx.arcTo(bx, by, bx + bw, by, r);
ctx.closePath();
ctx.fillStyle = light ? "rgba(255,255,255,0.92)" : "rgba(6,14,24,0.88)";
ctx.fill();
ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.75)`;
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.fillStyle = th.clusterLabel;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, lx, ly);
}
}
function drawLinks(hub, t) {
const mode = state.layoutMode;
if (mode === "clusters") {
// node to nearest cluster hub
for (const n of state.nodes) {
let best = null;
let bestD = Infinity;
for (const ch of state.clusterHubs || []) {
const dx = n.x - ch.x;
const dy = n.y - ch.y;
const d = dx * dx + dy * dy;
if (d < bestD) {
bestD = d;
best = ch;
}
}
if (best) drawPulseLink(best.x, best.y, n.x, n.y, n.connected, t, n.id, 0.85);
}
return;
}
if (mode === "constellation") {
// Soft hub link to each star; constellation edges already in decor
for (const n of state.nodes) {
const ch = (state.constellationCharts || [])[n.constellation];
if (ch) {
drawPulseLink(ch.x, ch.y, n.x, n.y, n.connected, t, n.id, 0.55);
} else {
drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.7);
}
}
return;
}
if (mode === "lanes") {
for (const n of state.nodes) {
drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.55);
}
return;
}
if (mode === "helix") {
// chain neighbors + faint hub links for connected
const sorted = [...state.nodes].sort((a, b) => (a.helixT || 0) - (b.helixT || 0));
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
drawPulseLink(a.x, a.y, b.x, b.y, a.connected || b.connected, t, a.id, 0.7);
}
for (const n of state.nodes) {
if (n.connected) drawPulseLink(hub.x, hub.y, n.x, n.y, true, t, n.id, 0.25);
}
return;
}
// orbit / galaxy
for (const n of state.nodes) {
drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 1);
}
}
function drawNodes(t) {
// draw far (small depth) first for helix
const list = [...state.nodes].sort((a, b) => (a.depth || 1) - (b.depth || 1));
for (const n of list) {
// lerp toward target
if (Number.isFinite(n.tx)) {
n.x += (n.tx - n.x) * 0.14;
n.y += (n.ty - n.y) * 0.14;
}
const col = nodeColor(n);
const selected = n.id === state.selectedId;
const depth = n.depth || 1;
if (selected) {
ctx.beginPath();
ctx.arc(n.x, n.y, n.r + 6, 0, Math.PI * 2);
ctx.strokeStyle = canvasTheme().selectRing;
ctx.lineWidth = 2;
ctx.stroke();
}
if (n.watts != null && state.viewMode === "power") {
ctx.beginPath();
ctx.arc(n.x, n.y, n.r + 3 + Math.min(10, n.watts / 80), 0, Math.PI * 2);
ctx.strokeStyle = col + "55";
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.beginPath();
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.globalAlpha = 0.55 + 0.45 * Math.min(depth, 1);
ctx.fill();
ctx.globalAlpha = 1;
if (state.cam.scale > 0.85) {
const th = canvasTheme();
// Halo behind text for contrast on busy light canvas
const label =
state.viewMode === "power" && n.watts != null
? `${Math.round(n.watts)}W`
: (n.name || "").slice(0, 18);
ctx.font = "600 9px IBM Plex Mono, monospace";
ctx.textAlign = "center";
ctx.textBaseline = "top";
if (isLightTheme()) {
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(236, 242, 248, 0.92)";
ctx.strokeText(label, n.x, n.y + n.r + 3);
}
ctx.fillStyle = th.label;
ctx.fillText(label, n.x, n.y + n.r + 3);
}
}
}
function draw() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
// hard clear in device pixels (avoids trail artifacts with DPR transform)
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();
ctx.save();
ctx.translate(state.cam.x, state.cam.y);
ctx.scale(state.cam.scale, state.cam.scale);
const hub = state.hub;
if (!hub) {
ctx.restore();
requestAnimationFrame(draw);
return;
}
const t = state.pulseT;
drawDecor(w, h, hub, t);
drawLinks(hub, t);
drawHub(hub, t);
drawNodes(t);
drawSubnetLabelPipes(hub);
ctx.restore();
if (!state.nodes.length) {
const th = canvasTheme();
ctx.fillStyle = th.empty;
ctx.font = "600 15px IBM Plex Sans, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const f = state.filters;
const tip = (!f.servers && !f.idrac)
? "No types selected — enable Servers and/or iDRACs in Filters"
: "No devices match the current filters";
ctx.fillText(tip, w / 2, h / 2);
ctx.font = "500 12px IBM Plex Mono, monospace";
ctx.fillStyle = th.emptyHint;
ctx.fillText("Adjust Filters or click Reset filters", w / 2, h / 2 + 28);
}
if (state.filters.pulseLinks !== false) {
state.pulseT += Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45);
}
requestAnimationFrame(draw);
}
function screenToWorld(sx, sy) {
return {
x: (sx - state.cam.x) / state.cam.scale,
y: (sy - state.cam.y) / state.cam.scale,
};
}
function hitTest(sx, sy) {
const p = screenToWorld(sx, sy);
if (state.hub) {
const dx = p.x - state.hub.x;
const dy = p.y - state.hub.y;
if (dx * dx + dy * dy <= (state.hub.r + 6) ** 2) return { type: "hub" };
}
let best = null;
let bestD = Infinity;
for (const n of state.nodes) {
const dx = p.x - n.x;
const dy = p.y - n.y;
const d = dx * dx + dy * dy;
if (d <= (n.r + 8) ** 2 && d < bestD) {
bestD = d;
best = n;
}
}
return best ? { type: "node", node: best } : null;
}
const KPI_META = {
total: { title: "All nodes", blurb: "Full OME inventory currently in cockpit." },
servers: { title: "Servers", blurb: "Server-class devices (OME type 1000)." },
idracs: { title: "iDRACs", blurb: "iDRAC / BMC endpoints discovered in OME." },
connected: { title: "Connected", blurb: "Devices with live OME connection state." },
offline: { title: "Offline", blurb: "Devices currently not connected to OME." },
powered: { title: "Powered on", blurb: "Devices reporting powered-on state." },
power: { title: "Live power", blurb: "Nodes with a live watt reading — hottest first." },
avgw: { title: "Average watts / node", blurb: "Power samples contributing to fleet average." },
samples: { title: "Power samples", blurb: "Devices included in the live power sample set." },
};
let kpiPopup = { key: null, selectedId: null, q: "", sort: "name" };
function devicesForKpi(key) {
const all = state.data?.devices || [];
switch (key) {
case "servers":
return all.filter((d) => d.is_server);
case "idracs":
return all.filter((d) => d.is_idrac);
case "connected":
return all.filter((d) => d.connected);
case "offline":
return all.filter((d) => !d.connected);
case "powered":
return all.filter((d) => d.powered_on);
case "power":
case "avgw":
case "samples":
return all.filter((d) => d.watts != null);
case "total":
default:
return all.slice();
}
}
function sortKpiDevices(list) {
const sort = kpiPopup.sort || "name";
const arr = list.slice();
if (sort === "watts") arr.sort((a, b) => (b.watts || 0) - (a.watts || 0));
else if (sort === "status") arr.sort((a, b) => String(a.status || "").localeCompare(String(b.status || "")));
else if (sort === "subnet") arr.sort((a, b) => String(a.subnet || "").localeCompare(String(b.subnet || "")));
else arr.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
return arr;
}
function filterKpiDevices(list) {
const q = (kpiPopup.q || "").trim().toLowerCase();
if (!q) return list;
return list.filter((d) =>
[d.name, d.ip, d.model, d.service_tag, d.subnet, d.status]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(q)
);
}
function kpiSummaryHtml(key, list) {
const connected = list.filter((d) => d.connected).length;
const offline = list.length - connected;
const powered = list.filter((d) => d.powered_on).length;
const withW = list.filter((d) => d.watts != null);
const watts = withW.reduce((a, d) => a + (d.watts || 0), 0);
const subnets = new Set(list.map((d) => d.subnet).filter(Boolean)).size;
const pills = [
`${list.length} in view`,
`${connected} connected`,
`${offline} offline`,
`${powered} powered`,
withW.length ? `${Math.round(watts)} W sampled` : "no power samples",
`${subnets} subnets`,
];
if (key === "power" || key === "avgw" || key === "samples") {
const top = sortKpiDevices(withW).slice(0, 1)[0];
if (top) pills.push(`hottest ${top.name?.slice(0, 22)} ${Math.round(top.watts)}W`);
}
return pills.map((p) => `<span class="pill">${escapeHtml(p)}</span>`).join("");
}
function renderKpiDetail(node) {
const el = $("#kpi-detail");
if (!el) return;
if (!node) {
el.innerHTML = `<p class="hint">Select a system on the left for live context and actions.</p>`;
return;
}
const alerts = (state.data?.alerts || []).filter((a) => a.device_id === node.id).slice(0, 5);
el.innerHTML = `
<h3>${escapeHtml(node.name || "device")}</h3>
<p class="meta">${escapeHtml(node.model || "—")} · ${escapeHtml(node.service_tag || "no tag")} · id ${node.id}</p>
<div class="badge-row">
<span class="badge ${node.connected ? "on" : "off"}">${node.connected ? "CONNECTED" : "OFFLINE"}</span>
<span class="badge ${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED ON" : "POWER N/A"}</span>
${node.watts != null ? `<span class="badge power">${Math.round(node.watts)} W</span>` : ""}
${node.is_idrac ? `<span class="badge">iDRAC</span>` : ""}
${node.is_server ? `<span class="badge">SERVER</span>` : ""}
</div>
<div class="kpi-kv">
<div class="row"><span class="k">Service Tag</span><span class="v"><code>${escapeHtml(node.service_tag || "—")}</code></span></div>
<div class="row"><span class="k">IP</span><span class="v">${escapeHtml(node.ip || "—")}</span></div>
<div class="row"><span class="k">Subnet</span><span class="v">${escapeHtml(node.subnet || "—")}</span></div>
<div class="row"><span class="k">Status</span><span class="v">${escapeHtml(node.status || "—")}</span></div>
<div class="row"><span class="k">Avg / peak</span><span class="v">${node.avg_watts != null ? Math.round(node.avg_watts) + " W" : "—"} / ${node.peak_watts != null ? Math.round(node.peak_watts) + " W" : "—"}</span></div>
<div class="row"><span class="k">Last status</span><span class="v">${escapeHtml(node.last_status_time || "—")}</span></div>
</div>
<div class="kpi-actions">
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button>
<button type="button" class="btn conn-link" data-kpi-act="ssh" ${node.ip ? "" : "disabled"}>SSH terminal</button>
<button type="button" class="btn conn-link" data-kpi-act="rdp" ${node.ip ? "" : "disabled"}>RDP popout</button>
<button type="button" class="btn" data-kpi-act="connect">Quick Connect</button>
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button>
<button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
</div>
<div class="inv-section">
<h3>Related alerts</h3>
${
alerts.length
? alerts
.map(
(a) =>
`<div class="inv-line">${escapeHtml(a.severity)} · ${escapeHtml((a.message || "").slice(0, 120))}</div>`
)
.join("")
: `<p class="hint">No alerts for this node in the current feed.</p>`
}
</div>`;
}
function renderKpiPopup() {
const key = kpiPopup.key;
if (!key) return;
const meta = KPI_META[key] || { title: key, blurb: "" };
const raw = devicesForKpi(key);
const list = sortKpiDevices(filterKpiDevices(raw));
$("#kpi-title").textContent = meta.title;
$("#kpi-sub").textContent = `${meta.blurb} · ${list.length} shown of ${raw.length}`;
$("#kpi-summary").innerHTML = kpiSummaryHtml(key, raw);
const listEl = $("#kpi-list");
if (!list.length) {
listEl.innerHTML = `<p class="hint">No systems match this KPI / search.</p>`;
renderKpiDetail(null);
return;
}
if (kpiPopup.selectedId == null || !list.some((d) => d.id === kpiPopup.selectedId)) {
kpiPopup.selectedId = list[0].id;
}
listEl.innerHTML = list
.slice(0, 200)
.map((d) => {
const bits = [
d.ip || "no IP",
d.connected ? "up" : "down",
d.watts != null ? Math.round(d.watts) + "W" : null,
d.subnet,
]
.filter(Boolean)
.join(" · ");
return `<button type="button" class="kpi-item${d.id === kpiPopup.selectedId ? " active" : ""}" data-kpi-device="${d.id}">
<span class="t">${escapeHtml(d.name || "device")}</span>
<span class="s">${escapeHtml(bits)}</span>
</button>`;
})
.join("");
const selected = list.find((d) => d.id === kpiPopup.selectedId) || null;
renderKpiDetail(selected);
}
function openKpiPopup(key) {
kpiPopup.key = key;
kpiPopup.selectedId = null;
kpiPopup.q = "";
const search = $("#kpi-search");
if (search) search.value = "";
const modal = $("#kpi-modal");
const scrim = $("#scrim");
modal?.classList.remove("hidden");
modal?.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "kpi";
renderKpiPopup();
}
function closeKpiPopup() {
const modal = $("#kpi-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "kpi") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function applyKpiAsFilter(key) {
state.kpiFocus = key;
// Align type filters so the map can show the KPI set
if (key === "idracs") {
state.filters.idrac = true;
state.filters.servers = false;
} else if (key === "servers" || key === "offline" || key === "powered" || key === "connected" || key === "total") {
state.filters.servers = true;
// keep idrac optional for total/connected/offline breadth
if (key === "total" || key === "connected" || key === "offline") {
state.filters.idrac = true;
}
}
if (key === "connected") state.filters.connected = true;
if (key === "powered") state.filters.powered = true;
if (key === "power" || key === "avgw" || key === "samples") {
state.filters.hasPower = true;
state.viewMode = "power";
$("#view-modes")?.querySelectorAll(".chip").forEach((c) =>
c.classList.toggle("active", c.dataset.mode === "power")
);
}
syncFilterInputs();
refreshLists();
layout();
snapNodes();
fitCameraToNodes();
updateFocusContext();
showToast(`Map filter: ${KPI_META[key]?.title || key}`);
}
function renderKpis() {
const s = state.data?.summary || {};
const items = [
{ key: "total", label: "Nodes", v: s.total ?? "—", cls: "" },
{ key: "servers", label: "Servers", v: s.servers ?? "—", cls: "" },
{ key: "idracs", label: "iDRACs", v: s.idracs ?? "—", cls: "" },
{ key: "connected", label: "Connected", v: s.connected ?? "—", cls: "" },
{ key: "offline", label: "Offline", v: s.offline ?? "—", cls: "warn" },
{ key: "powered", label: "Powered on", v: s.powered_on ?? "—", cls: "" },
{
key: "power",
label: "Live power",
v: s.total_watts != null ? `${Math.round(s.total_watts)} W` : "—",
cls: "power",
},
{
key: "avgw",
label: "Avg / node",
v: s.avg_node_watts != null ? `${Math.round(s.avg_node_watts)} W` : "—",
cls: "power",
},
{
key: "samples",
label: "Power samples",
v: s.power_samples ?? "—",
cls: "",
},
];
$("#kpi-strip").innerHTML = items
.map(
(it) => `
<button type="button" class="kpi ${it.cls}${state.kpiFocus === it.key ? " active" : ""}" data-kpi="${it.key}">
<span class="v">${it.v}</span>
<span class="l">${it.label}</span>
</button>`
)
.join("");
}
function renderSubnets() {
const subs = state.data?.subnets || [];
const html = [
`<button type="button" class="card${state.subnet === "all" ? " active" : ""}" data-subnet="all">
<span class="t">All networks</span>
<span class="s">${state.data?.summary?.total ?? 0} nodes · ${state.data?.summary?.total_watts != null ? Math.round(state.data.summary.total_watts) + " W" : "—"}</span>
</button>`,
...subs.map((s) => {
const vlanLabel =
s.vlan_id != null
? `VLAN ${s.vlan_id}${s.vlan_name ? " · " + s.vlan_name : ""}`
: s.cidr;
const accent = s.vlan_color ? ` style="--vlan:${escapeAttr(s.vlan_color)}"` : "";
return `<button type="button" class="card${state.subnet === s.cidr ? " active" : ""}${s.vlan_id != null ? " has-vlan" : ""}" data-subnet="${escapeAttr(s.cidr)}"${accent}>
<span class="t">${escapeHtml(vlanLabel)}</span>
<span class="s">${escapeHtml(s.cidr)} · ${s.connected}/${s.count} connected${s.watts != null ? ` · ${Math.round(s.watts)} W` : ""}</span>
</button>`;
}),
];
$("#subnet-list").innerHTML = html.join("");
}
function renderModels() {
const models = state.data?.models || [];
$("#model-list").innerHTML = [
`<button type="button" class="card${state.model == null ? " active" : ""}" data-model="">
<span class="t">All models</span>
<span class="s">${models.reduce((a, m) => a + m.count, 0)} servers</span>
</button>`,
...models.map(
(m) => `<button type="button" class="card${state.model === m.name ? " active" : ""}" data-model="${escapeAttr(m.name)}">
<span class="t">${escapeHtml(m.name)}</span>
<span class="s">${m.count} nodes</span>
</button>`
),
].join("");
}
function renderGroups() {
const groups = state.data?.groups || [];
$("#group-list").innerHTML = groups.length
? groups
.map(
(g) => `<button type="button" class="card" data-group="${escapeAttr(g.name)}">
<span class="t">${escapeHtml(g.name)}</span>
<span class="s">OME group · id ${g.id}</span>
</button>`
)
.join("")
: `<p class="hint">No groups loaded</p>`;
}
function renderLegend() {
const el = $("#legend");
if (state.viewMode === "power") {
el.innerHTML = `
<span style="--c:#3dffa0"> &lt;200W</span>
<span style="--c:#00a8e8">200450W</span>
<span style="--c:#ffb020">450650W</span>
<span style="--c:#ff5c5c">&gt;650W</span>
<span style="--c:#3a4a5a">geen reading</span>`;
} else if (state.viewMode === "connection") {
el.innerHTML = `<span style="--c:#3dffe0">connected</span><span style="--c:#ff5c5c">offline</span>`;
} else if (state.viewMode === "model") {
el.innerHTML = [...state.modelColors.entries()]
.slice(0, 6)
.map(([k, c]) => `<span style="--c:${c}">${escapeHtml(k.slice(0, 22))}</span>`)
.join("");
} else if (state.viewMode === "subnet") {
el.innerHTML = [...state.subnetColors.entries()]
.slice(0, 6)
.map(([k, c]) => `<span style="--c:${c}">${escapeHtml(k)}</span>`)
.join("");
} else {
el.innerHTML = `
<span style="--c:#3dffa0">healthy</span>
<span style="--c:#ffb020">warning</span>
<span style="--c:#ff5c5c">critical</span>
<span style="--c:#5a6a7a">offline</span>`;
}
}
function renderTicker() {
const d = state.data;
if (!d) return;
const ome = d.ome || {};
const s = d.summary || {};
const ctx = d.context || {};
const sev = ctx.alert_severity || {};
$("#ticker").innerHTML = `
<button type="button" data-tick="ome">${escapeHtml(ome.name || "OME")} ${escapeHtml(ome.version || "")}</button>
<span class="sep">·</span>
<button type="button" data-tick="pulse">pulse #${d.pulse ?? 0}</button>
<span class="sep">·</span>
<button type="button" data-tick="power">${s.total_watts != null ? Math.round(s.total_watts) + " W live" : "power pending"}</button>
<span class="sep">·</span>
<button type="button" data-tick="connected">${s.connected ?? 0} connected</button>
<span class="sep">·</span>
<button type="button" data-tick="alerts">${sev.Critical ?? 0} crit / ${sev.Warning ?? 0} warn</button>
<span class="sep">·</span>
<button type="button" data-tick="visible">${state.nodes.length} visible</button>
<span class="sep">·</span>
<span>updated ${d.updated_at ? new Date(d.updated_at * 1000).toLocaleTimeString() : "—"}</span>
`;
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, "&#39;");
}
function sevClass(sev) {
const s = String(sev || "").toLowerCase();
if (s.includes("crit")) return "critical";
if (s.includes("warn")) return "warning";
return "info";
}
function relTime(ts) {
if (!ts) return "—";
let t;
if (typeof ts === "number") t = ts * (ts > 1e12 ? 1 : 1000);
else {
// OME: "2026-07-16 23:36:33.858"
const d = Date.parse(String(ts).replace(" ", "T") + "Z");
t = Number.isFinite(d) ? d : Date.parse(String(ts));
}
if (!Number.isFinite(t)) return String(ts).slice(0, 19);
const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
if (sec < 60) return sec + "s ago";
if (sec < 3600) return Math.floor(sec / 60) + "m ago";
if (sec < 86400) return Math.floor(sec / 3600) + "h ago";
return Math.floor(sec / 86400) + "d ago";
}
function updateFocusContext() {
const el = $("#ctx-focus");
if (!el) return;
const bits = [];
bits.push("layout " + (state.layoutMode || "orbit"));
bits.push("color " + (state.viewMode || "status"));
bits.push(state.subnet === "all" ? "all networks" : state.subnet);
if (state.model) bits.push(state.model);
const types = [];
if (state.filters.servers) types.push("servers");
if (state.filters.idrac) types.push("iDRACs");
bits.push(types.length ? types.join("+") : "no types");
if (state.filters.connected) bits.push("connected");
if (state.filters.powered) bits.push("powered");
if (state.filters.hasPower) bits.push("has W");
if (state.filters.minWatts > 0) bits.push("≥" + state.filters.minWatts + "W");
if (state.filters.pulseLinks === false) bits.push("pulse off");
if (state.filters.search) bits.push("“" + state.filters.search.slice(0, 18) + "”");
if (state.selectedId) {
const n = (state.data?.devices || []).find((d) => d.id === state.selectedId);
if (n) bits.push("node " + (n.name || "").slice(0, 22));
}
bits.push(state.nodes.length + " visible");
el.textContent = "Focus: " + bits.join(" · ");
}
function renderContext() {
const d = state.data;
const ctx = d?.context || {};
const s = d?.summary || {};
const live = $("#ctx-live-text");
if (live) {
const age = d?.updated_at ? relTime(d.updated_at) : "—";
live.textContent =
(ctx.focus_hint || "Realtime context") +
" · pulse #" +
(d?.pulse ?? 0) +
" · " +
age;
}
const stats = $("#ctx-stats");
if (stats) {
const sev = ctx.alert_severity || {};
stats.innerHTML = `
<button type="button" class="ctx-stat danger" data-ctx="critical">
<span class="v">${sev.Critical ?? 0}</span><span class="l">Critical</span>
</button>
<button type="button" class="ctx-stat warn" data-ctx="warning">
<span class="v">${sev.Warning ?? 0}</span><span class="l">Warning</span>
</button>
<button type="button" class="ctx-stat" data-ctx="events">
<span class="v">${(d?.events || []).length}</span><span class="l">Fleet deltas</span>
</button>
<button type="button" class="ctx-stat" data-ctx="hottest">
<span class="v">${s.total_watts != null ? Math.round(s.total_watts) + "W" : "—"}</span><span class="l">Live power</span>
</button>`;
}
// alerts in left rail
const al = $("#alert-list");
if (al) {
const alerts = (d?.alerts || []).slice(0, 12);
if (!alerts.length) {
al.innerHTML = `<p class="hint">No alerts in this snapshot</p>`;
} else {
al.innerHTML = alerts
.map(
(a) => `<button type="button" class="card alert-card" data-alert-device="${a.device_id ?? ""}" data-alert-id="${a.id ?? ""}">
<span class="t">${escapeHtml(a.severity)} · ${escapeHtml((a.device || "OME").slice(0, 28))}</span>
<span class="s">${escapeHtml((a.message || "").slice(0, 110))}${a.time ? " · " + escapeHtml(relTime(a.time)) : ""}</span>
</button>`
)
.join("");
}
}
// live feed overlay: merge events + alerts
const body = $("#ctx-feed-body");
const meta = $("#ctx-feed-meta");
if (meta) {
meta.textContent =
(ctx.alerts_total != null ? ctx.alerts_total + " alerts total" : "alerts…") +
(ctx.events_new ? ` · +${ctx.events_new} delta` : "");
}
if (body) {
if ($("#ctx-feed")?.classList.contains("collapsed")) {
body.innerHTML = "";
updateFocusContext();
return;
}
const items = [];
for (const e of d?.events || []) {
items.push({
cls: sevClass(e.severity),
title: e.title || e.kind,
msg: e.text,
sub: (e.kind || "delta") + " · " + relTime(e.ts),
deviceId: e.device_id,
sort: e.ts || 0,
});
}
for (const a of (d?.alerts || []).slice(0, 15)) {
let sort = 0;
const parsed = Date.parse(String(a.time || "").replace(" ", "T") + "Z");
sort = Number.isFinite(parsed) ? parsed / 1000 : 0;
items.push({
cls: sevClass(a.severity),
title: (a.device || "OME") + " · " + (a.severity || ""),
msg: a.message || "",
sub: (a.category || "alert") + " · " + relTime(a.time),
deviceId: a.device_id,
sort,
});
}
// hottest quick context
for (const h of ctx.hottest || []) {
items.push({
cls: "info",
title: "Hot · " + (h.name || "").slice(0, 24),
msg: Math.round(h.watts) + " W · " + (h.subnet || ""),
sub: "power ranking",
deviceId: h.id,
sort: (d?.updated_at || 0) + (h.watts || 0) / 1e6,
});
}
items.sort((x, y) => (y.sort || 0) - (x.sort || 0));
body.innerHTML = items
.slice(0, 24)
.map(
(it) => `<button type="button" class="feed-item ${it.cls}" data-feed-device="${it.deviceId ?? ""}">
<span class="ft">${escapeHtml(it.title)}</span>
<span class="fm">${escapeHtml((it.msg || "").slice(0, 140))}</span>
<span class="fs">${escapeHtml(it.sub)}</span>
</button>`
)
.join("");
}
updateFocusContext();
}
function focusDeviceId(id) {
if (id == null || id === "") return;
const nid = Number(id);
const n = (state.data?.devices || []).find((d) => d.id === nid || d.id === id);
if (!n) return;
state.selectedId = n.id;
// ensure visible: clear blocking filters lightly
showInspector(n);
// pan toward node if laid out
const laid = state.nodes.find((x) => x.id === n.id);
if (laid) {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
state.cam.x = w / 2 - laid.x * state.cam.scale;
state.cam.y = h / 2 - laid.y * state.cam.scale;
}
updateFocusContext();
}
function snapNodes() {
for (const n of state.nodes) {
if (Number.isFinite(n.tx)) {
n.x = n.tx;
n.y = n.ty;
}
}
}
function fitCameraToNodes(opts = {}) {
const nodes = state.nodes;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (!w || !h) {
state.cam = { x: 0, y: 0, scale: 1 };
return;
}
if (!nodes.length) {
state.cam = { x: 0, y: 0, scale: 1 };
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const expand = (x, y, r = 0) => {
minX = Math.min(minX, x - r);
maxX = Math.max(maxX, x + r);
minY = Math.min(minY, y - r);
maxY = Math.max(maxY, y + r);
};
for (const n of nodes) {
const x = Number.isFinite(n.tx) ? n.tx : n.x;
const y = Number.isFinite(n.ty) ? n.ty : n.y;
expand(x, y, (n.r || 6) + 14);
}
if (state.hub) expand(state.hub.x, state.hub.y, (state.hub.r || 28) + 16);
for (const ch of state.clusterHubs || []) expand(ch.x, ch.y, 56);
for (const c of state.constellationCharts || []) expand(c.x, c.y, (c.localR || 40) + 24);
for (const a of state.labelAnchors || []) expand(a.x, a.y, 48);
const bw = Math.max(80, maxX - minX);
const bh = Math.max(80, maxY - minY);
// Leave room for ctx-bar (top) + legend/feed (bottom)
const padX = opts.padX ?? 70;
const padY = opts.padY ?? 110;
const availW = Math.max(120, w - padX * 2);
const availH = Math.max(120, h - padY * 2);
const minScale = opts.minScale ?? 0.28;
const maxScale = opts.maxScale ?? 1.35;
let scale = Math.min(availW / bw, availH / bh);
scale = Math.min(maxScale, Math.max(minScale, scale));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
// Bias slightly downward so top ctx-bar does not cover the hub
const yBias = 18;
state.cam.scale = scale;
state.cam.x = w / 2 - cx * scale;
state.cam.y = h / 2 - cy * scale + yBias;
}
function resetView() {
// Ensure canvas metrics are current, then rebuild layout in screen space
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (w && h) {
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
layout();
snapNodes();
// Designed layouts assume identity camera; then nudge to fit with padding
state.cam = { x: 0, y: 0, scale: 1 };
fitCameraToNodes();
updateFocusContext();
showToast("View reset · fit to fleet");
}
function syncFilterInputs() {
const f = state.filters;
const set = (id, val) => {
const el = document.getElementById(id);
if (!el) return;
if (el.type === "checkbox") el.checked = !!val;
else el.value = val;
};
set("f-connected", f.connected);
set("f-powered", f.powered);
set("f-servers", f.servers);
set("f-idrac", f.idrac);
set("f-has-power", f.hasPower);
set("f-ghost-links", f.ghostLinks);
set("f-pulse-links", f.pulseLinks !== false);
set("f-pulse-speed", f.pulseSpeed ?? 0.45);
set("f-pulse-line-color", f.pulseLineColor || "#3dffe0");
set("f-pulse-glow-color", f.pulseGlowColor || "#00a8e8");
set("f-pulse-packet-color", f.pulsePacketColor || "#ffffff");
set("f-min-watts", f.minWatts || 0);
const lab = $("#min-w-label");
if (lab) lab.textContent = String(f.minWatts || 0);
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(f.pulseSpeed ?? 0.45).toFixed(2)}×`;
const search = $("#search");
if (search) search.value = f.search || "";
}
function savePulsePrefs() {
try {
localStorage.setItem(
"cockpit_pulse_prefs",
JSON.stringify({
pulseSpeed: state.filters.pulseSpeed ?? 0.45,
pulseLineColor: state.filters.pulseLineColor || "#3dffe0",
pulseGlowColor: state.filters.pulseGlowColor || "#00a8e8",
pulsePacketColor: state.filters.pulsePacketColor || "#ffffff",
})
);
localStorage.setItem("cockpit_pulse_speed", String(state.filters.pulseSpeed ?? 0.45));
} catch (_) {}
}
function readFiltersFromDom() {
const on = (id) => !!document.getElementById(id)?.checked;
state.filters.connected = on("f-connected");
state.filters.powered = on("f-powered");
state.filters.servers = on("f-servers");
state.filters.idrac = on("f-idrac");
state.filters.hasPower = on("f-has-power");
state.filters.ghostLinks = on("f-ghost-links");
state.filters.pulseLinks = on("f-pulse-links");
const ps = document.getElementById("f-pulse-speed");
if (ps) state.filters.pulseSpeed = Math.max(0.1, Math.min(2, Number(ps.value) || 0.45));
const line = document.getElementById("f-pulse-line-color");
const glow = document.getElementById("f-pulse-glow-color");
const packet = document.getElementById("f-pulse-packet-color");
if (line?.value) state.filters.pulseLineColor = line.value;
if (glow?.value) state.filters.pulseGlowColor = glow.value;
if (packet?.value) state.filters.pulsePacketColor = packet.value;
const mw = document.getElementById("f-min-watts");
state.filters.minWatts = mw ? Number(mw.value) || 0 : 0;
state.filters.search = $("#search")?.value || "";
savePulsePrefs();
}
function clearFilters() {
state.filters = {
connected: false,
powered: false,
servers: true,
idrac: false,
hasPower: false,
ghostLinks: false,
pulseLinks: true,
pulseSpeed: 0.45,
pulseLineColor: "#3dffe0",
pulseGlowColor: "#00a8e8",
pulsePacketColor: "#ffffff",
minWatts: 0,
search: "",
};
state.subnet = "all";
state.model = null;
state.kpiFocus = null;
state.groupHint = null;
syncFilterInputs();
refreshLists();
layout();
snapNodes();
state.cam = { x: 0, y: 0, scale: 1 };
fitCameraToNodes();
updateFocusContext();
showToast("Filters reset · Servers on");
}
function showToast(msg, opts = {}) {
let el = $("#toast");
if (!el) {
el = document.createElement("div");
el.id = "toast";
el.className = "toast";
document.body.appendChild(el);
}
el.textContent = msg;
el.classList.toggle("toast-alert", !!opts.alert);
el.classList.add("show");
clearTimeout(showToast._t);
const ms = opts.ms != null ? opts.ms : opts.alert ? 8000 : 2200;
showToast._t = setTimeout(() => el.classList.remove("show"), ms);
}
function pushNotifyCard(ev) {
const host = $("#notify-stack");
if (!host) return;
const card = document.createElement("button");
card.type = "button";
card.className = `notify-card ${ev.severity || "info"}`;
card.dataset.deviceId = ev.device_id ?? "";
card.innerHTML = `
<span class="nc-kicker">${escapeHtml(ev.kind === "device_removed" ? "Removed" : "New on network")}</span>
<strong class="nc-title">${escapeHtml(ev.title || "Fleet change")}</strong>
<span class="nc-text">${escapeHtml(ev.text || "")}</span>
<span class="nc-meta">${escapeHtml(relTime(ev.ts))}</span>`;
card.addEventListener("click", () => {
if (ev.device_id != null) focusDeviceId(ev.device_id);
card.remove();
});
host.prepend(card);
while (host.children.length > 6) host.lastElementChild.remove();
setTimeout(() => card.classList.add("show"), 20);
setTimeout(() => {
card.classList.remove("show");
setTimeout(() => card.remove(), 400);
}, 14000);
}
function handleFleetNotifications(data) {
const notes = data?.context?.notifications || [];
const events = data?.events || [];
const candidates = [
...notes,
...events.filter((e) => e.kind === "device_new" || e.kind === "device_removed"),
];
for (const ev of candidates) {
const key = `${ev.kind}:${ev.device_id}:${Math.floor(ev.ts || 0)}`;
if (state.notifiedEventKeys.has(key)) continue;
state.notifiedEventKeys.add(key);
if (state.notifiedEventKeys.size > 200) {
state.notifiedEventKeys = new Set([...state.notifiedEventKeys].slice(-100));
}
const role = ev.role || (String(ev.title || "").toLowerCase().includes("idrac") ? "iDRAC" : "server");
const msg =
ev.kind === "device_removed"
? `${role} removed: ${ev.name || ev.title || "device"}`
: `New ${role} on network: ${ev.name || ev.title || "device"} · ${ev.ip || ""}`.trim();
showToast(msg, { alert: true, ms: 9000 });
pushNotifyCard(ev);
}
}
function hideTip() {
state.hoverId = null;
const tip = $("#node-tip");
if (tip) tip.classList.add("hidden");
}
function showTip(node, clientX, clientY) {
const tip = $("#node-tip");
if (!tip || !node) return;
state.hoverId = node.id;
const watts = node.watts != null ? `${Math.round(node.watts)} W` : "no power sample";
tip.innerHTML = `
<p class="nt-name">${escapeHtml(node.name || "node")}</p>
<p class="nt-meta">${escapeHtml(node.model || "—")} · ST ${escapeHtml(node.service_tag || "—")}<br/>
${node.connected ? "connected" : "offline"} · ${node.powered_on ? "powered on" : "power n/a"} · ${escapeHtml(watts)}<br/>
${escapeHtml(node.ip || "no IP")} · ${escapeHtml(node.subnet || "")}</p>
<p class="nt-hint">Double-click · Quick Connect</p>`;
tip.classList.remove("hidden");
const pad = 14;
let x = clientX + pad;
let y = clientY + pad;
const tw = tip.offsetWidth || 220;
const th = tip.offsetHeight || 100;
if (x + tw > window.innerWidth - 8) x = clientX - tw - pad;
if (y + th > window.innerHeight - 8) y = clientY - th - pad;
tip.style.left = `${Math.max(8, x)}px`;
tip.style.top = `${Math.max(8, y)}px`;
}
function relatedAlerts(node) {
const id = node?.id;
return (state.data?.alerts || []).filter((a) => a.device_id === id).slice(0, 3);
}
function openConnect(node) {
if (!node || node.id == null) return;
state.connectNode = node;
const modal = $("#connect-modal");
const scrim = $("#scrim");
if (!modal) return;
$("#connect-title").textContent = node.name || "Device";
$("#connect-sub").textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · OME #${node.id}`;
$("#connect-badges").innerHTML = `
<span class="badge ${node.connected ? "on" : "off"}">${node.connected ? "CONNECTED" : "OFFLINE"}</span>
<span class="badge ${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED ON" : "POWER N/A"}</span>
${node.watts != null ? `<span class="badge power">${Math.round(node.watts)} W</span>` : ""}
${node.is_idrac ? `<span class="badge">iDRAC</span>` : ""}
${node.is_server ? `<span class="badge">SERVER</span>` : ""}`;
const alerts = relatedAlerts(node);
$("#connect-context").innerHTML = `
<div class="modal-ctx-item"><span class="k">IP</span><span class="v">${escapeHtml(node.ip || "—")}</span></div>
<div class="modal-ctx-item"><span class="k">Subnet</span><span class="v">${escapeHtml(node.subnet || "—")}</span></div>
<div class="modal-ctx-item"><span class="k">Last status</span><span class="v">${escapeHtml(node.last_status_time || "—")}</span></div>
<div class="modal-ctx-item"><span class="k">Alerts</span><span class="v">${alerts.length ? alerts[0].severity + " · " + escapeHtml((alerts[0].message || "").slice(0, 42)) : "none in live feed"}</span></div>`;
const ip = node.ip;
const idrac = node.idrac_url || (ip ? `https://${ip}` : null);
$("#connect-grid").innerHTML = `
${idrac ? `<a class="connect-tile primary conn-link" href="${escapeAttr(idrac)}" target="_blank" rel="noopener">
<span class="ct-ico">▣</span>
<span class="ct-t">iDRAC Web</span>
<span class="ct-s">BMC console · ${escapeHtml(ip)}</span>
</a>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC Web</span><span class="ct-s">No management IP</span></div>`}
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh">
<span class="ct-ico">〉_</span>
<span class="ct-t">SSH terminal</span>
<span class="ct-s">Login with your username · ${escapeHtml(ip)}</span>
</button>` : ""}
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-rdp">
<span class="ct-ico">▭</span>
<span class="ct-t">RDP popout</span>
<span class="ct-s">${node.rdp_host ? `OS ${escapeHtml(node.rdp_host)}` : node.os_hostname ? `Resolve ${escapeHtml(node.os_hostname)}` : `Edit host · ${escapeHtml(ip)}`}${node.is_windows ? " · Windows" : ""}</span>
</button>` : ""}
<button type="button" class="connect-tile" id="btn-modal-ai">
<span class="ct-ico">✦</span>
<span class="ct-t">Ask AI</span>
<span class="ct-s">OpenManage AI about this node</span>
</button>
<button type="button" class="connect-tile" id="btn-modal-inventory">
<span class="ct-ico">☰</span>
<span class="ct-t">Inspector</span>
<span class="ct-s">Load full inventory</span>
</button>`;
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
scrim.dataset.mode = "connect";
$("#connect-grid").onclick = async (e) => {
const copyBtn = e.target.closest("[data-copy]");
if (copyBtn) {
try {
await navigator.clipboard.writeText(copyBtn.dataset.copy);
showToast("SSH command copied");
} catch (_) {
showToast("Copy failed");
}
return;
}
if (e.target.closest("#btn-modal-ssh")) {
closeConnect();
window.cockpitSsh?.open(node);
return;
}
if (e.target.closest("#btn-modal-rdp")) {
closeConnect();
window.cockpitRdp?.open(node);
return;
}
if (e.target.closest("#btn-modal-ai")) {
closeConnect();
openAi(node);
return;
}
if (e.target.closest("#btn-modal-inventory")) {
closeConnect();
showInspector(node);
setTimeout(() => $("#btn-detail")?.click(), 50);
}
};
}
function closeConnect() {
const modal = $("#connect-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
state.connectNode = null;
const scrim = $("#scrim");
if (scrim?.dataset.mode === "connect") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function softRefreshInspector(node) {
if (!node) return;
const body = $("#inspector-body");
if (!body || body.classList.contains("hidden")) {
showInspector(node);
return;
}
// Same node already open: update live fields only — keep inventory panel.
if (state.selectedId !== node.id) {
showInspector(node);
return;
}
const head = body.querySelector(".insp-head h2");
if (head && head.textContent !== (node.name || "")) {
showInspector(node);
return;
}
const badges = body.querySelector(".badge-row");
if (badges) {
badges.innerHTML = `
<span class="badge ${node.connected ? "on" : "off"}">${node.connected ? "CONNECTED" : "OFFLINE"}</span>
<span class="badge ${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED ON" : "POWER OFF / N/A"}</span>
${node.watts != null ? `<span class="badge power">${Math.round(node.watts)} W</span>` : ""}
${node.is_idrac ? `<span class="badge">iDRAC</span>` : ""}
${node.is_server ? `<span class="badge">SERVER</span>` : ""}`;
}
const setKv = (label, value) => {
for (const row of body.querySelectorAll(".kv-row")) {
const k = row.querySelector(".k");
const v = row.querySelector(".v");
if (k && v && k.textContent === label) {
if (label === "Subnet") return; // keep jump button
v.textContent = value;
}
}
};
setKv("IP", node.ip || "—");
setKv("Status", node.status || "—");
setKv("Avg W", node.avg_watts != null ? Math.round(node.avg_watts) + " W" : "—");
setKv("Peak W", node.peak_watts != null ? Math.round(node.peak_watts) + " W" : "—");
setKv("Energy", node.energy_kwh != null ? node.energy_kwh + " kWh" : "—");
setKv("Last status", node.last_status_time || "—");
setKv("Inventory", node.last_inventory_time || "—");
updateFocusContext();
}
async function loadExpansionPreview(deviceId) {
const mount = $("#warranty-compliance-mount");
if (!mount) return;
try {
const r = await fetch(`/api/devices/${deviceId}/expansion`);
if (!r.ok) return;
const data = await r.json();
const x = data.expansion || {};
const mem = x.memory || {};
const spec = x.spec || {};
const findings = (x.findings || []).filter((f) => f.severity === "warning").slice(0, 2);
const box = document.createElement("div");
box.className = "inv-section";
box.innerHTML = `<h3>Memory / expansion</h3>
<div class="inv-line">${mem.total_gb ?? "—"} GB in ${mem.populated ?? 0} DIMMs · ${mem.empty ?? 0} empty slots · Dell max ${spec.max_memory_tb != null ? spec.max_memory_tb + " TB" : "—"}</div>
<div class="inv-line">Disks ${x.disks?.installed ?? "—"} / ${spec.drive_bays_max ?? "—"} bays · PCIe max ${spec.pcie_slots_max ?? "—"}</div>
${findings.map((f) => `<div class="inv-line">${escapeHtml(f.text)}</div>`).join("")}
<div class="inv-line"><button type="button" class="btn compact" id="btn-open-hw">Open full DIMM map &amp; Dell guidance</button></div>`;
mount.appendChild(box);
$("#btn-open-hw")?.addEventListener("click", () => {
window.cockpitReports?.open?.();
// slight delay for drawer open
setTimeout(() => {
if (window.cockpitReports) {
window.cockpitReports.state = window.cockpitReports.state || {};
}
const st = document.querySelector('#reports-tabs [data-tab="hardware"]');
st?.click();
// set device after tab loads
setTimeout(() => {
const btn = document.querySelector(`[data-hw-id="${deviceId}"]`);
btn?.click();
}, 400);
}, 200);
});
} catch (_) {
/* ignore */
}
}
async function loadWarrantyCompliance(deviceId) {
const mount = $("#warranty-compliance-mount");
if (!mount) return;
try {
const [wRes, cRes] = await Promise.all([
fetch(`/api/devices/${deviceId}/warranty`),
fetch(`/api/devices/${deviceId}/compliance`),
]);
const w = wRes.ok ? await wRes.json() : { items: [] };
const c = cRes.ok ? await cRes.json() : { components: [] };
const primary = w.primary;
const comps = (c.components || []).filter(
(x) =>
String(x.update_action || "").toUpperCase() === "UPGRADE" ||
String(x.compliance_status || "").toUpperCase() === "CRITICAL"
);
let html = `<div class="inv-section"><h3>Warranty</h3>`;
if (primary) {
html += `<div class="inv-line">ST ${escapeHtml(primary.service_tag || "—")} · ${escapeHtml(primary.service_level || "—")}</div>
<div class="inv-line">Ends ${escapeHtml(primary.end_date || "—")} · ${primary.days_remaining ?? "—"} days left</div>`;
} else if ((w.items || []).length) {
html += (w.items || [])
.slice(0, 3)
.map(
(x) =>
`<div class="inv-line">${escapeHtml(x.service_level || "—")} · ends ${escapeHtml(x.end_date || "—")} · ${x.days_remaining ?? "—"}d</div>`
)
.join("");
} else {
html += `<div class="inv-line">No warranty records in OME</div>`;
}
html += `</div><div class="inv-section"><h3>Dell catalog compliance</h3>`;
if (c.device) {
html += `<div class="inv-line">${escapeHtml(c.device.compliance_status || c.device.firmware_status || "—")} · baseline ${escapeHtml((c.baseline || {}).name || "—")}</div>`;
}
if (comps.length) {
html += comps
.slice(0, 8)
.map(
(x) =>
`<div class="inv-line">${escapeHtml(x.component || "component")}: ${escapeHtml(x.current_version || "?")}${escapeHtml(x.catalog_version || "?")} ${x.dell_uri ? `<a href="${escapeAttr(x.dell_uri)}" target="_blank" rel="noopener">dell.com</a>` : ""}</div>`
)
.join("");
} else if (c.device) {
html += `<div class="inv-line">No upgrade components in baseline report</div>`;
} else {
html += `<div class="inv-line">Not in active Dell baseline report</div>`;
}
html += `</div>`;
mount.innerHTML = html;
} catch (err) {
mount.innerHTML = `<p class="hint">Warranty/compliance unavailable</p>`;
}
}
function setInvOpen(on) {
document.body.classList.toggle("inv-open", !!on);
}
function wrapInvHtml(html) {
return `<div class="inv-panel">
<div class="inv-panel-head">
<strong>Full inventory</strong>
<span>Theme toggle parked left while reading</span>
</div>
${html}
</div>`;
}
async function showInspector(node) {
state.selectedId = node?.id ?? null;
setInvOpen(false);
const empty = $("#inspector-empty");
const body = $("#inspector-body");
if (!node) {
empty.classList.remove("hidden");
body.classList.add("hidden");
return;
}
empty.classList.add("hidden");
body.classList.remove("hidden");
body.innerHTML = `
<div class="insp-head">
<h2>${escapeHtml(node.name)}</h2>
<p class="meta">${escapeHtml(node.model || "—")} · id ${node.id}</p>
</div>
<div class="badge-row">
<span class="badge ${node.connected ? "on" : "off"}">${node.connected ? "CONNECTED" : "OFFLINE"}</span>
<span class="badge ${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED ON" : "POWER OFF / N/A"}</span>
${node.watts != null ? `<span class="badge power">${Math.round(node.watts)} W</span>` : ""}
${node.is_idrac ? `<span class="badge">iDRAC</span>` : ""}
${node.is_server ? `<span class="badge">SERVER</span>` : ""}
</div>
<div class="kv">
<div class="kv-row st-row"><span class="k">Service Tag</span><span class="v st-value"><code>${escapeHtml(node.service_tag || "—")}</code>
${node.service_tag ? `<button type="button" class="btn ghost compact" id="btn-copy-st" title="Copy Service Tag">Copy</button>` : ""}
</span></div>
<div class="kv-row"><span class="k">IP</span><span class="v">${escapeHtml(node.ip || "—")}</span></div>
<div class="kv-row"><span class="k">Subnet</span><span class="v"><button type="button" class="linkish" data-jump-subnet="${escapeAttr(node.subnet)}">${escapeHtml(node.subnet)}</button></span></div>
<div class="kv-row"><span class="k">Model</span><span class="v">${escapeHtml(node.model || "—")}</span></div>
<div class="kv-row"><span class="k">Status</span><span class="v">${escapeHtml(node.status)}</span></div>
<div class="kv-row"><span class="k">Avg W</span><span class="v">${node.avg_watts != null ? Math.round(node.avg_watts) + " W" : "—"}</span></div>
<div class="kv-row"><span class="k">Peak W</span><span class="v">${node.peak_watts != null ? Math.round(node.peak_watts) + " W" : "—"}</span></div>
<div class="kv-row"><span class="k">Energy</span><span class="v">${node.energy_kwh != null ? node.energy_kwh + " kWh" : "—"}</span></div>
<div class="kv-row"><span class="k">OME id</span><span class="v">${node.id}</span></div>
<div class="kv-row"><span class="k">Last status</span><span class="v">${escapeHtml(node.last_status_time || "—")}</span></div>
<div class="kv-row"><span class="k">Inventory</span><span class="v">${escapeHtml(node.last_inventory_time || "—")}</span></div>
</div>
<div id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty &amp; Dell compliance…</p></div>
<div class="action-stack">
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${escapeHtml(node.ip)}</button>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-rdp-term">RDP popout · ${escapeHtml(node.rdp_host || node.os_hostname || node.ip)}</button>` : ""}
<button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
<button type="button" class="btn" id="btn-ask-ai">Ask OpenManage AI</button>
<button type="button" class="btn ghost" id="btn-focus-model">Filter this model</button>
</div>
<div id="inv-mount"></div>
`;
body.querySelector("[data-jump-subnet]")?.addEventListener("click", (e) => {
state.subnet = e.currentTarget.dataset.jumpSubnet;
refreshLists();
layout();
});
$("#btn-focus-model")?.addEventListener("click", () => {
state.model = node.model;
refreshLists();
layout();
});
$("#btn-copy-st")?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(node.service_tag);
const btn = $("#btn-copy-st");
if (btn) {
btn.textContent = "Copied";
setTimeout(() => {
btn.textContent = "Copy";
}, 1200);
}
} catch (_) {
/* ignore */
}
});
updateFocusContext();
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
$("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node));
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
loadWarrantyCompliance(node.id);
loadExpansionPreview(node.id);
const cachedInv = state.inventoryCache[node.id];
if (cachedInv) {
const mount = $("#inv-mount");
if (mount) {
mount.innerHTML = cachedInv.includes("inv-panel") ? cachedInv : wrapInvHtml(cachedInv);
setInvOpen(true);
}
}
$("#btn-detail")?.addEventListener("click", async () => {
const mount = $("#inv-mount");
const deviceId = node.id;
state.inventoryLoadingId = deviceId;
setInvOpen(true);
mount.innerHTML = `<div class="inv-panel"><p class="hint">Loading full inventory + application landscape…</p></div>`;
try {
const r = await fetch(`/api/devices/${deviceId}`);
const detail = await r.json();
const inv = detail.inventory || {};
const land = detail.landscape || {};
const p = detail.power || {};
const line = (s) => `<div class="inv-line">${escapeHtml(s)}</div>`;
const section = (title, rows) => {
if (!rows || !rows.length) return "";
return `<div class="inv-section"><h3>${escapeHtml(title)} (${rows.length})</h3>${rows.join("")}</div>`;
};
let html = `<div class="inv-section"><h3>Power (live)</h3>
<div class="inv-line">Instant ${p.watts ?? "—"} W · avg ${p.avg_watts ?? "—"} · peak ${p.peak_watts ?? "—"}</div>
<div class="inv-line">Energy ${p.energy_kwh ?? "—"} kWh</div></div>`;
// Application / firmware landscape first
const osRows = (land.os || []).map((x) =>
line(`${x.OsName || x.OperatingSystemName || "OS"} · ${x.OsVersion || ""} · host ${x.Hostname || "—"}`)
);
html += section("Operating system", osRows);
const soft = (items, title) => {
const rows = (items || []).slice(0, 40).map((x) =>
line(
`${x.DeviceDescription || x.SoftwareType || x.Name || "component"} · v${x.Version || "?"} · ${x.Status || ""} · ${x.InstallationDate || ""}`.trim()
)
);
return section(title, rows);
};
html += soft(land.firmware, "Firmware landscape");
html += soft(land.drivers, "Drivers");
html += soft(land.applications, "Software / apps");
if (!(land.firmware || []).length && (land.software || []).length) {
html += soft(land.software, "Software inventory");
}
const mgmtRows = (land.management || []).map((x) => {
const agents = (x.EndPointAgents || [])
.map((a) => a.AgentName || a.ManagementProfile || a.AgentType || "")
.filter(Boolean)
.join(", ");
return line(
`${x.DnsName || x.InstrumentationName || "mgmt"} · ${x.IpAddress || ""} · MAC ${x.MacAddress || "—"}` +
(agents ? ` · agents: ${agents}` : "")
);
});
html += section("Management / agents", mgmtRows);
const licRows = (land.licenses || []).slice(0, 20).map((x) =>
line(`${x.LicenseDescription || x.EntitlementId || x.LicenseType || "license"} · ${x.LicenseStatus || x.Status || ""}`)
);
html += section("Licenses", licRows);
const sections = [
["serverProcessors", "CPUs", (x) => `${x.ModelName || x.BrandName || x.Manufacturer || "CPU"} · ${x.CurrentSpeed || ""} MHz · ${x.NumberOfCores || "?"} cores · ${x.Status || ""}`],
["serverMemoryDevices", "Memory", (x) => `${x.Name || "DIMM"} · ${x.Size || "?"} · ${x.Speed || x.CurrentOperatingSpeed || ""} · ${x.Manufacturer || ""} · ${x.SerialNumber || ""}`],
["serverArrayDisks", "Disks", (x) => `${x.ModelNumber || x.SerialNumber || "disk"} · ${x.MediaType || ""} · ${x.Size || x.Capacity || "?"} · ${x.StatusString || x.Status || ""}`],
["serverRaidControllers", "RAID", (x) => `${x.Name || "RAID"} · FW ${x.FirmwareVersion || "?"} · cache ${x.CacheSizeInMb || "?"} MB · ${x.Status || ""}`],
["serverPowerSupplies", "PSUs", (x) => `${x.Name || x.Model || "PSU"} · ${x.OutputWatts || "?"} W · ${x.FirmwareVersion || ""} · ${x.Status || ""}`],
["serverNetworkInterfaces", "NICs", (x) => {
const ports = (x.Ports || []).map((p) => p.ProductName || p.PermanentMACAddress || p.MacAddress || "").filter(Boolean).slice(0, 4).join(" | ");
return `${x.ProductName || x.VendorName || "NIC"} · ${ports || x.PermanentMACAddress || ""}`;
}],
["serverFcCards", "FC HBAs", (x) => `${x.ProductName || x.VendorName || "FC"} · ${x.WWN || x.PortName || ""}`],
["serverDellVideos", "GPUs / video", (x) => `${x.ProductName || x.Description || "video"} · ${x.Manufacturer || ""}`],
["serverDeviceCards", "Device cards", (x) => `${x.ProductName || x.Description || x.SlotName || "card"} · ${x.Manufacturer || ""}`],
["deviceBaseboards", "Baseboard", (x) => `${x.Manufacturer || ""} · ${x.ProductName || x.Model || ""} · SN ${x.SerialNumber || "—"}`],
["deviceFru", "FRU", (x) => `${x.Name || "FRU"} · ${x.Manufacturer || ""} · PN ${x.PartNumber || ""} · SN ${x.SerialNumber || ""}`],
["deviceLocation", "Location", (x) => `${x.Datacenter || ""} ${x.Room || ""} ${x.Aisle || ""} ${x.Rack || ""} U${x.Rackslot || x.RackSlot || ""}`.trim() || JSON.stringify(x).slice(0, 80)],
["subsystemRollupStatus", "Health rollup", (x) => `${x.SubsystemName || x.Type || "sub"} · ${x.Status || ""}`],
["serverStorageEnclosures", "Storage enclosures", (x) => `${x.Name || x.ProductName || "enclosure"} · ${x.Status || ""}`],
["serverVirtualFlashes", "Virtual flash", (x) => `${x.Name || "vFlash"} · ${x.Capacity || x.Size || ""} · ${x.Status || ""}`],
["serverBiosSystemProfileSettings", "BIOS profile", (x) => Object.entries(x).slice(0, 6).map(([k, v]) => `${k}=${v}`).join(" · ")],
["deviceCapabilities", "Capabilities", (x) => `CapabilityType ${x.CapabilityType ?? x.Id ?? "?"}`],
["serverSupportedPowerStates", "Power states", (x) => `PowerState ${x.PowerState ?? x.Id ?? "?"}`],
];
for (const [key, title, fmt] of sections) {
const rows = inv[key] || [];
if (!rows.length) continue;
html += section(
title,
rows.slice(0, 24).map((row) => line(fmt(row)))
);
}
// Any remaining inventory keys not already shown
const shown = new Set(sections.map((s) => s[0]).concat([
"deviceSoftware", "serverOperatingSystems", "deviceManagement", "deviceLicense",
]));
for (const [key, rows] of Object.entries(inv)) {
if (shown.has(key) || !Array.isArray(rows) || !rows.length) continue;
html += section(
key,
rows.slice(0, 12).map((row) =>
line(
typeof row === "object"
? Object.entries(row).slice(0, 6).map(([k, v]) => `${k}=${v}`).join(" · ")
: String(row)
)
)
);
}
html = wrapInvHtml(html || `<p class="hint">No inventory available</p>`);
state.inventoryCache[deviceId] = html;
if (state.selectedId === deviceId) {
const live = $("#inv-mount");
if (live) live.innerHTML = html;
setInvOpen(true);
}
} catch (e) {
const errHtml = wrapInvHtml(`<p class="hint">Inventory failed: ${escapeHtml(e.message)}</p>`);
if (state.selectedId === deviceId) {
const live = $("#inv-mount");
if (live) live.innerHTML = errHtml;
setInvOpen(true);
}
} finally {
if (state.inventoryLoadingId === deviceId) state.inventoryLoadingId = null;
}
});
}
function showHubInspector() {
const ome = state.data?.ome || {};
const s = state.data?.summary || {};
state.selectedId = null;
setInvOpen(false);
$("#inspector-empty").classList.add("hidden");
const body = $("#inspector-body");
body.classList.remove("hidden");
body.innerHTML = `
<div class="insp-head">
<h2>${escapeHtml(ome.name || "OpenManage Enterprise")}</h2>
<p class="meta">v${escapeHtml(ome.version || "?")} · build ${escapeHtml(String(ome.build || "?"))}</p>
</div>
<div class="badge-row">
<span class="badge on">HUB</span>
<span class="badge power">${s.total_watts != null ? Math.round(s.total_watts) + " W fleet" : "power…"}</span>
</div>
<div class="kv">
<div class="kv-row"><span class="k">FQDN</span><span class="v">${escapeHtml(ome.fqdn || "—")}</span></div>
<div class="kv-row"><span class="k">API</span><span class="v">${escapeHtml(ome.url || "—")}</span></div>
<div class="kv-row"><span class="k">Servers</span><span class="v">${s.servers ?? "—"}</span></div>
<div class="kv-row"><span class="k">Connected</span><span class="v">${s.connected ?? "—"}</span></div>
<div class="kv-row"><span class="k">Power samples</span><span class="v">${s.power_samples ?? "—"}</span></div>
</div>
<div class="action-stack">
<a class="btn primary" href="${escapeAttr(ome.console_url || ome.url || "#")}" target="_blank" rel="noopener">Open OME console</a>
<button type="button" class="btn" id="btn-ai-hub">Ask OpenManage AI about fleet</button>
</div>`;
$("#btn-ai-hub")?.addEventListener("click", () => openAi(null));
}
function openAi(node) {
const url = state.data?.openwebui_url || "http://atc-portal01.dell-atc.lan:3080";
$("#ai-frame").src = url;
$("#ai-drawer").classList.add("open");
$("#ai-drawer").setAttribute("aria-hidden", "false");
const scrim = $("#scrim");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "ai";
if (node) {
console.info("Ask AI about", node.name, node.service_tag, node.watts);
}
}
function closeAi() {
$("#ai-drawer").classList.remove("open");
$("#ai-drawer").setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "ai" || !scrim?.dataset.mode) {
scrim?.classList.remove("open");
if (scrim) delete scrim.dataset.mode;
}
}
function refreshLists() {
renderKpis();
renderSubnets();
renderModels();
renderGroups();
renderLegend();
renderTicker();
renderContext();
const ome = state.data?.ome;
if (ome) {
$("#ome-meta").textContent = `OM Enterprise ${ome.version || "?"} · build ${ome.build || "?"} · live`;
}
}
function applySnapshot(data) {
state.data = data;
handleFleetNotifications(data);
try {
window.dispatchEvent(new CustomEvent("cockpit-snapshot", { detail: data }));
if (data.gpu) window.dispatchEvent(new CustomEvent("cockpit-gpu", { detail: data.gpu }));
} catch (_) {}
// warm color maps
for (const d of data.devices || []) {
colorFor(state.modelColors, d.model || "?");
colorFor(state.subnetColors, d.subnet || "?");
}
refreshLists();
layout();
if (state.selectedId) {
const n = (data.devices || []).find((x) => x.id === state.selectedId);
if (n) softRefreshInspector(n);
}
}
// events
$("#layout-modes")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-layout]");
if (!btn) return;
state.layoutMode = btn.dataset.layout;
$("#layout-modes").querySelectorAll(".viz-card").forEach((c) => c.classList.toggle("active", c === btn));
layout();
snapNodes();
state.cam = { x: 0, y: 0, scale: 1 };
fitCameraToNodes();
updateFocusContext();
});
$("#view-modes").addEventListener("click", (e) => {
const btn = e.target.closest("[data-mode]");
if (!btn) return;
state.viewMode = btn.dataset.mode;
$("#view-modes").querySelectorAll(".chip").forEach((c) => c.classList.toggle("active", c === btn));
renderLegend();
layout();
});
$("#subnet-list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-subnet]");
if (!btn) return;
state.subnet = btn.dataset.subnet;
state.kpiFocus = null;
refreshLists();
layout();
});
$("#model-list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-model]");
if (!btn) return;
state.model = btn.dataset.model || null;
refreshLists();
layout();
});
$("#group-list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-group]");
if (!btn) return;
state.filters.search = btn.dataset.group || "";
$("#search").value = state.filters.search;
state.groupHint = btn.dataset.group;
showInspector({
id: null,
name: `Group: ${btn.dataset.group}`,
model: "OME Group",
service_tag: "—",
connected: true,
powered_on: false,
watts: null,
avg_watts: null,
peak_watts: null,
energy_kwh: null,
ip: null,
subnet: "—",
status: "group",
is_idrac: false,
is_server: false,
idrac_url: null,
});
// note: OME groups aren't device-membership expanded here — search/filter by name hint
layout();
});
$("#kpi-strip").addEventListener("click", (e) => {
const btn = e.target.closest("[data-kpi]");
if (!btn) return;
const key = btn.dataset.kpi;
openKpiPopup(key);
// Keep strip highlight in sync with open context
state.kpiFocus = key;
renderKpis();
});
$("#btn-kpi-close")?.addEventListener("click", closeKpiPopup);
$("#kpi-search")?.addEventListener("input", (e) => {
kpiPopup.q = e.target.value || "";
renderKpiPopup();
});
$("#kpi-sort")?.addEventListener("change", (e) => {
kpiPopup.sort = e.target.value || "name";
renderKpiPopup();
});
$("#btn-kpi-apply")?.addEventListener("click", () => {
if (kpiPopup.key) applyKpiAsFilter(kpiPopup.key);
});
$("#btn-kpi-clear")?.addEventListener("click", () => {
state.kpiFocus = null;
state.filters.connected = false;
state.filters.powered = false;
state.filters.hasPower = false;
state.filters.servers = true;
state.filters.idrac = false;
syncFilterInputs();
refreshLists();
layout();
snapNodes();
fitCameraToNodes();
updateFocusContext();
renderKpis();
showToast("Map KPI filter cleared");
});
$("#kpi-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-kpi-device]");
if (!btn) return;
kpiPopup.selectedId = Number(btn.dataset.kpiDevice);
renderKpiPopup();
});
$("#kpi-detail")?.addEventListener("click", (e) => {
const act = e.target.closest("[data-kpi-act]");
if (!act) return;
const node = (state.data?.devices || []).find((d) => d.id === kpiPopup.selectedId);
if (!node) return;
const a = act.dataset.kpiAct;
if (a === "focus") {
closeKpiPopup();
focusDeviceId(node.id);
showToast("Focused " + (node.name || ""));
} else if (a === "ssh") {
window.cockpitSsh?.open(node);
} else if (a === "rdp") {
window.cockpitRdp?.open(node);
} else if (a === "connect") {
closeKpiPopup();
openConnect(node);
} else if (a === "inventory") {
closeKpiPopup();
showInspector(node);
setTimeout(() => $("#btn-detail")?.click(), 60);
} else if (a === "chat") {
closeKpiPopup();
document.getElementById("btn-chat")?.click();
setTimeout(() => {
const input = document.getElementById("chat-input");
if (input) {
input.value = `Give operational context for ${node.name} (${node.ip || "no IP"}). Status ${node.status}, connected=${node.connected}, watts=${node.watts ?? "n/a"}. Suggest next actions for ATC admins.`;
input.focus();
}
}, 150);
}
});
$("#filters").addEventListener("change", (e) => {
const t = e.target;
readFiltersFromDom();
if (t.id === "f-min-watts") {
$("#min-w-label").textContent = String(state.filters.minWatts);
}
if (t.id === "f-pulse-speed") {
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`;
showToast(`Pulse speed ${Number(state.filters.pulseSpeed).toFixed(2)}×`);
return;
}
// Pulse-only toggle: no need to relayout
if (t.id === "f-pulse-links") {
updateFocusContext();
showToast(state.filters.pulseLinks ? "Link pulse on" : "Link pulse off");
return;
}
layout();
snapNodes();
fitCameraToNodes();
renderTicker();
updateFocusContext();
if (!state.filters.servers && !state.filters.idrac) {
showToast("Enable Servers and/or iDRACs to show nodes");
}
});
$("#filters").addEventListener("input", (e) => {
if (e.target?.id !== "f-pulse-speed") return;
readFiltersFromDom();
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`;
});
function togglePulsePop(force) {
const pop = $("#pulse-pop");
const btn = $("#btn-pulse-settings");
const block = document.querySelector('.rail-block[data-module="filters"]');
if (!pop || !btn) return;
const open = force != null ? !!force : pop.hidden;
pop.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
block?.classList.toggle("pulse-open", open);
if (open) {
const ps = $("#f-pulse-speed");
if (ps) ps.value = String(state.filters.pulseSpeed ?? 0.45);
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed ?? 0.45).toFixed(2)}×`;
const line = $("#f-pulse-line-color");
const glow = $("#f-pulse-glow-color");
const packet = $("#f-pulse-packet-color");
if (line) line.value = state.filters.pulseLineColor || "#3dffe0";
if (glow) glow.value = state.filters.pulseGlowColor || "#00a8e8";
if (packet) packet.value = state.filters.pulsePacketColor || "#ffffff";
}
}
$("#btn-pulse-settings")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
togglePulsePop();
});
const PULSE_THEMES = {
cyan: { line: "#3dffe0", glow: "#00a8e8", packet: "#ffffff" },
orange: { line: "#ff9a3c", glow: "#ffb040", packet: "#fff4e5" },
green: { line: "#3dffa0", glow: "#1b7a4a", packet: "#e8fff4" },
purple: { line: "#c4a7ff", glow: "#7b6cf0", packet: "#f4efff" },
pink: { line: "#f472b6", glow: "#db2777", packet: "#fff0f7" },
gold: { line: "#f0e68c", glow: "#d4af37", packet: "#fffceb" },
};
$("#pulse-pop")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-pulse-speed]");
if (chip) {
const v = Number(chip.dataset.pulseSpeed);
state.filters.pulseSpeed = v;
const ps = $("#f-pulse-speed");
if (ps) ps.value = String(v);
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${v.toFixed(2)}×`;
savePulsePrefs();
showToast(`Pulse speed ${v.toFixed(2)}×`);
return;
}
const sw = e.target.closest("[data-pulse-theme]");
if (sw) {
const theme = PULSE_THEMES[sw.dataset.pulseTheme];
if (!theme) return;
state.filters.pulseLineColor = theme.line;
state.filters.pulseGlowColor = theme.glow;
state.filters.pulsePacketColor = theme.packet;
const line = $("#f-pulse-line-color");
const glow = $("#f-pulse-glow-color");
const packet = $("#f-pulse-packet-color");
if (line) line.value = theme.line;
if (glow) glow.value = theme.glow;
if (packet) packet.value = theme.packet;
savePulsePrefs();
showToast(`Pulse color: ${sw.dataset.pulseTheme}`);
}
});
$("#pulse-pop")?.addEventListener("input", (e) => {
if (!e.target?.matches?.('input[type="color"]')) return;
readFiltersFromDom();
});
document.addEventListener("click", (e) => {
const pop = $("#pulse-pop");
if (!pop || pop.hidden) return;
if (e.target.closest("#pulse-pop") || e.target.closest("#btn-pulse-settings")) return;
togglePulsePop(false);
});
$("#search").addEventListener("input", (e) => {
state.filters.search = e.target.value;
layout();
renderTicker();
});
$("#ticker").addEventListener("click", (e) => {
const btn = e.target.closest("[data-tick]");
if (!btn) return;
const k = btn.dataset.tick;
if (k === "ome") showHubInspector();
if (k === "power") {
state.viewMode = "power";
state.kpiFocus = "power";
state.filters.hasPower = true;
$("#f-has-power").checked = true;
refreshLists();
layout();
}
if (k === "connected") {
state.kpiFocus = "connected";
state.filters.connected = true;
$("#f-connected").checked = true;
refreshLists();
layout();
}
if (k === "alerts") {
if (window.cockpit?.openTriage) window.cockpit.openTriage("critical");
else updateFocusContext();
}
});
canvas.addEventListener("mousedown", (e) => {
state.dragging = true;
state.last = { x: e.clientX, y: e.clientY };
});
window.addEventListener("mouseup", () => {
state.dragging = false;
});
canvas.addEventListener("mousemove", (e) => {
if (state.dragging) {
state.cam.x += e.clientX - state.last.x;
state.cam.y += e.clientY - state.last.y;
state.last = { x: e.clientX, y: e.clientY };
hideTip();
return;
}
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit?.type === "node") showTip(hit.node, e.clientX, e.clientY);
else hideTip();
});
canvas.addEventListener("mouseleave", hideTip);
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = e.deltaY > 0 ? 0.9 : 1.1;
state.cam.scale = Math.min(3.5, Math.max(0.35, state.cam.scale * factor));
const after = screenToWorld(mx, my);
state.cam.x += (after.x - before.x) * state.cam.scale;
state.cam.y += (after.y - before.y) * state.cam.scale;
}, { passive: false });
canvas.addEventListener("click", (e) => {
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (!hit) {
showInspector(null);
hideTip();
return;
}
if (hit.type === "hub") {
showHubInspector();
return;
}
showInspector(hit.node);
});
canvas.addEventListener("dblclick", (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
if (hit?.type === "node") {
showInspector(hit.node);
openConnect(hit.node);
} else if (hit?.type === "hub") {
showHubInspector();
}
});
// Shared API for Ops desk / triage popup
window.cockpit = {
getState: () => state,
focusDeviceId,
openConnect,
showInspector,
openAi,
relTime,
escapeHtml,
openTriage: null,
openKpiPopup,
closeKpiPopup,
};
// Realtime context interactivity
$("#ctx-stats")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-ctx]");
if (!btn) return;
if (window.cockpit.openTriage) window.cockpit.openTriage(btn.dataset.ctx);
});
$("#alert-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-alert-device]");
if (!btn) return;
if (window.cockpit.openTriage) {
window.cockpit.openTriage("all", {
alertId: btn.dataset.alertId,
deviceId: btn.dataset.alertDevice,
});
} else {
focusDeviceId(btn.dataset.alertDevice);
}
});
$("#ctx-feed-body")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-feed-device]");
if (!btn) return;
if (window.cockpit.openTriage) {
window.cockpit.openTriage("all", { deviceId: btn.dataset.feedDevice });
} else {
focusDeviceId(btn.dataset.feedDevice);
}
});
function applyTheme(theme) {
const t = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
try {
localStorage.setItem("cockpit_theme", t);
} catch (_) {}
const btn = $("#btn-theme");
if (btn) {
btn.dataset.theme = t;
btn.title = t === "light" ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute("aria-label", btn.title);
}
}
applyTheme(localStorage.getItem("cockpit_theme") || "light");
$("#btn-theme")?.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "light";
applyTheme(cur === "light" ? "dark" : "light");
});
$("#btn-reset-view").addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
resetView();
});
$("#btn-clear-filters")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
clearFilters();
});
$("#btn-ai")?.addEventListener("click", () => openAi(null));
$("#btn-ai-close").addEventListener("click", closeAi);
$("#btn-connect-close")?.addEventListener("click", closeConnect);
$("#scrim").addEventListener("click", () => {
const mode = $("#scrim")?.dataset.mode;
if (mode === "triage") document.getElementById("btn-triage-close")?.click();
else if (mode === "kpi") closeKpiPopup();
else if (mode === "connect") closeConnect();
else if (
mode === "chat-drawer" ||
mode === "ops-drawer" ||
mode === "ai-drawer" ||
mode === "reports-drawer" ||
mode === "an-context" ||
mode === "network-drawer"
) {
/* reports.js / ops.js / network.js also listen */
} else closeAi();
});
$("#btn-ome-console").addEventListener("click", () => {
const url = state.data?.ome?.console_url || state.data?.ome?.url;
if (url) window.open(url, "_blank", "noopener");
});
// collapsible left-rail modules
$("#left-rail")?.addEventListener("click", (e) => {
const btn = e.target.closest(".rail-toggle");
if (!btn) return;
const block = btn.closest(".rail-block");
if (!block) return;
const open = block.classList.toggle("open");
btn.setAttribute("aria-expanded", open ? "true" : "false");
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#an-context-modal")?.classList.contains("hidden")) {
window.cockpitReports?.closeAnContext?.();
return;
}
closeConnect();
closeAi();
closeKpiPopup();
hideTip();
document.getElementById("btn-triage-close")?.click();
}
});
window.addEventListener("resize", resize);
window.addEventListener("cockpit-feed-expand", () => {
try { renderContext(); } catch (_) {}
});
async function boot() {
try {
const r = await fetch("/api/fleet");
applySnapshot(await r.json());
} catch (e) {
console.error(e);
}
connectWs();
}
function connectWs() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/fleet`);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "snapshot" && msg.data) applySnapshot(msg.data);
if (msg.type === "gpu" && msg.data) {
if (state.data) state.data.gpu = msg.data;
window.dispatchEvent(new CustomEvent("cockpit-gpu", { detail: msg.data }));
}
} catch (_) {}
};
ws.onclose = () => setTimeout(connectWs, 2500);
}
// Keep JS filter state in sync with checkbox defaults in HTML
try {
const saved = JSON.parse(localStorage.getItem("cockpit_pulse_prefs") || "null");
if (saved && typeof saved === "object") {
if (Number.isFinite(Number(saved.pulseSpeed)) && saved.pulseSpeed > 0) {
state.filters.pulseSpeed = Number(saved.pulseSpeed);
}
if (saved.pulseLineColor) state.filters.pulseLineColor = saved.pulseLineColor;
if (saved.pulseGlowColor) state.filters.pulseGlowColor = saved.pulseGlowColor;
if (saved.pulsePacketColor) state.filters.pulsePacketColor = saved.pulsePacketColor;
} else {
const savedSpd = Number(localStorage.getItem("cockpit_pulse_speed"));
if (Number.isFinite(savedSpd) && savedSpd > 0) state.filters.pulseSpeed = savedSpd;
}
} catch (_) {}
readFiltersFromDom();
syncFilterInputs();
resize();
requestAnimationFrame(draw);
boot();
// style for inline link buttons in inspector
const style = document.createElement("style");
style.textContent = `.linkish{appearance:none;border:none;background:none;color:var(--dell-bright);font:inherit;font-family:var(--mono);padding:0;cursor:pointer;text-align:left}.linkish:hover{text-decoration:underline;color:#fff}`;
document.head.appendChild(style);
})();