(() => { const $ = (sel) => document.querySelector(sel); const canvas = $("#topo"); const ctx = canvas.getContext("2d"); const state = { data: null, viewMode: "status", layoutMode: "orbit", clusterHubs: [], laneGuides: [], labelAnchors: [], subnet: "all", model: null, groupHint: null, kpiFocus: null, selectedId: null, inventoryCache: {}, inventoryLoadingId: null, notifiedEventKeys: new Set(), lastPulse: 0, filters: { connected: false, powered: false, servers: true, idrac: false, hasPower: false, ghostLinks: false, pulseLinks: true, pulseSpeed: 0.45, pulseLineColor: "#3dffe0", pulseGlowColor: "#00a8e8", pulsePacketColor: "#ffffff", minWatts: 0, search: "", }, cam: { x: 0, y: 0, scale: 1 }, nodes: [], hub: null, dragging: false, last: null, hoverId: null, connectNode: null, pulseT: 0, modelColors: new Map(), subnetColors: new Map(), }; const PALETTE = [ "#00a8e8", "#3dffe0", "#ffb020", "#7ec8ff", "#3dffa0", "#ff7a59", "#c4a7ff", "#f0e68c", "#5eead4", "#f472b6", ]; function colorFor(map, key) { if (!map.has(key)) map.set(key, PALETTE[map.size % PALETTE.length]); return map.get(key); } function statusColor(status) { const s = String(status || ""); if (s === "1000" || s === "0") return "#3dffa0"; if (s === "2000" || s === "2") return "#ffb020"; if (s === "3000" || s === "3" || s === "4000") return "#ff5c5c"; return "#7a93a8"; } function powerColor(watts) { if (watts == null) return "#3a4a5a"; if (watts < 200) return "#3dffa0"; if (watts < 450) return "#00a8e8"; if (watts < 650) return "#ffb020"; return "#ff5c5c"; } function nodeColor(n) { switch (state.viewMode) { case "power": return powerColor(n.watts); case "connection": return n.connected ? "#3dffe0" : "#ff5c5c"; case "model": return colorFor(state.modelColors, n.model || "?"); case "subnet": return colorFor(state.subnetColors, n.subnet || "?"); default: if (!n.connected) return "#5a6a7a"; return statusColor(n.status); } } function visibleDevices() { const d = state.data; if (!d) return []; const f = state.filters; const q = (f.search || "").trim().toLowerCase(); // Type filters are INCLUDE toggles: // - Servers on => include servers // - iDRACs on => include iDRACs // - both off => show nothing (do not silently show all) const includeServers = !!f.servers; const includeIdrac = !!f.idrac; if (!includeServers && !includeIdrac) return []; return (d.devices || []).filter((n) => { if (state.subnet !== "all") { const cidrs = n.map_cidrs && n.map_cidrs.length ? n.map_cidrs : [n.subnet]; if (!cidrs.includes(state.subnet)) return false; } if (state.model && n.model !== state.model) return false; const isServer = !!n.is_server; const isIdrac = !!n.is_idrac; const typeOk = (includeServers && isServer) || (includeIdrac && isIdrac); if (!typeOk) return false; if (f.connected && !n.connected) return false; if (f.powered && !n.powered_on) return false; if (f.hasPower && n.watts == null) return false; if (f.minWatts > 0 && (n.watts == null || n.watts < f.minWatts)) return false; if (state.kpiFocus === "offline" && (n.connected || !n.is_server)) return false; if (state.kpiFocus === "connected" && !n.connected) return false; if (state.kpiFocus === "powered" && !n.powered_on) return false; if (state.kpiFocus === "idracs" && !n.is_idrac) return false; if (state.kpiFocus === "power" && n.watts == null) return false; if (q) { const hay = [n.name, n.model, n.service_tag, n.ip, n.subnet] .filter(Boolean) .join(" ") .toLowerCase(); if (!hay.includes(q)) return false; } return true; }); } function groupBySubnet(devices) { const bySubnet = new Map(); for (const n of devices) { const k = n.subnet || "unknown"; if (!bySubnet.has(k)) bySubnet.set(k, []); bySubnet.get(k).push(n); } return [...bySubnet.keys()].sort().map((k) => ({ cidr: k, members: bySubnet.get(k) })); } function subnetLabelInfo(cidr) { const meta = (state.data?.subnets || []).find((s) => s.cidr === cidr) || {}; const vlanBit = meta.vlan_id != null ? `VLAN ${meta.vlan_id}` : null; return { cidr, label: vlanBit ? `${vlanBit} · ${cidr}` : cidr, color: meta.vlan_color || colorFor(state.subnetColors, cidr || "?"), }; } function makeLabelAnchor({ x, y, cidr, localR = 28, dirX = null, dirY = null, pipeExtra = 36 }) { const info = subnetLabelInfo(cidr); return { x, y, cidr: info.cidr, label: info.label, color: info.color, localR, pipeExtra, dirX, dirY, }; } function anchorsFromPoints(cidr, points, omeHub, opts = {}) { if (!points.length) return null; let sx = 0; let sy = 0; for (const p of points) { sx += p.x; sy += p.y; } const ax = sx / points.length; const ay = sy / points.length; let localR = 16; for (const p of points) { localR = Math.max(localR, Math.hypot(p.x - ax, p.y - ay)); } let dirX = opts.dirX; let dirY = opts.dirY; if (dirX == null || dirY == null) { if (omeHub) { dirX = ax - omeHub.x; dirY = ay - omeHub.y; } else { dirX = 0; dirY = -1; } } return makeLabelAnchor({ x: ax, y: ay, cidr, localR: localR + (opts.pad || 8), dirX, dirY, pipeExtra: opts.pipeExtra || 40, }); } function nodeRadius(n) { return n.connected ? 8 : 5.5; } function setTargets(nodes) { // preserve current x/y for lerp; first time seed from target const prev = new Map(state.nodes.map((n) => [n.id, n])); for (const n of nodes) { const p = prev.get(n.id); if (p && Number.isFinite(p.x)) { n.x = p.x; n.y = p.y; } else { n.x = n.tx; n.y = n.ty; } } state.nodes = nodes; } function layoutOrbit(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const ringBase = Math.min(w, h) * 0.18; const nodes = []; groups.forEach((g, si) => { const ring = ringBase + si * Math.min(70, Math.max(42, 360 / Math.max(groups.length, 1))); g.members.forEach((n, i) => { const a = (i / Math.max(g.members.length, 1)) * Math.PI * 2 - Math.PI / 2 + si * 0.15; const jitter = (n.id % 7) * 2.2; nodes.push({ ...n, tx: cx + Math.cos(a) * (ring + jitter), ty: cy + Math.sin(a) * (ring + jitter), r: nodeRadius(n), depth: 1, }); }); }); state.clusterHubs = []; state.laneGuides = []; state.labelAnchors = []; setTargets(nodes); } function layoutGalaxy(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const nodes = []; const arms = Math.max(groups.length, 1); const maxR = Math.min(w, h) * 0.42; groups.forEach((g, si) => { const armAngle = (si / arms) * Math.PI * 2; g.members.forEach((n, i) => { const t = (i + 1) / (g.members.length + 1); const r = 70 + t * maxR; const twist = t * 3.2 + armAngle; const wobble = Math.sin(i * 1.7 + si) * 12; const x = cx + Math.cos(twist) * r + Math.cos(twist + Math.PI / 2) * wobble * 0.35; const y = cy + Math.sin(twist) * r + Math.sin(twist + Math.PI / 2) * wobble * 0.35; nodes.push({ ...n, tx: x, ty: y, r: nodeRadius(n), depth: 0.6 + t * 0.8, arm: si, }); }); }); state.clusterHubs = []; state.laneGuides = []; state.labelAnchors = []; setTargets(nodes); } function layoutClusters(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const nodes = []; const hubs = []; const anchors = []; const R = Math.min(w, h) * 0.32; const omeHub = { x: cx, y: cy }; groups.forEach((g, si) => { const a = (si / Math.max(groups.length, 1)) * Math.PI * 2 - Math.PI / 2; const hx = cx + Math.cos(a) * R; const hy = cy + Math.sin(a) * R; const localR = 28 + Math.min(90, g.members.length * 4.5); const info = subnetLabelInfo(g.cidr); hubs.push({ x: hx, y: hy, label: info.label, cidr: g.cidr, color: info.color, localR, angle: a, memberCount: g.members.length, }); anchors.push( makeLabelAnchor({ x: hx, y: hy, cidr: g.cidr, localR, dirX: hx - omeHub.x, dirY: hy - omeHub.y, pipeExtra: 36, }) ); g.members.forEach((n, i) => { const la = (i / Math.max(g.members.length, 1)) * Math.PI * 2; nodes.push({ ...n, tx: hx + Math.cos(la) * localR, ty: hy + Math.sin(la) * localR, r: nodeRadius(n), depth: 1, cluster: si, }); }); }); state.clusterHubs = hubs; state.laneGuides = []; state.labelAnchors = anchors; setTargets(nodes); } function layoutLanes(devices, cx, cy, w, h) { const groups = groupBySubnet(devices); const nodes = []; const guides = []; const anchors = []; const top = 70; const bottom = h - 50; const span = Math.max(bottom - top, 120); groups.forEach((g, si) => { const y = top + (groups.length <= 1 ? span / 2 : (si / (groups.length - 1 || 1)) * span); const info = subnetLabelInfo(g.cidr); guides.push({ y, label: info.label, color: info.color, cidr: g.cidr }); // Pipe goes up from the left end of the lane — clear of the horizontal edge anchors.push( makeLabelAnchor({ x: 96, y, cidr: g.cidr, localR: 6, dirX: 0, dirY: -1, pipeExtra: 22, }) ); g.members.forEach((n, i) => { const t = g.members.length <= 1 ? 0.5 : i / (g.members.length - 1); const x = 90 + t * (w - 160); const bob = Math.sin(i * 0.9 + si) * 10; nodes.push({ ...n, tx: x, ty: y + bob, r: nodeRadius(n), depth: 1, lane: si, }); }); }); state.clusterHubs = []; state.laneGuides = guides; state.labelAnchors = anchors; // hub left side state.hub = { x: 48, y: cy, r: 26 }; setTargets(nodes); } function layoutHelix(devices, cx, cy, w, h) { const list = [...devices].sort((a, b) => Number(b.connected) - Number(a.connected) || (b.watts || 0) - (a.watts || 0)); const nodes = []; const turns = 2.4; const byCidr = new Map(); list.forEach((n, i) => { const t = list.length <= 1 ? 0.5 : i / (list.length - 1); const angle = t * Math.PI * 2 * turns; const y = 60 + t * (h - 120); const amp = Math.min(w, h) * 0.28; const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(angle)); const x = cx + Math.cos(angle) * amp * depth; const ty = y + Math.sin(angle * 2) * 8; nodes.push({ ...n, tx: x, ty, r: (n.connected ? 7 : 4.5) * (0.75 + depth * 0.55), depth, helixT: t, }); const cidr = n.subnet || "unknown"; if (!byCidr.has(cidr)) byCidr.set(cidr, []); byCidr.get(cidr).push({ x, y: ty }); }); const anchors = []; for (const [cidr, pts] of byCidr.entries()) { const a = anchorsFromPoints(cidr, pts, { x: cx, y: cy }, { dirX: 1, dirY: 0, pipeExtra: 52, pad: 10, }); if (a) anchors.push(a); } state.clusterHubs = []; state.laneGuides = []; state.labelAnchors = anchors; 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(); const th = canvasTheme(); ctx.strokeStyle = th.hubStroke; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = th.hubText; ctx.font = "700 11px IBM Plex Sans, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("OME", hub.x, hub.y); } function isLightTheme() { return document.documentElement.getAttribute("data-theme") === "light"; } function canvasTheme() { if (isLightTheme()) { return { label: "rgba(14, 28, 44, 0.92)", labelSoft: "rgba(30, 50, 72, 0.78)", empty: "rgba(14, 28, 44, 0.88)", emptyHint: "rgba(60, 85, 110, 0.95)", hubStroke: "#0076ce", hubText: "#ffffff", selectRing: "#0076ce", decor: (i) => `rgba(0, 100, 180, ${0.08 + i * 0.025})`, spiral: "rgba(0, 100, 180, 0.16)", helix: (i) => `rgba(0, 100, 180, ${0.1 + i * 0.04})`, ghostLink: (a) => `rgba(70, 90, 110, ${0.22 * a})`, clusterLabel: "rgba(14, 28, 44, 0.85)", laneLabel: "rgba(14, 28, 44, 0.8)", }; } return { label: "rgba(232,244,255,0.85)", labelSoft: "rgba(232,244,255,0.7)", empty: "rgba(232,244,255,0.82)", emptyHint: "rgba(122,147,168,0.95)", hubStroke: "#3dffe0", hubText: "#fff", selectRing: "#fff", decor: (i) => `rgba(0,168,232,${0.035 + i * 0.012})`, spiral: "rgba(0,168,232,0.08)", helix: (i) => `rgba(0,168,232,${0.06 + i * 0.03})`, ghostLink: (a) => `rgba(90,106,122,${0.14 * a})`, clusterLabel: "rgba(232,244,255,0.7)", laneLabel: "rgba(232,244,255,0.55)", }; } function hexToRgb(hex) { const h = String(hex || "#3dffe0").replace("#", ""); const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h.padEnd(6, "0"); const n = parseInt(full.slice(0, 6), 16); if (!Number.isFinite(n)) return { r: 61, g: 255, b: 224 }; return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } function rgba(hex, a) { const { r, g, b } = hexToRgb(hex); return `rgba(${r},${g},${b},${a})`; } function drawPulseLink(x0, y0, x1, y1, connected, t, id, alphaScale) { if (!connected && !state.filters.ghostLinks) return; const animate = state.filters.pulseLinks !== false; const spd = Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45); let line = state.filters.pulseLineColor || "#3dffe0"; let glow = state.filters.pulseGlowColor || "#00a8e8"; let packet = state.filters.pulsePacketColor || "#ffffff"; if (isLightTheme()) { // Stronger, readable pulse on light stage when using default cyan palette if (!state.filters.pulseLineColor || line === "#3dffe0") line = "#0076ce"; if (!state.filters.pulseGlowColor || glow === "#00a8e8") glow = "#00a4e4"; if (!state.filters.pulsePacketColor || packet === "#ffffff") packet = "#0b2a44"; } const alpha = connected ? (animate ? (0.55 + 0.35 * Math.sin(t * 0.05 * spd + id)) : 0.72) * alphaScale : 0.06 * alphaScale; // glow underlay for live OME links if (connected) { ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.strokeStyle = rgba(glow, (animate ? 0.28 : 0.16) * alphaScale); ctx.lineWidth = animate ? 4.5 : 3.2; ctx.stroke(); } ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.strokeStyle = connected ? rgba(line, alpha) : canvasTheme().ghostLink(alphaScale); ctx.lineWidth = connected ? 2.4 : 0.8; ctx.stroke(); if (connected && animate) { // dual packets along the link for (const off of [0, 0.5]) { const u = ((t * 0.018 * spd + (id % 50) * 0.02 + off) % 1); const px = x0 + (x1 - x0) * u; const py = y0 + (y1 - y0) * u; ctx.beginPath(); ctx.arc(px, py, 3.2, 0, Math.PI * 2); ctx.fillStyle = packet; ctx.fill(); ctx.beginPath(); ctx.arc(px, py, 5.5, 0, Math.PI * 2); ctx.strokeStyle = rgba(line, 0.6); ctx.lineWidth = 1.5; ctx.stroke(); } } } function drawDecor(w, h, hub, t) { const mode = state.layoutMode; if (mode === "orbit" || mode === "galaxy") { for (let i = 1; i <= 5; i++) { ctx.beginPath(); ctx.arc(hub.x, hub.y, 70 * i * 0.5, 0, Math.PI * 2); ctx.strokeStyle = canvasTheme().decor(i); ctx.lineWidth = 1; ctx.stroke(); } if (mode === "galaxy") { // faint spiral guide ctx.beginPath(); for (let i = 0; i <= 120; i++) { const u = i / 120; const ang = u * Math.PI * 4; const r = 40 + u * Math.min(w, h) * 0.4; const x = hub.x + Math.cos(ang) * r; const y = hub.y + Math.sin(ang) * r; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = canvasTheme().spiral; ctx.lineWidth = 1.2; ctx.stroke(); } } else if (mode === "clusters") { for (const ch of state.clusterHubs || []) { const ring = (ch.localR || 52) + 10; ctx.beginPath(); ctx.arc(ch.x, ch.y, ring, 0, Math.PI * 2); ctx.strokeStyle = (ch.color || "#00a8e8") + "33"; ctx.lineWidth = 1.2; ctx.stroke(); ctx.beginPath(); ctx.arc(ch.x, ch.y, 10, 0, Math.PI * 2); ctx.fillStyle = ch.color || "#00a8e8"; ctx.globalAlpha = 0.55; ctx.fill(); ctx.globalAlpha = 1; drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55); } } else if (mode === "lanes") { for (const g of state.laneGuides || []) { ctx.beginPath(); ctx.moveTo(70, g.y); ctx.lineTo(w - 30, g.y); ctx.strokeStyle = (g.color || "#00a8e8") + "44"; ctx.lineWidth = 2; ctx.stroke(); // moving dashes const dashX = ((t * 1.8) % (w - 100)) + 70; ctx.beginPath(); ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = g.color || "#3dffe0"; ctx.fill(); } } else if (mode === "helix") { // depth rails for (let i = 0; i < 3; i++) { ctx.beginPath(); for (let s = 0; s <= 80; s++) { const u = s / 80; const ang = u * Math.PI * 2 * 2.4 + i * 0.9; const y = 60 + u * (h - 120); const amp = Math.min(w, h) * 0.28; const depth = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(ang)); const x = hub.x + Math.cos(ang) * amp * depth; if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = canvasTheme().helix(i); ctx.lineWidth = 1; ctx.stroke(); } } } function drawSubnetLabelPipes(omeHub) { const hubs = (state.labelAnchors && state.labelAnchors.length ? state.labelAnchors : state.clusterHubs) || []; if (!hubs.length || !omeHub) return; const th = canvasTheme(); const light = isLightTheme(); for (const ch of hubs) { const label = String(ch.label || ch.cidr || "").trim(); if (!label) continue; let ux; let uy; if (ch.dirX != null && ch.dirY != null && (ch.dirX !== 0 || ch.dirY !== 0)) { const len = Math.hypot(ch.dirX, ch.dirY) || 1; ux = ch.dirX / len; uy = ch.dirY / len; } else { let dx = ch.x - omeHub.x; let dy = ch.y - omeHub.y; let len = Math.hypot(dx, dy); if (len < 1) { dx = 0; dy = -1; len = 1; } ux = dx / len; uy = dy / len; } const ring = ch.localR || 52; const pipeStart = Math.min(12, Math.max(4, ring * 0.25)); const pipeEnd = ring + (ch.pipeExtra != null ? ch.pipeExtra : 36); const sx = ch.x + ux * pipeStart; const sy = ch.y + uy * pipeStart; const ex = ch.x + ux * pipeEnd; const ey = ch.y + uy * pipeEnd; const lx = ch.x + ux * (pipeEnd + 14); const ly = ch.y + uy * (pipeEnd + 14); const color = ch.color || "#00a8e8"; const rgb = hexToRgb(color); ctx.save(); ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.55)`; ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke(); ctx.beginPath(); ctx.arc(ex, ey, 2.4, 0, Math.PI * 2); ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`; ctx.fill(); ctx.restore(); ctx.font = "600 10px IBM Plex Mono, monospace"; const tw = ctx.measureText(label).width; const padX = 8; const bw = tw + padX * 2; const bh = 18; const bx = lx - bw / 2; const by = ly - bh / 2; ctx.beginPath(); const r = 6; ctx.moveTo(bx + r, by); ctx.arcTo(bx + bw, by, bx + bw, by + bh, r); ctx.arcTo(bx + bw, by + bh, bx, by + bh, r); ctx.arcTo(bx, by + bh, bx, by, r); ctx.arcTo(bx, by, bx + bw, by, r); ctx.closePath(); ctx.fillStyle = light ? "rgba(255,255,255,0.92)" : "rgba(6,14,24,0.88)"; ctx.fill(); ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.75)`; ctx.lineWidth = 1.2; ctx.stroke(); ctx.fillStyle = th.clusterLabel; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(label, lx, ly); } } function drawLinks(hub, t) { const mode = state.layoutMode; if (mode === "clusters") { // node to nearest cluster hub for (const n of state.nodes) { let best = null; let bestD = Infinity; for (const ch of state.clusterHubs || []) { const dx = n.x - ch.x; const dy = n.y - ch.y; const d = dx * dx + dy * dy; if (d < bestD) { bestD = d; best = ch; } } if (best) drawPulseLink(best.x, best.y, n.x, n.y, n.connected, t, n.id, 0.85); } return; } if (mode === "lanes") { for (const n of state.nodes) { drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 0.55); } return; } if (mode === "helix") { // chain neighbors + faint hub links for connected const sorted = [...state.nodes].sort((a, b) => (a.helixT || 0) - (b.helixT || 0)); for (let i = 0; i < sorted.length - 1; i++) { const a = sorted[i]; const b = sorted[i + 1]; drawPulseLink(a.x, a.y, b.x, b.y, a.connected || b.connected, t, a.id, 0.7); } for (const n of state.nodes) { if (n.connected) drawPulseLink(hub.x, hub.y, n.x, n.y, true, t, n.id, 0.25); } return; } // orbit / galaxy for (const n of state.nodes) { drawPulseLink(hub.x, hub.y, n.x, n.y, n.connected, t, n.id, 1); } } function drawNodes(t) { // draw far (small depth) first for helix const list = [...state.nodes].sort((a, b) => (a.depth || 1) - (b.depth || 1)); for (const n of list) { // lerp toward target if (Number.isFinite(n.tx)) { n.x += (n.tx - n.x) * 0.14; n.y += (n.ty - n.y) * 0.14; } const col = nodeColor(n); const selected = n.id === state.selectedId; const depth = n.depth || 1; if (selected) { ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 6, 0, Math.PI * 2); ctx.strokeStyle = canvasTheme().selectRing; ctx.lineWidth = 2; ctx.stroke(); } if (n.watts != null && state.viewMode === "power") { ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 3 + Math.min(10, n.watts / 80), 0, Math.PI * 2); ctx.strokeStyle = col + "55"; ctx.lineWidth = 2; ctx.stroke(); } ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx.fillStyle = col; ctx.globalAlpha = 0.55 + 0.45 * Math.min(depth, 1); ctx.fill(); ctx.globalAlpha = 1; if (state.cam.scale > 0.85) { const th = canvasTheme(); // Halo behind text for contrast on busy light canvas const label = state.viewMode === "power" && n.watts != null ? `${Math.round(n.watts)}W` : (n.name || "").slice(0, 18); ctx.font = "600 9px IBM Plex Mono, monospace"; ctx.textAlign = "center"; ctx.textBaseline = "top"; if (isLightTheme()) { ctx.lineWidth = 3; ctx.strokeStyle = "rgba(236, 242, 248, 0.92)"; ctx.strokeText(label, n.x, n.y + n.r + 3); } ctx.fillStyle = th.label; ctx.fillText(label, n.x, n.y + n.r + 3); } } } function draw() { const w = canvas.clientWidth; const h = canvas.clientHeight; // hard clear in device pixels (avoids trail artifacts with DPR transform) ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore(); ctx.save(); ctx.translate(state.cam.x, state.cam.y); ctx.scale(state.cam.scale, state.cam.scale); const hub = state.hub; if (!hub) { ctx.restore(); requestAnimationFrame(draw); return; } const t = state.pulseT; drawDecor(w, h, hub, t); drawLinks(hub, t); drawHub(hub, t); drawNodes(t); drawSubnetLabelPipes(hub); ctx.restore(); if (!state.nodes.length) { const th = canvasTheme(); ctx.fillStyle = th.empty; ctx.font = "600 15px IBM Plex Sans, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; const f = state.filters; const tip = (!f.servers && !f.idrac) ? "No types selected — enable Servers and/or iDRACs in Filters" : "No devices match the current filters"; ctx.fillText(tip, w / 2, h / 2); ctx.font = "500 12px IBM Plex Mono, monospace"; ctx.fillStyle = th.emptyHint; ctx.fillText("Adjust Filters or click Reset filters", w / 2, h / 2 + 28); } if (state.filters.pulseLinks !== false) { state.pulseT += Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45); } requestAnimationFrame(draw); } function screenToWorld(sx, sy) { return { x: (sx - state.cam.x) / state.cam.scale, y: (sy - state.cam.y) / state.cam.scale, }; } function hitTest(sx, sy) { const p = screenToWorld(sx, sy); if (state.hub) { const dx = p.x - state.hub.x; const dy = p.y - state.hub.y; if (dx * dx + dy * dy <= (state.hub.r + 6) ** 2) return { type: "hub" }; } let best = null; let bestD = Infinity; for (const n of state.nodes) { const dx = p.x - n.x; const dy = p.y - n.y; const d = dx * dx + dy * dy; if (d <= (n.r + 8) ** 2 && d < bestD) { bestD = d; best = n; } } return best ? { type: "node", node: best } : null; } const KPI_META = { total: { title: "All nodes", blurb: "Full OME inventory currently in cockpit." }, servers: { title: "Servers", blurb: "Server-class devices (OME type 1000)." }, idracs: { title: "iDRACs", blurb: "iDRAC / BMC endpoints discovered in OME." }, connected: { title: "Connected", blurb: "Devices with live OME connection state." }, offline: { title: "Offline", blurb: "Devices currently not connected to OME." }, powered: { title: "Powered on", blurb: "Devices reporting powered-on state." }, power: { title: "Live power", blurb: "Nodes with a live watt reading — hottest first." }, avgw: { title: "Average watts / node", blurb: "Power samples contributing to fleet average." }, samples: { title: "Power samples", blurb: "Devices included in the live power sample set." }, }; let kpiPopup = { key: null, selectedId: null, q: "", sort: "name" }; function devicesForKpi(key) { const all = state.data?.devices || []; switch (key) { case "servers": return all.filter((d) => d.is_server); case "idracs": return all.filter((d) => d.is_idrac); case "connected": return all.filter((d) => d.connected); case "offline": return all.filter((d) => !d.connected); case "powered": return all.filter((d) => d.powered_on); case "power": case "avgw": case "samples": return all.filter((d) => d.watts != null); case "total": default: return all.slice(); } } function sortKpiDevices(list) { const sort = kpiPopup.sort || "name"; const arr = list.slice(); if (sort === "watts") arr.sort((a, b) => (b.watts || 0) - (a.watts || 0)); else if (sort === "status") arr.sort((a, b) => String(a.status || "").localeCompare(String(b.status || ""))); else if (sort === "subnet") arr.sort((a, b) => String(a.subnet || "").localeCompare(String(b.subnet || ""))); else arr.sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))); return arr; } function filterKpiDevices(list) { const q = (kpiPopup.q || "").trim().toLowerCase(); if (!q) return list; return list.filter((d) => [d.name, d.ip, d.model, d.service_tag, d.subnet, d.status] .filter(Boolean) .join(" ") .toLowerCase() .includes(q) ); } function kpiSummaryHtml(key, list) { const connected = list.filter((d) => d.connected).length; const offline = list.length - connected; const powered = list.filter((d) => d.powered_on).length; const withW = list.filter((d) => d.watts != null); const watts = withW.reduce((a, d) => a + (d.watts || 0), 0); const subnets = new Set(list.map((d) => d.subnet).filter(Boolean)).size; const pills = [ `${list.length} in view`, `${connected} connected`, `${offline} offline`, `${powered} powered`, withW.length ? `${Math.round(watts)} W sampled` : "no power samples", `${subnets} subnets`, ]; if (key === "power" || key === "avgw" || key === "samples") { const top = sortKpiDevices(withW).slice(0, 1)[0]; if (top) pills.push(`hottest ${top.name?.slice(0, 22)} ${Math.round(top.watts)}W`); } return pills.map((p) => `${escapeHtml(p)}`).join(""); } function renderKpiDetail(node) { const el = $("#kpi-detail"); if (!el) return; if (!node) { el.innerHTML = `
Select a system on the left for live context and actions.
`; return; } const alerts = (state.data?.alerts || []).filter((a) => a.device_id === node.id).slice(0, 5); el.innerHTML = `${escapeHtml(node.service_tag || "—")}No alerts for this node in the current feed.
` }No systems match this KPI / search.
`; 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 ``; }) .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) => ` ` ) .join(""); } function renderSubnets() { const subs = state.data?.subnets || []; const html = [ ``, ...subs.map((s) => { const vlanLabel = s.vlan_id != null ? `VLAN ${s.vlan_id}${s.vlan_name ? " · " + s.vlan_name : ""}` : s.cidr; const accent = s.vlan_color ? ` style="--vlan:${escapeAttr(s.vlan_color)}"` : ""; return ``; }), ]; $("#subnet-list").innerHTML = html.join(""); } function renderModels() { const models = state.data?.models || []; $("#model-list").innerHTML = [ ``, ...models.map( (m) => `` ), ].join(""); } function renderGroups() { const groups = state.data?.groups || []; $("#group-list").innerHTML = groups.length ? groups .map( (g) => `` ) .join("") : `No groups loaded
`; } function renderLegend() { const el = $("#legend"); if (state.viewMode === "power") { el.innerHTML = ` <200W 200–450W 450–650W >650W geen reading`; } else if (state.viewMode === "connection") { el.innerHTML = `connectedoffline`; } else if (state.viewMode === "model") { el.innerHTML = [...state.modelColors.entries()] .slice(0, 6) .map(([k, c]) => `${escapeHtml(k.slice(0, 22))}`) .join(""); } else if (state.viewMode === "subnet") { el.innerHTML = [...state.subnetColors.entries()] .slice(0, 6) .map(([k, c]) => `${escapeHtml(k)}`) .join(""); } else { el.innerHTML = ` healthy warning critical offline`; } } 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 = ` · · · · · · updated ${d.updated_at ? new Date(d.updated_at * 1000).toLocaleTimeString() : "—"} `; } function escapeHtml(s) { return String(s ?? "") .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 = ` `; } // alerts in left rail const al = $("#alert-list"); if (al) { const alerts = (d?.alerts || []).slice(0, 12); if (!alerts.length) { al.innerHTML = `No alerts in this snapshot
`; } else { al.innerHTML = alerts .map( (a) => `` ) .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) => `` ) .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-pulse-speed", f.pulseSpeed ?? 0.45); set("f-pulse-line-color", f.pulseLineColor || "#3dffe0"); set("f-pulse-glow-color", f.pulseGlowColor || "#00a8e8"); set("f-pulse-packet-color", f.pulsePacketColor || "#ffffff"); set("f-min-watts", f.minWatts || 0); const lab = $("#min-w-label"); if (lab) lab.textContent = String(f.minWatts || 0); const pLab = $("#pulse-speed-label"); if (pLab) pLab.textContent = `${Number(f.pulseSpeed ?? 0.45).toFixed(2)}×`; const search = $("#search"); if (search) search.value = f.search || ""; } function savePulsePrefs() { try { localStorage.setItem( "cockpit_pulse_prefs", JSON.stringify({ pulseSpeed: state.filters.pulseSpeed ?? 0.45, pulseLineColor: state.filters.pulseLineColor || "#3dffe0", pulseGlowColor: state.filters.pulseGlowColor || "#00a8e8", pulsePacketColor: state.filters.pulsePacketColor || "#ffffff", }) ); localStorage.setItem("cockpit_pulse_speed", String(state.filters.pulseSpeed ?? 0.45)); } catch (_) {} } function readFiltersFromDom() { const on = (id) => !!document.getElementById(id)?.checked; state.filters.connected = on("f-connected"); state.filters.powered = on("f-powered"); state.filters.servers = on("f-servers"); state.filters.idrac = on("f-idrac"); state.filters.hasPower = on("f-has-power"); state.filters.ghostLinks = on("f-ghost-links"); state.filters.pulseLinks = on("f-pulse-links"); const ps = document.getElementById("f-pulse-speed"); if (ps) state.filters.pulseSpeed = Math.max(0.1, Math.min(2, Number(ps.value) || 0.45)); const line = document.getElementById("f-pulse-line-color"); const glow = document.getElementById("f-pulse-glow-color"); const packet = document.getElementById("f-pulse-packet-color"); if (line?.value) state.filters.pulseLineColor = line.value; if (glow?.value) state.filters.pulseGlowColor = glow.value; if (packet?.value) state.filters.pulsePacketColor = packet.value; const mw = document.getElementById("f-min-watts"); state.filters.minWatts = mw ? Number(mw.value) || 0 : 0; state.filters.search = $("#search")?.value || ""; savePulsePrefs(); } function clearFilters() { state.filters = { connected: false, powered: false, servers: true, idrac: false, hasPower: false, ghostLinks: false, pulseLinks: true, pulseSpeed: 0.45, pulseLineColor: "#3dffe0", pulseGlowColor: "#00a8e8", pulsePacketColor: "#ffffff", minWatts: 0, search: "", }; state.subnet = "all"; state.model = null; state.kpiFocus = null; state.groupHint = null; syncFilterInputs(); refreshLists(); layout(); snapNodes(); state.cam = { x: 0, y: 0, scale: 1 }; fitCameraToNodes(); updateFocusContext(); showToast("Filters reset · Servers on"); } function showToast(msg, opts = {}) { let el = $("#toast"); if (!el) { el = document.createElement("div"); el.id = "toast"; el.className = "toast"; document.body.appendChild(el); } el.textContent = msg; el.classList.toggle("toast-alert", !!opts.alert); el.classList.add("show"); clearTimeout(showToast._t); const ms = opts.ms != null ? opts.ms : opts.alert ? 8000 : 2200; showToast._t = setTimeout(() => el.classList.remove("show"), ms); } function pushNotifyCard(ev) { const host = $("#notify-stack"); if (!host) return; const card = document.createElement("button"); card.type = "button"; card.className = `notify-card ${ev.severity || "info"}`; card.dataset.deviceId = ev.device_id ?? ""; card.innerHTML = ` ${escapeHtml(ev.kind === "device_removed" ? "Removed" : "New on network")} ${escapeHtml(ev.title || "Fleet change")} ${escapeHtml(ev.text || "")} `; 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 = `${escapeHtml(node.name || "node")}
Double-click · Quick Connect
`; 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 = ` ${node.connected ? "CONNECTED" : "OFFLINE"} ${node.powered_on ? "POWERED ON" : "POWER N/A"} ${node.watts != null ? `${Math.round(node.watts)} W` : ""} ${node.is_idrac ? `iDRAC` : ""} ${node.is_server ? `SERVER` : ""}`; const alerts = relatedAlerts(node); $("#connect-context").innerHTML = `Warranty/compliance unavailable
`; } } function setInvOpen(on) { document.body.classList.toggle("inv-open", !!on); } function wrapInvHtml(html) { return `${escapeHtml(node.service_tag || "—")}
${node.service_tag ? `` : ""}
Loading warranty & Dell compliance…
Loading full inventory + application landscape…
No inventory available
`); state.inventoryCache[deviceId] = html; if (state.selectedId === deviceId) { const live = $("#inv-mount"); if (live) live.innerHTML = html; setInvOpen(true); } } catch (e) { const errHtml = wrapInvHtml(`Inventory failed: ${escapeHtml(e.message)}
`); if (state.selectedId === deviceId) { const live = $("#inv-mount"); if (live) live.innerHTML = errHtml; setInvOpen(true); } } finally { if (state.inventoryLoadingId === deviceId) state.inventoryLoadingId = null; } }); } function showHubInspector() { const ome = state.data?.ome || {}; const s = state.data?.summary || {}; state.selectedId = null; setInvOpen(false); $("#inspector-empty").classList.add("hidden"); const body = $("#inspector-body"); body.classList.remove("hidden"); body.innerHTML = `