Files
ome-cockpit/ui/app.js
T
mo 115b6e39aa Add Reports drawer, Dell catalog compliance, and Service Tag chat context.
Exposes OME warranties, baselines, report defs, and CSV/JSON exports for customer-ready fleet briefs.
2026-07-17 15:15:57 +02:00

2330 lines
86 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: [],
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,
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" && n.subnet !== 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 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 = [];
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;
nodes.push({
...n,
tx: cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35,
ty: cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35,
r: nodeRadius(n),
depth: 0.6 + t * 0.8,
arm: si,
});
});
});
state.clusterHubs = [];
state.laneGuides = [];
setTargets(nodes);
}
function layoutClusters(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const hubs = [];
const R = Math.min(w, h) * 0.32;
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;
hubs.push({ x: hx, y: hy, label: g.cidr, color: colorFor(state.subnetColors, g.cidr) });
const localR = 28 + Math.min(90, g.members.length * 4.5);
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 = [];
setTargets(nodes);
}
function layoutLanes(devices, cx, cy, w, h) {
const groups = groupBySubnet(devices);
const nodes = [];
const guides = [];
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);
guides.push({ y, label: g.cidr, color: colorFor(state.subnetColors, g.cidr) });
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;
// 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;
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;
// perspective: scale by "depth"
const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(angle));
const x = cx + Math.cos(angle) * amp * depth;
nodes.push({
...n,
tx: x,
ty: y + Math.sin(angle * 2) * 8,
r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55),
depth,
helixT: t,
});
});
state.clusterHubs = [];
state.laneGuides = [];
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 "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();
ctx.strokeStyle = "#3dffe0";
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "#fff";
ctx.font = "700 11px IBM Plex Sans, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("OME", hub.x, hub.y);
}
function drawPulseLink(x0, y0, x1, y1, connected, t, id, alphaScale) {
if (!connected && !state.filters.ghostLinks) return;
const animate = state.filters.pulseLinks !== false;
const alpha = connected
? (animate ? (0.55 + 0.35 * Math.sin(t * 0.05 + 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(0,168,232,${(animate ? 0.22 : 0.14) * alphaScale})`;
ctx.lineWidth = animate ? 4.5 : 3.2;
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.strokeStyle = connected ? `rgba(61,255,224,${alpha})` : `rgba(90,106,122,${0.14 * 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 + (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 = "rgba(255,255,255,0.95)";
ctx.fill();
ctx.beginPath();
ctx.arc(px, py, 5.5, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(61,255,224,0.55)";
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 = `rgba(0,168,232,${0.035 + i * 0.012})`;
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 = "rgba(0,168,232,0.08)";
ctx.lineWidth = 1.2;
ctx.stroke();
}
} else if (mode === "clusters") {
for (const ch of state.clusterHubs || []) {
ctx.beginPath();
ctx.arc(ch.x, ch.y, 52, 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;
ctx.fillStyle = "rgba(232,244,255,0.7)";
ctx.font = "500 9px IBM Plex Mono, monospace";
ctx.textAlign = "center";
ctx.fillText((ch.label || "").slice(0, 18), ch.x, ch.y - 18);
drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55);
}
} 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();
ctx.fillStyle = "rgba(232,244,255,0.55)";
ctx.font = "500 9px IBM Plex Mono, monospace";
ctx.textAlign = "left";
ctx.fillText((g.label || "").slice(0, 16), 74, g.y - 8);
}
} 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 = `rgba(0,168,232,${0.06 + i * 0.03})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
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 === "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 = "#fff";
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) {
ctx.fillStyle = "rgba(232,244,255,0.85)";
ctx.font = "500 9px IBM Plex Mono, monospace";
ctx.textAlign = "center";
ctx.textBaseline = "top";
const label =
state.viewMode === "power" && n.watts != null
? `${Math.round(n.watts)}W`
: (n.name || "").slice(0, 18);
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);
ctx.restore();
if (!state.nodes.length) {
ctx.fillStyle = "rgba(232,244,255,0.82)";
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 = "rgba(122,147,168,0.95)";
ctx.fillText("Adjust Filters or click Reset filters", w / 2, h / 2 + 28);
}
if (state.filters.pulseLinks !== false) state.pulseT += 1;
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" 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) => `<button type="button" class="card${state.subnet === s.cidr ? " active" : ""}" data-subnet="${s.cidr}">
<span class="t">${s.cidr}</span>
<span class="s">${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);
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-min-watts", f.minWatts || 0);
const lab = $("#min-w-label");
if (lab) lab.textContent = String(f.minWatts || 0);
const search = $("#search");
if (search) search.value = f.search || "";
}
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 mw = document.getElementById("f-min-watts");
state.filters.minWatts = mw ? Number(mw.value) || 0 : 0;
state.filters.search = $("#search")?.value || "";
}
function clearFilters() {
state.filters = {
connected: false,
powered: false,
servers: true,
idrac: false,
hasPower: false,
ghostLinks: false,
pulseLinks: true,
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>` : ""}
<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-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 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>`;
}
}
async function showInspector(node) {
state.selectedId = node?.id ?? null;
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>` : ""}
<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-ask-ai")?.addEventListener("click", () => openAi(node));
loadWarrantyCompliance(node.id);
const cachedInv = state.inventoryCache[node.id];
if (cachedInv) {
const mount = $("#inv-mount");
if (mount) mount.innerHTML = cachedInv;
}
$("#btn-detail")?.addEventListener("click", async () => {
const mount = $("#inv-mount");
const deviceId = node.id;
state.inventoryLoadingId = deviceId;
mount.innerHTML = `<p class="hint">Loading full inventory + application landscape…</p>`;
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 = 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;
}
} catch (e) {
const errHtml = `<p class="hint">Inventory failed: ${escapeHtml(e.message)}</p>`;
if (state.selectedId === deviceId) {
const live = $("#inv-mount");
if (live) live.innerHTML = errHtml;
}
} finally {
if (state.inventoryLoadingId === deviceId) state.inventoryLoadingId = null;
}
});
}
function showHubInspector() {
const ome = state.data?.ome || {};
const s = state.data?.summary || {};
state.selectedId = null;
$("#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 === "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);
}
// 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");
}
});
$("#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);
}
});
$("#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") {
/* ops.js also listens */
} 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") {
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
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);
})();