Add VLAN inventory, Present decks, and network fabric mapping.

Seed all ATC/FDE VLANs with live host inventory and editable notes, fix cluster membership on the map, and ship Present/network UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-18 21:56:30 +02:00
parent 91222faffe
commit 1e87f22006
15 changed files with 7587 additions and 382 deletions
+295 -36
View File
@@ -26,6 +26,10 @@
hasPower: false,
ghostLinks: false,
pulseLinks: true,
pulseSpeed: 0.45,
pulseLineColor: "#3dffe0",
pulseGlowColor: "#00a8e8",
pulsePacketColor: "#ffffff",
minWatts: 0,
search: "",
},
@@ -98,7 +102,10 @@
if (!includeServers && !includeIdrac) return [];
return (d.devices || []).filter((n) => {
if (state.subnet !== "all" && n.subnet !== state.subnet) return false;
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;
@@ -345,50 +352,112 @@
ctx.arc(hub.x, hub.y, hub.r, 0, Math.PI * 2);
ctx.fillStyle = "#0076ce";
ctx.fill();
ctx.strokeStyle = "#3dffe0";
const th = canvasTheme();
ctx.strokeStyle = th.hubStroke;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = "#fff";
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 + id)) : 0.72) * alphaScale
? (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(0,168,232,${(animate ? 0.22 : 0.14) * alphaScale})`;
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(61,255,224,${alpha})` : `rgba(90,106,122,${0.14 * alphaScale})`;
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 + (id % 50) * 0.02 + off) % 1);
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 = "rgba(255,255,255,0.95)";
ctx.fillStyle = packet;
ctx.fill();
ctx.beginPath();
ctx.arc(px, py, 5.5, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(61,255,224,0.55)";
ctx.strokeStyle = rgba(line, 0.6);
ctx.lineWidth = 1.5;
ctx.stroke();
}
@@ -401,7 +470,7 @@
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.strokeStyle = canvasTheme().decor(i);
ctx.lineWidth = 1;
ctx.stroke();
}
@@ -417,7 +486,7 @@
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = "rgba(0,168,232,0.08)";
ctx.strokeStyle = canvasTheme().spiral;
ctx.lineWidth = 1.2;
ctx.stroke();
}
@@ -434,8 +503,8 @@
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.fillStyle = canvasTheme().clusterLabel;
ctx.font = "600 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);
@@ -454,8 +523,8 @@
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.fillStyle = canvasTheme().laneLabel;
ctx.font = "600 9px IBM Plex Mono, monospace";
ctx.textAlign = "left";
ctx.fillText((g.label || "").slice(0, 16), 74, g.y - 8);
}
@@ -473,7 +542,7 @@
if (s === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = `rgba(0,168,232,${0.06 + i * 0.03})`;
ctx.strokeStyle = canvasTheme().helix(i);
ctx.lineWidth = 1;
ctx.stroke();
}
@@ -540,7 +609,7 @@
if (selected) {
ctx.beginPath();
ctx.arc(n.x, n.y, n.r + 6, 0, Math.PI * 2);
ctx.strokeStyle = "#fff";
ctx.strokeStyle = canvasTheme().selectRing;
ctx.lineWidth = 2;
ctx.stroke();
}
@@ -558,14 +627,21 @@
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 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);
}
}
@@ -600,7 +676,8 @@
ctx.restore();
if (!state.nodes.length) {
ctx.fillStyle = "rgba(232,244,255,0.82)";
const th = canvasTheme();
ctx.fillStyle = th.empty;
ctx.font = "600 15px IBM Plex Sans, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
@@ -610,11 +687,13 @@
: "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.fillStyle = th.emptyHint;
ctx.fillText("Adjust Filters or click Reset filters", w / 2, h / 2 + 28);
}
if (state.filters.pulseLinks !== false) state.pulseT += 1;
if (state.filters.pulseLinks !== false) {
state.pulseT += Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45);
}
requestAnimationFrame(draw);
}
@@ -922,12 +1001,17 @@
<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>`
),
...subs.map((s) => {
const vlanLabel =
s.vlan_id != null
? `VLAN ${s.vlan_id}${s.vlan_name ? " · " + s.vlan_name : ""}`
: s.cidr;
const accent = s.vlan_color ? ` style="--vlan:${escapeAttr(s.vlan_color)}"` : "";
return `<button type="button" class="card${state.subnet === s.cidr ? " active" : ""}${s.vlan_id != null ? " has-vlan" : ""}" data-subnet="${escapeAttr(s.cidr)}"${accent}>
<span class="t">${escapeHtml(vlanLabel)}</span>
<span class="s">${escapeHtml(s.cidr)} · ${s.connected}/${s.count} connected${s.watts != null ? ` · ${Math.round(s.watts)} W` : ""}</span>
</button>`;
}),
];
$("#subnet-list").innerHTML = html.join("");
}
@@ -1301,13 +1385,34 @@
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");
@@ -1317,9 +1422,18 @@
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() {
@@ -1331,6 +1445,10 @@
hasPower: false,
ghostLinks: false,
pulseLinks: true,
pulseSpeed: 0.45,
pulseLineColor: "#3dffe0",
pulseGlowColor: "#00a8e8",
pulsePacketColor: "#ffffff",
minWatts: 0,
search: "",
};
@@ -1690,8 +1808,23 @@
}
}
function setInvOpen(on) {
document.body.classList.toggle("inv-open", !!on);
}
function wrapInvHtml(html) {
return `<div class="inv-panel">
<div class="inv-panel-head">
<strong>Full inventory</strong>
<span>Theme toggle parked left while reading</span>
</div>
${html}
</div>`;
}
async function showInspector(node) {
state.selectedId = node?.id ?? null;
setInvOpen(false);
const empty = $("#inspector-empty");
const body = $("#inspector-body");
if (!node) {
@@ -1775,14 +1908,18 @@
const cachedInv = state.inventoryCache[node.id];
if (cachedInv) {
const mount = $("#inv-mount");
if (mount) mount.innerHTML = cachedInv;
if (mount) {
mount.innerHTML = cachedInv.includes("inv-panel") ? cachedInv : wrapInvHtml(cachedInv);
setInvOpen(true);
}
}
$("#btn-detail")?.addEventListener("click", async () => {
const mount = $("#inv-mount");
const deviceId = node.id;
state.inventoryLoadingId = deviceId;
mount.innerHTML = `<p class="hint">Loading full inventory + application landscape…</p>`;
setInvOpen(true);
mount.innerHTML = `<div class="inv-panel"><p class="hint">Loading full inventory + application landscape…</p></div>`;
try {
const r = await fetch(`/api/devices/${deviceId}`);
const detail = await r.json();
@@ -1888,17 +2025,19 @@
);
}
html = html || `<p class="hint">No inventory available</p>`;
html = wrapInvHtml(html || `<p class="hint">No inventory available</p>`);
state.inventoryCache[deviceId] = html;
if (state.selectedId === deviceId) {
const live = $("#inv-mount");
if (live) live.innerHTML = html;
setInvOpen(true);
}
} catch (e) {
const errHtml = `<p class="hint">Inventory failed: ${escapeHtml(e.message)}</p>`;
const errHtml = wrapInvHtml(`<p class="hint">Inventory failed: ${escapeHtml(e.message)}</p>`);
if (state.selectedId === deviceId) {
const live = $("#inv-mount");
if (live) live.innerHTML = errHtml;
setInvOpen(true);
}
} finally {
if (state.inventoryLoadingId === deviceId) state.inventoryLoadingId = null;
@@ -1910,6 +2049,7 @@
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");
@@ -2144,6 +2284,12 @@
if (t.id === "f-min-watts") {
$("#min-w-label").textContent = String(state.filters.minWatts);
}
if (t.id === "f-pulse-speed") {
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`;
showToast(`Pulse speed ${Number(state.filters.pulseSpeed).toFixed(2)}×`);
return;
}
// Pulse-only toggle: no need to relayout
if (t.id === "f-pulse-links") {
updateFocusContext();
@@ -2160,6 +2306,94 @@
}
});
$("#filters").addEventListener("input", (e) => {
if (e.target?.id !== "f-pulse-speed") return;
readFiltersFromDom();
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`;
});
function togglePulsePop(force) {
const pop = $("#pulse-pop");
const btn = $("#btn-pulse-settings");
const block = document.querySelector('.rail-block[data-module="filters"]');
if (!pop || !btn) return;
const open = force != null ? !!force : pop.hidden;
pop.hidden = !open;
btn.setAttribute("aria-expanded", open ? "true" : "false");
block?.classList.toggle("pulse-open", open);
if (open) {
const ps = $("#f-pulse-speed");
if (ps) ps.value = String(state.filters.pulseSpeed ?? 0.45);
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed ?? 0.45).toFixed(2)}×`;
const line = $("#f-pulse-line-color");
const glow = $("#f-pulse-glow-color");
const packet = $("#f-pulse-packet-color");
if (line) line.value = state.filters.pulseLineColor || "#3dffe0";
if (glow) glow.value = state.filters.pulseGlowColor || "#00a8e8";
if (packet) packet.value = state.filters.pulsePacketColor || "#ffffff";
}
}
$("#btn-pulse-settings")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
togglePulsePop();
});
const PULSE_THEMES = {
cyan: { line: "#3dffe0", glow: "#00a8e8", packet: "#ffffff" },
orange: { line: "#ff9a3c", glow: "#ffb040", packet: "#fff4e5" },
green: { line: "#3dffa0", glow: "#1b7a4a", packet: "#e8fff4" },
purple: { line: "#c4a7ff", glow: "#7b6cf0", packet: "#f4efff" },
pink: { line: "#f472b6", glow: "#db2777", packet: "#fff0f7" },
gold: { line: "#f0e68c", glow: "#d4af37", packet: "#fffceb" },
};
$("#pulse-pop")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-pulse-speed]");
if (chip) {
const v = Number(chip.dataset.pulseSpeed);
state.filters.pulseSpeed = v;
const ps = $("#f-pulse-speed");
if (ps) ps.value = String(v);
const pLab = $("#pulse-speed-label");
if (pLab) pLab.textContent = `${v.toFixed(2)}×`;
savePulsePrefs();
showToast(`Pulse speed ${v.toFixed(2)}×`);
return;
}
const sw = e.target.closest("[data-pulse-theme]");
if (sw) {
const theme = PULSE_THEMES[sw.dataset.pulseTheme];
if (!theme) return;
state.filters.pulseLineColor = theme.line;
state.filters.pulseGlowColor = theme.glow;
state.filters.pulsePacketColor = theme.packet;
const line = $("#f-pulse-line-color");
const glow = $("#f-pulse-glow-color");
const packet = $("#f-pulse-packet-color");
if (line) line.value = theme.line;
if (glow) glow.value = theme.glow;
if (packet) packet.value = theme.packet;
savePulsePrefs();
showToast(`Pulse color: ${sw.dataset.pulseTheme}`);
}
});
$("#pulse-pop")?.addEventListener("input", (e) => {
if (!e.target?.matches?.('input[type="color"]')) return;
readFiltersFromDom();
});
document.addEventListener("click", (e) => {
const pop = $("#pulse-pop");
if (!pop || pop.hidden) return;
if (e.target.closest("#pulse-pop") || e.target.closest("#btn-pulse-settings")) return;
togglePulsePop(false);
});
$("#search").addEventListener("input", (e) => {
state.filters.search = e.target.value;
layout();
@@ -2332,8 +2566,15 @@
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 if (
mode === "chat-drawer" ||
mode === "ops-drawer" ||
mode === "ai-drawer" ||
mode === "reports-drawer" ||
mode === "an-context" ||
mode === "network-drawer"
) {
/* reports.js / ops.js / network.js also listen */
} else closeAi();
});
$("#btn-ome-console").addEventListener("click", () => {
@@ -2353,6 +2594,10 @@
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#an-context-modal")?.classList.contains("hidden")) {
window.cockpitReports?.closeAnContext?.();
return;
}
closeConnect();
closeAi();
closeKpiPopup();
@@ -2392,6 +2637,20 @@
}
// Keep JS filter state in sync with checkbox defaults in HTML
try {
const saved = JSON.parse(localStorage.getItem("cockpit_pulse_prefs") || "null");
if (saved && typeof saved === "object") {
if (Number.isFinite(Number(saved.pulseSpeed)) && saved.pulseSpeed > 0) {
state.filters.pulseSpeed = Number(saved.pulseSpeed);
}
if (saved.pulseLineColor) state.filters.pulseLineColor = saved.pulseLineColor;
if (saved.pulseGlowColor) state.filters.pulseGlowColor = saved.pulseGlowColor;
if (saved.pulsePacketColor) state.filters.pulsePacketColor = saved.pulsePacketColor;
} else {
const savedSpd = Number(localStorage.getItem("cockpit_pulse_speed"));
if (Number.isFinite(savedSpd) && savedSpd > 0) state.filters.pulseSpeed = savedSpd;
}
} catch (_) {}
readFiltersFromDom();
syncFilterInputs();
resize();