b73ff9465c
Interactive OME fleet UI with topology, KPI popups, in-browser SSH, chat, tickets, and full inventory landscape.
2254 lines
82 KiB
JavaScript
2254 lines
82 KiB
JavaScript
(() => {
|
||
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">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"> <200W</span>
|
||
<span style="--c:#00a8e8">200–450W</span>
|
||
<span style="--c:#ffb020">450–650W</span>
|
||
<span style="--c:#ff5c5c">>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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
function escapeAttr(s) {
|
||
return escapeHtml(s).replace(/'/g, "'");
|
||
}
|
||
|
||
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 || "—")}<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 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 || "—")} · ${escapeHtml(node.service_tag || "no tag")}</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"><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">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 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();
|
||
});
|
||
updateFocusContext();
|
||
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
|
||
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
|
||
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
|
||
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);
|
||
})();
|