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
+1021 -77
View File
File diff suppressed because it is too large Load Diff
+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();
+138 -15
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=plate4" />
<link rel="stylesheet" href="/styles.css?v=vlanall1" />
<link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script>
@@ -41,6 +41,7 @@
<button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button>
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button>
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn present-btn" id="btn-present" title="Customer presentation · mid or fullscreen slides">Present</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit Copilot</button>
</div>
</header>
@@ -77,7 +78,46 @@
<label class="toggle"><input type="checkbox" id="f-powered" /> Powered on</label>
<label class="toggle"><input type="checkbox" id="f-has-power" /> Has power reading</label>
<label class="toggle"><input type="checkbox" id="f-ghost-links" /> Ghost offline links</label>
<div class="pulse-row">
<label class="toggle"><input type="checkbox" id="f-pulse-links" checked /> Animate link pulse</label>
<button type="button" class="btn ghost compact pulse-gear" id="btn-pulse-settings" title="Pulse speed settings" aria-haspopup="dialog" aria-expanded="false"></button>
<div class="pulse-pop" id="pulse-pop" hidden role="dialog" aria-label="Link pulse settings">
<strong>Link pulse</strong>
<label class="range pulse-speed-range">
<span>Speed <em id="pulse-speed-label">0.45×</em></span>
<input type="range" id="f-pulse-speed" min="0.15" max="1.5" step="0.05" value="0.45" />
</label>
<div class="pulse-presets">
<button type="button" class="chip" data-pulse-speed="0.25">Slow</button>
<button type="button" class="chip" data-pulse-speed="0.45">Normal</button>
<button type="button" class="chip" data-pulse-speed="0.8">Fast</button>
<button type="button" class="chip" data-pulse-speed="1.2">Max</button>
</div>
<div class="pulse-colors">
<label class="pulse-color-field">
<span>Line</span>
<input type="color" id="f-pulse-line-color" value="#3dffe0" title="Link line color" />
</label>
<label class="pulse-color-field">
<span>Glow</span>
<input type="color" id="f-pulse-glow-color" value="#00a8e8" title="Link glow color" />
</label>
<label class="pulse-color-field">
<span>Packet</span>
<input type="color" id="f-pulse-packet-color" value="#ffffff" title="Moving packet color" />
</label>
</div>
<div class="pulse-color-presets" id="pulse-color-presets">
<button type="button" class="pulse-swatch" data-pulse-theme="cyan" style="--sw:#3dffe0" title="Cyan"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="orange" style="--sw:#ff9a3c" title="Orange"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="green" style="--sw:#3dffa0" title="Green"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="purple" style="--sw:#c4a7ff" title="Purple"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="pink" style="--sw:#f472b6" title="Pink"></button>
<button type="button" class="pulse-swatch" data-pulse-theme="gold" style="--sw:#f0e68c" title="Gold"></button>
</div>
<p class="hint">Lower speed = calmer packets on the main map.</p>
</div>
</div>
<label class="range">
<span>Min watts <em id="min-w-label">0</em></span>
<input type="range" id="f-min-watts" min="0" max="800" step="10" value="0" />
@@ -188,7 +228,16 @@
<div class="inspector-body hidden" id="inspector-body"></div>
</aside>
<footer class="ticker" id="ticker"></footer>
<footer class="app-footer" id="app-footer">
<div class="app-credit" title="Not an official Dell product">
<span>OME Cockpit · Data FDEs <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong></span>
<span class="app-credit-sep" aria-hidden="true">·</span>
<span>Data Forward Deployed Engineers</span>
<span class="app-credit-sep" aria-hidden="true">·</span>
<span>Not an official Dell Technologies product</span>
</div>
<div class="ticker" id="ticker"></div>
</footer>
</div>
<button type="button" class="theme-fab" id="btn-theme" title="Switch theme" aria-label="Switch light or dark theme">
@@ -240,10 +289,11 @@
<input type="text" id="chat-input" placeholder="Ask about fleet memory, alerts, power, iDRACs, GPUs…" autocomplete="off" />
<button type="submit" class="btn primary">Send</button>
</form>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<!-- Ops desk / tickets -->
<div class="drawer wide" id="ops-drawer" aria-hidden="true">
<div class="drawer wide ops-drawer" id="ops-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
@@ -255,11 +305,20 @@
<div class="ops-side">
<label class="ops-identity">Acting as
<select id="ops-actor">
<option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option>
<optgroup label="ATC Admins">
<option value="jody">Jody van Dongen · Admin</option>
<option value="laurens">Laurens Rammers · Admin</option>
</optgroup>
<optgroup label="Data FDE team">
<option value="mo">Mohamed El Kadi · Data FDE</option>
<option value="bart">Bart Sjerps · Data FDE</option>
</optgroup>
</select>
</label>
<div class="ops-side-actions">
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
<button type="button" class="btn compact" id="btn-ops-refresh" title="Reload ticket list">Refresh</button>
</div>
<div class="ops-filters">
<select id="ops-filter" title="Ticket history filter">
<option value="all" selected>All history</option>
@@ -270,6 +329,9 @@
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
<option value="mine">Assigned to me</option>
<option value="created">Created by me</option>
<option value="fde">FDE involved</option>
<option value="admin">Admin handoff</option>
</select>
<span class="ops-count" id="ops-count">0 tickets</span>
</div>
@@ -277,12 +339,47 @@
</div>
<div class="ops-main" id="ops-main">
<div class="ops-empty" id="ops-empty">
<h3>Admin collaboration</h3>
<p>Jody van Dongen and Laurens Rammers can exchange notes and tickets for ATC datacenter operations.</p>
<h3>ATC Ops desk</h3>
<p class="ops-intro">Select a ticket or create one to collaborate across ATC admins and Data Forward Deployed Engineers.</p>
<div class="ops-team-grid" id="ops-team-grid">
<section class="ops-team-card admin">
<h4>ATC Admins</h4>
<ul>
<li><strong>Jody van Dongen</strong><span>OME fleet · racks · warranty / compliance</span></li>
<li><strong>Laurens Rammers</strong><span>Datacenter ops · handoffs · escalation</span></li>
</ul>
</section>
<section class="ops-team-card fde">
<h4>Data Forward Deployed Engineers</h4>
<ul>
<li><strong>Mohamed El Kadi</strong><span>Data Forward Deployed Engineer · Cockpit · OpenManage AI</span></li>
<li><strong>Bart Sjerps</strong><span>Data Forward Deployed Engineer · FDE cluster · AI workloads</span></li>
</ul>
</section>
</div>
<div class="ops-quick" id="ops-quick">
<h4>Quick actions</h4>
<div class="ops-quick-row">
<button type="button" class="btn primary" data-ops-quick="ticket">New ticket</button>
<button type="button" class="btn" data-ops-quick="to-admin">Handoff to admin</button>
<button type="button" class="btn" data-ops-quick="to-fde">Ask FDE / AI cluster</button>
<button type="button" class="btn" data-ops-quick="active">Show active tickets</button>
<button type="button" class="btn" data-ops-quick="chat">Open Copilot</button>
<button type="button" class="btn" data-ops-quick="reports">Fleet reports</button>
</div>
</div>
<div class="ops-stats" id="ops-stats"></div>
</div>
<div class="ops-thread hidden" id="ops-thread"></div>
</div>
</div>
<footer class="ops-footer">
<span>OME Cockpit · Data FDEs <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong></span>
<span class="ops-footer-sep" aria-hidden="true">·</span>
<span>Data Forward Deployed Engineers</span>
<span class="ops-footer-sep" aria-hidden="true">·</span>
<span>Not an official Dell Technologies product</span>
</footer>
</div>
<div class="drawer" id="ai-drawer" aria-hidden="true">
@@ -318,6 +415,7 @@
<div id="network-racks" class="network-panel hidden"></div>
<div id="network-vlans" class="network-panel hidden"></div>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<!-- Reports / compliance / export -->
@@ -341,6 +439,7 @@
<div class="reports-body" id="reports-mount">
<p class="hint">Select a report tab to load live OME data.</p>
</div>
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> &amp; <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
</div>
<div class="scrim" id="scrim"></div>
@@ -354,7 +453,7 @@
<div>
<p class="modal-kicker">ATC Ops desk</p>
<h2 id="ticket-modal-title">Create ticket</h2>
<p class="modal-sub">Professional handoff between Jody van Dongen and Laurens Rammers</p>
<p class="modal-sub">Handoff between ATC admins (Jody / Laurens) and Data FDEs (Mo / Bart)</p>
</div>
</div>
<form id="ticket-form" class="ticket-form">
@@ -372,8 +471,14 @@
</label>
<label>Assign to
<select id="ticket-assignee">
<option value="laurens">Laurens Rammers</option>
<optgroup label="ATC Admins">
<option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option>
</optgroup>
<optgroup label="Data FDE team">
<option value="mo">Mohamed El Kadi</option>
<option value="bart">Bart Sjerps</option>
</optgroup>
</select>
</label>
</div>
@@ -389,6 +494,23 @@
</div>
<div class="modal hidden" id="an-context-modal" aria-hidden="true" role="dialog" aria-labelledby="an-ctx-title">
<div class="modal-card an-ctx-card">
<button type="button" class="modal-close" id="an-ctx-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="32" height="32" />
<div>
<p class="modal-kicker">Analytics context</p>
<h2 id="an-ctx-title">Context</h2>
<p class="modal-sub" id="an-ctx-sub"></p>
</div>
</div>
<div class="an-ctx-pills" id="an-ctx-pills"></div>
<div class="an-ctx-list" id="an-ctx-list"></div>
<div class="an-ctx-actions" id="an-ctx-actions"></div>
</div>
</div>
<div class="modal hidden" id="kpi-modal" aria-hidden="true" role="dialog" aria-labelledby="kpi-title">
<div class="modal-card kpi-card">
<button type="button" class="modal-close" id="btn-kpi-close" aria-label="Close">×</button>
@@ -511,11 +633,12 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=plate4"></script>
<script src="/ops.js?v=plate4"></script>
<script src="/ssh.js?v=plate4"></script>
<script src="/rdp.js?v=plate4"></script>
<script src="/reports.js?v=plate4"></script>
<script src="/network.js?v=plate4"></script>
<script src="/app.js?v=vlanall1"></script>
<script src="/ops.js?v=opsfde2"></script>
<script src="/ssh.js?v=fabric4"></script>
<script src="/rdp.js?v=fabric4"></script>
<script src="/reports.js?v=anctx1"></script>
<script src="/network.js?v=vlanall1"></script>
<script src="/present.js?v=present16"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#1e293b"/>
<rect x="10" y="14" width="44" height="36" rx="4" fill="#f8fafc"/>
<rect x="10" y="14" width="44" height="8" rx="4" fill="#cbd5e1"/>
<circle cx="16" cy="18" r="1.8" fill="#ef4444"/>
<circle cx="22" cy="18" r="1.8" fill="#f59e0b"/>
<circle cx="28" cy="18" r="1.8" fill="#22c55e"/>
<rect x="16" y="28" width="20" height="3" rx="1.5" fill="#94a3b8"/>
<rect x="16" y="34" width="28" height="3" rx="1.5" fill="#cbd5e1"/>
<rect x="16" y="40" width="16" height="3" rx="1.5" fill="#cbd5e1"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#2496ED"/>
<g fill="#fff">
<rect x="14" y="28" width="7" height="6" rx="1"/>
<rect x="23" y="28" width="7" height="6" rx="1"/>
<rect x="32" y="28" width="7" height="6" rx="1"/>
<rect x="23" y="20" width="7" height="6" rx="1"/>
<rect x="32" y="20" width="7" height="6" rx="1"/>
<rect x="32" y="12" width="7" height="6" rx="1"/>
<rect x="41" y="28" width="7" height="6" rx="1"/>
</g>
<path d="M12 38c2 6 8 10 20 10 14 0 22-5 24-12H12z" fill="#fff" opacity=".95"/>
<circle cx="48" cy="18" r="3" fill="#fff" opacity=".85"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#009688"/>
<path d="M34 10L18 36h12l-2 18 18-28H34l2-16z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0076CE"/>
<text x="32" y="38" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">OME</text>
</svg>

After

Width:  |  Height:  |  Size: 266 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<circle cx="32" cy="28" r="14" fill="#38bdf8"/>
<circle cx="26" cy="26" r="2.5" fill="#0f172a"/>
<circle cx="38" cy="26" r="2.5" fill="#0f172a"/>
<path d="M24 34c2.5 3 5.5 4.5 8 4.5S37.5 37 40 34" stroke="#0f172a" stroke-width="2.5" stroke-linecap="round"/>
<path d="M18 48c4-6 10-9 14-9s10 3 14 9" fill="#7dd3fc"/>
</svg>

After

Width:  |  Height:  |  Size: 462 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0f766e"/>
<ellipse cx="32" cy="20" rx="16" ry="6" fill="#99f6e4"/>
<path d="M16 20v10c0 3.3 7.2 6 16 6s16-2.7 16-6V20" fill="#2dd4bf"/>
<path d="M16 30v10c0 3.3 7.2 6 16 6s16-2.7 16-6V30" fill="#14b8a6"/>
<ellipse cx="32" cy="40" rx="16" ry="6" fill="#99f6e4" opacity=".5"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#7c3aed"/>
<rect x="14" y="16" width="36" height="32" rx="4" fill="#ede9fe"/>
<rect x="20" y="22" width="10" height="8" rx="1.5" fill="#7c3aed"/>
<rect x="34" y="22" width="10" height="8" rx="1.5" fill="#a78bfa"/>
<rect x="20" y="34" width="24" height="6" rx="1.5" fill="#7c3aed"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

+414 -59
View File
@@ -18,8 +18,60 @@
wireDeviceId: null,
anim: 0,
raf: 0,
peerOrder: {}, // switchId -> [port,...]
cableColors: {}, // `${switchId}:${port}` -> #hex
};
const CABLE_PALETTE = ["#ff9a3c", "#3dffe0", "#7ec8ff", "#ff5c5c", "#c4a7ff", "#3dffa0", "#f0e68c", "#f472b6"];
function loadFabricPrefs() {
try {
const raw = JSON.parse(localStorage.getItem("cockpit_fabric_prefs") || "{}");
if (raw.peerOrder) state.peerOrder = raw.peerOrder;
if (raw.cableColors) state.cableColors = raw.cableColors;
} catch (_) {}
}
function saveFabricPrefs() {
try {
localStorage.setItem(
"cockpit_fabric_prefs",
JSON.stringify({ peerOrder: state.peerOrder, cableColors: state.cableColors })
);
} catch (_) {}
}
loadFabricPrefs();
function cableColorKey(port) {
return `${state.selectedSwitchId || 0}:${port}`;
}
function getCableColor(port) {
return state.cableColors[cableColorKey(port)] || CABLE_PALETTE[(Number(port) - 1) % CABLE_PALETTE.length];
}
function setCableColor(port, hex) {
state.cableColors[cableColorKey(port)] = hex;
saveFabricPrefs();
}
function hexToRgba(hex, a) {
const h = String(hex || "#ff9a3c").replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full, 16);
if (!Number.isFinite(n)) return `rgba(255,154,60,${a})`;
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r},${g},${b},${a})`;
}
function orderedWiredPorts(ports) {
const wired = (ports || []).filter((p) => p.wired && p.link?.device);
const order = state.peerOrder[state.selectedSwitchId] || [];
const rank = new Map(order.map((p, i) => [Number(p), i]));
return wired.slice().sort((a, b) => {
const ra = rank.has(a.port) ? rank.get(a.port) : 1000 + a.port;
const rb = rank.has(b.port) ? rank.get(b.port) : 1000 + b.port;
return ra - rb;
});
}
async function api(method, url, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
@@ -207,6 +259,8 @@
<span class="sw-wired-pill">${pm.wired_count}/${sw.port_count} ports wired</span>
</div>
</div>
<div class="sw-cable-stage" id="sw-cable-stage">
<canvas id="sw-cable-canvas" aria-hidden="true"></canvas>
<div class="sw-port-grid" id="sw-port-grid">
${ports
.map((p) => {
@@ -228,19 +282,24 @@
})
.join("")}
</div>
<div class="sw-cable-stage">
<canvas id="sw-cable-canvas"></canvas>
<div class="sw-peer-rail" id="sw-peer-rail">
${ports
.filter((p) => p.wired && p.link?.device)
<p class="sw-peer-hint">Drag servers to rearrange · pick cable color per link</p>
${orderedWiredPorts(ports)
.map((p) => {
const d = p.link.device;
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" data-peer-port="${p.port}">
const col = getCableColor(p.port);
const nicLabel = p.link.device_port || "—";
return `<div class="sw-peer-chip ${d.connected ? "up" : "down"}" draggable="true" data-peer-port="${p.port}" style="--cable:${escape(col)}">
<span class="sw-peer-grip" title="Drag to reorder" aria-hidden="true"></span>
<span class="sw-peer-port">P${p.port}</span>
<div>
<div class="sw-peer-body">
<strong>${escape(d.name)}</strong>
<span class="mono">NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")}</span>
<span class="mono">${escape(nicLabel)} · ST=${escape(d.service_tag || "—")}</span>
<span class="sw-peer-status">${d.connected ? "connected" : "offline"}</span>
</div>
<label class="sw-cable-color" title="Cable / pulse color">
<input type="color" value="${escape(col)}" data-cable-color="${p.port}" />
</label>
</div>`;
})
.join("") || '<p class="hint">No ports wired yet — click a port on the switch.</p>'}
@@ -276,7 +335,65 @@
$("#sw-wire-save")?.addEventListener("click", saveWire);
$("#sw-wire-clear")?.addEventListener("click", clearWire);
$("#sw-wire-device")?.addEventListener("change", onWireDeviceChange);
if ($("#sw-wire-device")?.value) onWireDeviceChange();
bindPeerRail($("#sw-peer-rail"));
requestAnimationFrame(() => {
requestAnimationFrame(() => drawCables());
});
}
function bindPeerRail(rail) {
if (!rail) return;
let dragPort = null;
rail.querySelectorAll("[data-cable-color]").forEach((inp) => {
inp.addEventListener("input", (e) => {
e.stopPropagation();
const port = Number(inp.dataset.cableColor);
setCableColor(port, inp.value);
const chip = inp.closest(".sw-peer-chip");
if (chip) chip.style.setProperty("--cable", inp.value);
drawCables();
});
inp.addEventListener("click", (e) => e.stopPropagation());
inp.addEventListener("mousedown", (e) => e.stopPropagation());
});
rail.querySelectorAll(".sw-peer-chip[draggable]").forEach((chip) => {
chip.addEventListener("dragstart", (e) => {
dragPort = Number(chip.dataset.peerPort);
chip.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(dragPort));
});
chip.addEventListener("dragend", () => {
chip.classList.remove("dragging");
rail.querySelectorAll(".sw-peer-chip").forEach((n) => n.classList.remove("drag-over"));
dragPort = null;
drawCables();
});
chip.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
chip.classList.add("drag-over");
});
chip.addEventListener("dragleave", () => chip.classList.remove("drag-over"));
chip.addEventListener("drop", (e) => {
e.preventDefault();
chip.classList.remove("drag-over");
const from = Number(e.dataTransfer.getData("text/plain") || dragPort);
const to = Number(chip.dataset.peerPort);
if (!from || !to || from === to) return;
const ports = orderedWiredPorts(state.portmap?.ports || []).map((p) => p.port);
const fi = ports.indexOf(from);
const ti = ports.indexOf(to);
if (fi < 0 || ti < 0) return;
ports.splice(fi, 1);
ports.splice(ti, 0, from);
state.peerOrder[state.selectedSwitchId] = ports;
saveFabricPrefs();
renderFabric();
startPulse();
});
});
}
function renderWirePane(selPort, servers) {
@@ -304,7 +421,10 @@
</select>
</label>
<label class="sw-field">Server port / NIC
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="e.g. NIC.Slot.1-1 / eth0 / Port 2" value="${escape(curPort)}" />
<select id="sw-wire-nic-pick">
<option value=""> load after selecting server </option>
</select>
<input type="text" id="sw-wire-nic" list="sw-nic-list" placeholder="PortId / FQDD (e.g. NIC.Slot.1-1)" value="${escape(curPort)}" />
<datalist id="sw-nic-list"></datalist>
</label>
<label class="sw-field">Note
@@ -330,21 +450,51 @@
const id = Number($("#sw-wire-device")?.value || 0);
state.wireDeviceId = id || null;
const list = $("#sw-nic-list");
if (!list || !id) return;
list.innerHTML = "";
const pick = $("#sw-wire-nic-pick");
const input = $("#sw-wire-nic");
if (list) list.innerHTML = "";
if (pick) {
pick.innerHTML = id
? `<option value="">Loading NIC ports from OME…</option>`
: `<option value="">— select a server first —</option>`;
}
if (!id) return;
try {
const data = await api("GET", `/api/devices/${id}/nics`);
(data.nics || []).forEach((n) => {
const rows = data.nics || [];
if (pick) {
pick.innerHTML =
`<option value="">— ${rows.length ? "select NIC port" : "no NIC ports in OME"} —</option>` +
rows
.map((n) => {
const val = n.port_id || n.fqdd || n.name || "";
const label = n.label || [n.port_id || n.fqdd, n.name, n.mac].filter(Boolean).join(" · ");
return `<option value="${escape(val)}" title="${escape(label)}">${escape(label)}</option>`;
})
.join("");
if (input?.value) {
const match = [...pick.options].find((o) => o.value && o.value === input.value);
if (match) pick.value = match.value;
}
pick.onchange = () => {
if (pick.value && input) input.value = pick.value;
};
}
rows.forEach((n) => {
if (!list) return;
const opt = document.createElement("option");
opt.value = n.fqdd || n.name;
opt.label = `${n.name}${n.mac ? " · " + n.mac : ""}`;
const val = n.port_id || n.fqdd || n.name || "";
opt.value = val;
opt.label = n.label || val;
list.appendChild(opt);
});
if ((data.nics || [])[0] && !$("#sw-wire-nic")?.value) {
$("#sw-wire-nic").value = data.nics[0].fqdd || data.nics[0].name || "";
if (rows[0] && input && !input.value) {
const first = rows[0].port_id || rows[0].fqdd || rows[0].name || "";
input.value = first;
if (pick) pick.value = first;
}
} catch (_) {
/* ignore */
if (pick) pick.innerHTML = `<option value="">NIC lookup failed — type PortId manually</option>`;
}
}
@@ -389,7 +539,7 @@
const canvas = $("#sw-cable-canvas");
const grid = $("#sw-port-grid");
const rail = $("#sw-peer-rail");
const stage = canvas?.parentElement;
const stage = $("#sw-cable-stage") || canvas?.parentElement;
if (!canvas || !grid || !stage || state.tab !== "fabric") return;
const dpr = window.devicePixelRatio || 1;
const w = stage.clientWidth;
@@ -414,39 +564,57 @@
if (!portBtn || !peer) return;
const a = portBtn.getBoundingClientRect();
const b = peer.getBoundingClientRect();
/* Attach to port bottom-center → peer left-center (all in stage coords) */
const x1 = a.left + a.width / 2 - stageBox.left;
const y1 = a.bottom - stageBox.top;
const x2 = b.left - stageBox.left;
const y2 = b.top + b.height / 2 - stageBox.top;
const live = !!p.link?.device?.connected;
const midY = y1 + Math.max(24, (y2 - y1) * 0.35);
if (!Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(x2) || !Number.isFinite(y2)) return;
const live = !!p.link?.device?.connected;
const col = getCableColor(p.port);
const dy = Math.max(28, y2 - y1);
const c1x = x1;
const c1y = y1 + dy * 0.45;
const c2x = x2 - Math.min(80, Math.max(36, (x2 - x1) * 0.35));
const c2y = y2;
/* Base dashed cable — user-chosen color */
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2);
ctx.strokeStyle = live ? "rgba(61,255,224,0.55)" : "rgba(0,168,232,0.35)";
ctx.lineWidth = live ? 2.4 : 1.6;
ctx.setLineDash([8, 7]);
ctx.lineDashOffset = -t * (live ? 55 : 22);
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
ctx.strokeStyle = hexToRgba(col, live ? 0.72 : 0.4);
ctx.lineWidth = live ? 2.6 : 2.2;
ctx.lineCap = "round";
ctx.setLineDash([7, 6]);
ctx.lineDashOffset = -t * (live ? 50 : 28);
ctx.stroke();
if (live) {
/* Glow pulse */
const pulse = (Math.sin(t * 5 + p.port) + 1) / 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(x1, midY, x2 - 40, y2, x2, y2);
ctx.strokeStyle = `rgba(255,154,60,${0.2 + pulse * 0.55})`;
ctx.lineWidth = 3.2;
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2);
ctx.strokeStyle = hexToRgba(col, (live ? 0.22 : 0.1) + pulse * (live ? 0.5 : 0.25));
ctx.lineWidth = live ? 3.4 : 2.8;
ctx.setLineDash([]);
ctx.stroke();
}
// moving packet
/* Moving packet */
const u = (t * 0.35 + p.port * 0.07) % 1;
const pt = bezierPoint(x1, y1, x1, midY, x2 - 40, y2, x2, y2, u);
const pt = bezierPoint(x1, y1, c1x, c1y, c2x, c2y, x2, y2, u);
ctx.beginPath();
ctx.arc(pt.x, pt.y, live ? 3.2 : 2.2, 0, Math.PI * 2);
ctx.fillStyle = live ? "#ff9a3c" : "#00a8e8";
ctx.arc(pt.x, pt.y, live ? 3.4 : 2.8, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
/* Anchor dots */
ctx.beginPath();
ctx.arc(x1, y1, 2.5, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
ctx.beginPath();
ctx.arc(x2, y2, 2.5, 0, Math.PI * 2);
ctx.fill();
});
}
@@ -744,7 +912,7 @@
<header class="rd-overview-head">
<div>
<h3>Rack floor · ${escape(state.site === "Both" ? "ATC1 + ATC2" : state.site)}</h3>
<p>Klik een rack om de Design Studio te openen (inzoomen, alle 42U, Dell catalogus).</p>
<p>Click a rack to open Design Studio (zoom, all 42U, Dell catalog).</p>
</div>
<div class="rd-legend">
<span class="lg server">Server</span><span class="lg switch">Switch</span>
@@ -865,7 +1033,7 @@
<main class="rs-canvas-wrap">
<div class="rs-statusbar">
<span id="rs-status-text">Sleep stencils uit de bibliotheek naar het rack · drop op een U</span>
<span id="rs-status-text">Drag stencils from the library onto a U</span>
<span class="rs-status-side">${escape((state.rackSide || "front").toUpperCase())} view</span>
</div>
<div class="rs-canvas visio" id="rs-canvas">
@@ -1052,7 +1220,7 @@
} catch (_) {}
}
el.classList.add("rs-dragging");
setStatus(`Sleep ${name} (${uHeight}U) — drop op de onderkant-U (belegt omhoog)`);
setStatus(`Dragging ${name} (${uHeight}U) — drop on the bottom U (fills upward)`);
node.classList.add("dragging");
}
@@ -1074,7 +1242,7 @@
e.dataTransfer.setData("application/x-rack-item", raw);
e.dataTransfer.setData("text/plain", raw);
el.classList.add("rs-dragging");
setStatus(`Verplaatsen (${h}U): drop op nieuwe onderkant-U`);
setStatus(`Moving (${h}U): drop on a new bottom U`);
node.classList.add("dragging");
}
@@ -1086,7 +1254,7 @@
el.classList.remove("rs-dragging");
if (ghost) ghost.hidden = true;
dragPayload = null;
setStatus("Sleep stencils uit de bibliotheek naar het rack · drop op onderkant-U");
setStatus("Drag stencils from the library onto a bottom U");
});
// click still selects for keyboard users
node.addEventListener("click", () => {
@@ -1099,7 +1267,7 @@
state.placeSku = node.dataset.sku || null;
state.placeH = Number(node.dataset.h || 1);
}
setStatus(`Geselecteerd: ${node.dataset.name}sleep of klik een U`);
setStatus(`Selected: ${node.dataset.name}drag or click a U`);
el.querySelectorAll(".rs-stencil").forEach((n) => n.classList.toggle("active", n === node));
});
});
@@ -1130,7 +1298,7 @@
if (ok) {
setStatus(`Drop → ${span.label} (${span.height}U)`);
} else {
setStatus(`Past niet op ${span.label} — overlap of buiten U1U${rk?.units || 42}`);
setStatus(`Does not fit on ${span.label} — overlap or outside U1U${rk?.units || 42}`);
}
});
zone.addEventListener("dragleave", (e) => {
@@ -1153,7 +1321,7 @@
const rk = focusRack();
const exclude = payload.kind === "move" ? payload.item_id : null;
if (!unitsFree(rk, u, h, exclude)) {
setStatus("Drop geblokkeerd — U-bereik niet vrij");
setStatus("Drop blocked — U range is not free");
return;
}
state._dragMoved = true;
@@ -1182,10 +1350,10 @@
}
renderRacks();
renderStudio();
setStatus(`Geplaatst op ${uSpan(u, h).label}`);
setStatus(`Placed on ${uSpan(u, h).label}`);
} catch (err) {
alert("Drop failed: " + err.message);
setStatus("Drop mislukt — probeer een vrije U");
setStatus("Drop failed — try a free U");
}
});
});
@@ -1198,7 +1366,7 @@
const h = state.placeSku ? catalogHeight(state.placeSku, state.placeH || 1) : Math.max(1, state.placeH || 1);
const rk = focusRack();
if (!unitsFree(rk, u, h, null)) {
setStatus(`Past niet op ${uSpan(u, h).label}`);
setStatus(`Does not fit on ${uSpan(u, h).label}`);
return;
}
state.placeU = u;
@@ -1213,7 +1381,7 @@
const rk = focusRack();
const ok = unitsFree(rk, u, h, null);
highlightSpan(u, h, ok);
setStatus(ok ? `Klik → ${uSpan(u, h).label}` : `Past niet op ${uSpan(u, h).label}`);
setStatus(ok ? `Click → ${uSpan(u, h).label}` : `Does not fit on ${uSpan(u, h).label}`);
});
node.addEventListener("mouseleave", () => {
if (!dragPayload) clearDropHighlight();
@@ -1236,7 +1404,7 @@
const side = state.rackSide || "front";
const rk = focusRack();
if (!unitsFree(rk, uStart, height, null)) {
alert(`Past niet op ${uSpan(uStart, height).label}`);
alert(`Does not fit on ${uSpan(uStart, height).label}`);
return;
}
try {
@@ -1365,12 +1533,68 @@
}
/* ===== VLANs ===== */
function rackInfoForDevice(deviceId) {
if (deviceId == null || !state.racks?.sites) return null;
for (const site of state.racks.sites) {
for (const rk of site.racks || []) {
for (const it of rk.items || []) {
if (it.device_id === deviceId) {
const span = uSpan(it.u_start, it.u_height || 1);
return {
site: site.name || site.id,
rack: rk.name,
uLabel: span.label,
side: it.side || "front",
};
}
}
}
}
return null;
}
function switchLinksForDevice(deviceId) {
const fromMember = [];
// Prefer API-enriched port_links (all switches), fall back to selected faceplate
const all = state.vlans?.port_links || [];
all
.filter((l) => l.device_id === deviceId)
.forEach((l) => fromMember.push({ port: l.switch_port, switch_id: l.switch_id }));
if (fromMember.length) return fromMember;
const ports = state.portmap?.ports || [];
return ports.filter((p) => p.wired && p.link?.device_id === deviceId);
}
function formatSwitchPorts(ports) {
if (!ports || !ports.length) return "none mapped";
return ports
.map((p) => {
const name = p.switch_name || (p.switch_id != null ? `sw ${p.switch_id}` : "switch");
const port = p.switch_port != null ? `P${p.switch_port}` : p.port != null ? `P${p.port}` : "port ?";
const hint = p.inferred ? " (hint)" : "";
return `${name} · ${port}${hint}`;
})
.join("; ");
}
function renderVlans() {
const root = $("#network-vlans");
if (!root || !state.vlans) return;
const vlans = state.vlans.vlans || [];
if (state.selectedVlanId == null && vlans[0]) state.selectedVlanId = vlans[0].id;
const sel = vlans.find((v) => v.id === state.selectedVlanId) || vlans[0];
const members = sel?.members || [];
const up = members.filter((m) => m.connected).length;
const roles = {};
members.forEach((m) => {
const r = (m.role || "other").toLowerCase();
roles[r] = (roles[r] || 0) + 1;
});
const matrix = state.vlans.attachment_matrix || [];
const matrixFocus = matrix.filter((row) => {
if (!sel) return true;
return (row.vlans || []).some((v) => v.vlan_id === sel.vlan_id || v.cidr === sel.cidr);
});
root.innerHTML = `
<div class="vlan-layout">
@@ -1381,11 +1605,11 @@
${vlans
.map((v) => {
const active = v.id === state.selectedVlanId ? " active" : "";
return `<button type="button" class="vlan-card${active}" data-vlan="${v.id}" style="--vlan:${escape(v.color || "#00a8e8")}">
return `<button type="button" class="vlan-card${active}" data-vlan="${v.id}" style="--vlan:${escape(v.color || "#ff9a3c")}">
<span class="vlan-id">VLAN ${v.vlan_id ?? "—"}</span>
<strong>${escape(v.name)}</strong>
<span class="mono">${escape(v.cidr)}</span>
<span>${v.member_count} endpoints · ${v.connected_count} connected</span>
<span class="vlan-card-meta">${v.member_count} nodes · ${v.connected_count} up · ${escape(v.site_id || "—")}</span>
</button>`;
})
.join("")}
@@ -1394,7 +1618,7 @@
? `<h4>Discovered (not in catalog)</h4>
<ul class="vlan-discovered">
${(state.vlans.discovered_subnets || [])
.map((d) => `<li><span class="mono">${escape(d.cidr)}</span> · ${d.device_ips} IPs</li>`)
.map((d) => `<li><span class="mono">${escape(d.cidr)}</span> · ${d.device_ips} device IPs seen</li>`)
.join("")}
</ul>`
: ""}
@@ -1402,22 +1626,113 @@
<section class="vlan-detail-pane">
${
sel
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#00a8e8")}">
? `<header class="vlan-detail-head" style="border-color:${escape(sel.color || "#ff9a3c")}">
<div>
<h3>VLAN ${sel.vlan_id ?? "—"} · ${escape(sel.name)}</h3>
<p class="mono">${escape(sel.cidr)} · ${escape(sel.site_id || "—")} · ${escape(sel.purpose || "")}</p>
<p class="mono">${escape(sel.cidr)}</p>
<p class="vlan-purpose">${escape(sel.purpose || "No purpose set")} · site ${escape(sel.site_id || "—")}</p>
</div>
<div class="vlan-stat-stack">
<span class="vlan-stat">${members.length} nodes</span>
<span class="vlan-stat soft">${up} connected</span>
</div>
<span class="vlan-stat">${sel.member_count} members</span>
</header>
<div class="vlan-members">
${(sel.members || [])
<div class="vlan-context">
<div class="vlan-kpi"><strong>${members.length}</strong><span>Endpoints</span></div>
<div class="vlan-kpi"><strong>${up}</strong><span>Online</span></div>
<div class="vlan-kpi"><strong>${members.length - up}</strong><span>Offline</span></div>
<div class="vlan-kpi"><strong>${Object.keys(roles).length}</strong><span>Roles</span></div>
</div>
${
Object.keys(roles).length
? `<div class="vlan-role-pills">${Object.entries(roles)
.sort((a, b) => b[1] - a[1])
.map(([r, n]) => `<span class="vlan-role-pill">${escape(r)} · ${n}</span>`)
.join("")}</div>`
: ""
}
<h4 class="vlan-nodes-h">Server switch / VLAN map</h4>
<div class="vlan-matrix-wrap">
<table class="vlan-matrix">
<thead>
<tr>
<th>Server</th>
<th>Model</th>
<th>VLANs</th>
<th>Switch / port</th>
<th>Note</th>
</tr>
</thead>
<tbody>
${
(matrixFocus.length ? matrixFocus : matrix)
.slice(0, 120)
.map((row) => {
const vlanBits = (row.vlans || [])
.map(
(m) => `<div class="vlan-member ${m.connected ? "up" : "down"}">
<strong>${escape(m.name)}</strong>
<span class="mono">ST=${escape(m.service_tag || "—")} · ${escape(m.role || "")} · ${(m.ips || []).join(", ")}</span>
</div>`
(v) =>
`<span class="vlan-chip" style="--vlan:${escape(v.color || "#888")}">V${v.vlan_id ?? "?"} ${escape(v.name || "")}</span>`
)
.join("") || '<p class="hint">No live members matched this CIDR.</p>'}
.join(" ");
const note = row.user_note || row.endpoint_note || "";
return `<tr>
<td>
<strong>${escape(row.name)}</strong>
<div class="muted mono">${escape((row.ips || []).slice(0, 3).join(", "))}${row.service_tag ? " · " + escape(row.service_tag) : ""}</div>
</td>
<td>${escape(row.model || "—")}</td>
<td>${vlanBits || "—"}</td>
<td class="mono">${escape(formatSwitchPorts(row.switch_ports))}</td>
<td class="vlan-note-cell">${escape(note || "—")}</td>
</tr>`;
})
.join("") || `<tr><td colspan="5" class="hint">No server attachments yet.</td></tr>`
}
</tbody>
</table>
</div>
<h4 class="vlan-nodes-h">Add / note a host</h4>
<form class="vlan-add-host" id="vlan-add-host">
<input name="hostname" placeholder="hostname" required />
<input name="ip" class="mono" placeholder="10.0.x.x" required />
<input name="model" placeholder="model (optional)" />
<input name="user_note" placeholder="manual note" />
<button type="submit" class="btn">Add host</button>
</form>
<h4 class="vlan-nodes-h">Coupled nodes</h4>
<div class="vlan-members">
${
members
.map((m) => {
const rack = rackInfoForDevice(m.id);
const links = m.switch_ports?.length ? m.switch_ports : switchLinksForDevice(m.id);
const src = m.source === "inventory" ? "inventory" : m.ome_name && m.ome_name !== m.name ? m.ome_name : "";
const eid = m.endpoint_id;
return `<article class="vlan-member ${m.connected ? "up" : "down"}" data-endpoint-id="${eid || ""}">
<div class="vlan-member-top">
<strong>${escape(m.name)}</strong>
<span class="vlan-badge ${m.connected ? "on" : "off"}">${m.connected ? "online" : "offline"}</span>
</div>
<div class="vlan-member-grid">
<span><em>Role</em> ${escape(m.role || "")}</span>
<span><em>Model</em> ${escape(m.model || "")}</span>
<span><em>Service Tag</em> <span class="mono">${escape(m.service_tag || "")}</span></span>
<span><em>IPs in VLAN</em> <span class="mono">${escape((m.ips || []).join(", ") || "")}</span></span>
<span><em>Rack</em> ${rack ? `${escape(rack.site)} / ${escape(rack.rack)} · ${rack.uLabel}` : "not placed"}</span>
<span><em>Switch ports</em> ${escape(formatSwitchPorts(links))}</span>
${src ? `<span><em>OME</em> ${escape(src)}</span>` : ""}
${m.endpoint_note ? `<span><em>System note</em> ${escape(m.endpoint_note)}</span>` : ""}
</div>
<div class="vlan-note-edit">
<label>Manual note</label>
<textarea data-note-for="${eid || ""}" rows="2" placeholder="${eid ? "Type a note and save" : "Host not yet in inventory use Add host above"}"${eid ? "" : " disabled"}>${escape(m.user_note || "")}</textarea>
<button type="button" class="btn ghost" data-save-note="${eid || ""}" ${eid ? "" : "disabled"}>Save note</button>
<span class="vlan-note-status" data-note-status="${eid || ""}"></span>
</div>
</article>`;
})
.join("") || '<p class="hint">No live members matched this CIDR yet.</p>'
}
</div>`
: '<p class="hint">No VLANs</p>'
}
@@ -1431,6 +1746,46 @@
renderVlans();
});
});
root.querySelectorAll("[data-save-note]").forEach((btn) => {
btn.addEventListener("click", async () => {
const eid = Number(btn.dataset.saveNote);
if (!eid) return;
const ta = root.querySelector(`textarea[data-note-for="${eid}"]`);
const status = root.querySelector(`[data-note-status="${eid}"]`);
try {
if (status) status.textContent = "Saving…";
const res = await api("PATCH", `/api/network/endpoints/${eid}`, { user_note: ta?.value || "" });
if (res.vlans) state.vlans = res.vlans;
if (status) status.textContent = "Saved";
renderVlans();
} catch (e) {
if (status) status.textContent = "Error: " + e.message;
}
});
});
const addForm = root.querySelector("#vlan-add-host");
addForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(addForm);
const body = {
hostname: String(fd.get("hostname") || "").trim(),
ip: String(fd.get("ip") || "").trim(),
model: String(fd.get("model") || "").trim() || null,
user_note: String(fd.get("user_note") || "").trim() || null,
vlan_id: sel?.vlan_id ?? null,
role: "server",
kind: "host",
};
try {
const res = await api("POST", "/api/network/endpoints", body);
if (res.vlans) state.vlans = res.vlans;
renderVlans();
} catch (err) {
alert("Add host failed: " + err.message);
}
});
}
function bind() {
+126 -8
View File
@@ -43,6 +43,12 @@
let activeTicketId = null;
let gpuData = null;
let chatModelsLoaded = false;
let opsUsers = [
{ id: "jody", name: "Jody van Dongen", team: "admin", role: "ATC Datacenter Admin" },
{ id: "laurens", name: "Laurens Rammers", team: "admin", role: "ATC Datacenter Admin" },
{ id: "mo", name: "Mohamed El Kadi", team: "fde", role: "Data Forward Deployed Engineer" },
{ id: "bart", name: "Bart Sjerps", team: "fde", role: "Data Forward Deployed Engineer" },
];
function selectedChatModel() {
const sel = $("#chat-model");
@@ -79,7 +85,44 @@
}
function adminName(id) {
return id === "laurens" ? "Laurens Rammers" : "Jody van Dongen";
const u = opsUsers.find((x) => x.id === id);
return u?.name || id || "—";
}
function userTeam(id) {
return opsUsers.find((x) => x.id === id)?.team || "admin";
}
function defaultAssignee(me) {
if (me === "jody") return "laurens";
if (me === "laurens") return "jody";
if (me === "mo") return "bart";
if (me === "bart") return "mo";
return "jody";
}
function assigneeOptionsHtml(selected) {
const admins = opsUsers.filter((u) => u.team === "admin");
const fde = opsUsers.filter((u) => u.team === "fde");
const opt = (u) =>
`<option value="${u.id}" ${u.id === selected ? "selected" : ""}>${escape(u.name)}</option>`;
return `<optgroup label="ATC Admins">${admins.map(opt).join("")}</optgroup>
<optgroup label="FDE team">${fde.map(opt).join("")}</optgroup>`;
}
async function loadOpsUsers() {
try {
const data = await api.get("/api/ops/users");
if (data.users?.length) opsUsers = data.users;
} catch (_) {
try {
const data = await api.get("/api/admins");
if (data.users?.length) opsUsers = data.users;
else if (data.admins?.length) opsUsers = data.admins;
} catch (_) {
/* keep defaults */
}
}
}
function openDrawer(id) {
@@ -224,20 +267,42 @@
function filteredTicketList() {
const f = ticketFilterValue();
const me = actor();
const fdeIds = new Set(opsUsers.filter((u) => u.team === "fde").map((u) => u.id));
const adminIds = new Set(opsUsers.filter((u) => u.team === "admin").map((u) => u.id));
return (tickets || []).filter((t) => {
if (f === "all") return true;
if (f === "mine") return t.assignee === me;
if (f === "created") return t.created_by === me;
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status);
if (f === "fde") return fdeIds.has(t.assignee) || fdeIds.has(t.created_by);
if (f === "admin") return adminIds.has(t.assignee) && adminIds.has(t.created_by);
return t.status === f;
});
}
function renderOpsStats() {
const box = $("#ops-stats");
if (!box) return;
const all = tickets || [];
const active = all.filter((t) => ["open", "accepted", "in_progress"].includes(t.status)).length;
const mine = all.filter((t) => t.assignee === actor()).length;
const crit = all.filter((t) => t.priority === "critical" || t.priority === "high").length;
box.innerHTML = `
<div class="ops-stat"><em>${all.length}</em><span>Total</span></div>
<div class="ops-stat"><em>${active}</em><span>Active</span></div>
<div class="ops-stat"><em>${mine}</em><span>Assigned to me</span></div>
<div class="ops-stat"><em>${crit}</em><span>High / critical</span></div>`;
}
async function loadTickets() {
const data = await api.get("/api/tickets");
tickets = data.tickets || [];
if (data.users?.length) opsUsers = data.users;
else if (data.admins?.length) opsUsers = data.admins;
const list = $("#ticket-list");
if (!list) return;
filteredTickets = filteredTicketList();
renderOpsStats();
const count = $("#ops-count");
if (count) {
count.textContent =
@@ -257,9 +322,10 @@
const when = tkt.updated_at
? new Date(tkt.updated_at * 1000).toLocaleString()
: "";
const team = userTeam(tkt.assignee);
return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
<span class="t">#${tkt.id} · ${escape(tkt.title)}</span>
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · ${escape(adminName(tkt.created_by))} ${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}${when ? " · " + escape(when) : ""}</span>
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · <span class="ops-team-pill ${team}">${team}</span> · ${escape(adminName(tkt.created_by))} ${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}${when ? " · " + escape(when) : ""}</span>
</button>`;
})
.join("");
@@ -318,8 +384,7 @@
<option value="closed">Closed</option>
</select>
<select id="ops-assign">
<option value="jody">Assign Jody</option>
<option value="laurens">Assign Laurens</option>
${assigneeOptionsHtml(tkt.assignee || "jody")}
</select>
<select id="ops-priority">
<option value="low">Low</option>
@@ -347,7 +412,7 @@
.join("")}
</div>
<form class="ops-reply" id="ops-reply">
<textarea id="ops-reply-body" rows="3" placeholder="Write a note to the other admin…"></textarea>
<textarea id="ops-reply-body" rows="3" placeholder="Write a note to the team…"></textarea>
<button class="btn primary" type="submit">Send note</button>
</form>`;
@@ -401,11 +466,13 @@
function openTicketModal(draft = null) {
ticketDraft = draft;
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
const other = draft?.assignee || defaultAssignee(me);
const assigneeSel = $("#ticket-assignee");
if (assigneeSel) assigneeSel.innerHTML = assigneeOptionsHtml(other);
$("#ticket-title").value = draft?.title || "";
$("#ticket-body").value = draft?.body || "";
$("#ticket-priority").value = draft?.priority || "normal";
$("#ticket-assignee").value = draft?.assignee || other;
$("#ticket-assignee").value = other;
const modal = $("#ticket-modal");
const scrim = $("#scrim");
modal?.classList.remove("hidden");
@@ -800,7 +867,7 @@
async function createTicketFromTriage(it, node) {
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
const other = userTeam(me) === "fde" ? "jody" : defaultAssignee(me);
const sev = it.severity || it.kind || "alert";
const title = ("[" + sev + "] " + it.title).slice(0, 180);
const body = [
@@ -833,6 +900,7 @@
function wire() {
$("#ops-actor")?.addEventListener("change", (e) => {
localStorage.setItem("atc_actor", e.target.value);
loadTickets();
});
const saved = localStorage.getItem("atc_actor");
if (saved && $("#ops-actor")) $("#ops-actor").value = saved;
@@ -877,9 +945,59 @@
$("#btn-chat-close")?.addEventListener("click", closeDrawers);
$("#btn-ops")?.addEventListener("click", async () => {
openDrawer("#ops-drawer");
await loadOpsUsers();
await loadTickets();
$("#ops-empty")?.classList.remove("hidden");
$("#ops-thread")?.classList.add("hidden");
activeTicketId = null;
});
$("#btn-ops-close")?.addEventListener("click", closeDrawers);
$("#btn-ops-refresh")?.addEventListener("click", () => loadTickets().catch((e) => alert(e.message)));
$("#ops-quick")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-ops-quick]");
if (!btn) return;
const q = btn.dataset.opsQuick;
if (q === "ticket") {
openTicketModal();
return;
}
if (q === "to-admin") {
openTicketModal({
title: "Handoff to ATC admin",
body: "Context for Jody / Laurens:\n\n",
assignee: "jody",
priority: "normal",
});
return;
}
if (q === "to-fde") {
openTicketModal({
title: "Data FDE / AI cluster request",
body: "Request for Mohamed El Kadi / Bart Sjerps (Data Forward Deployed Engineers · AI workloads):\n\n",
assignee: "mo",
priority: "normal",
});
return;
}
if (q === "active") {
const sel = $("#ops-filter");
if (sel) {
sel.value = "openish";
localStorage.setItem("cockpit_ops_filter", "openish");
}
loadTickets();
return;
}
if (q === "chat") {
openDrawer("#chat-drawer");
loadChatModels();
return;
}
if (q === "reports") {
window.cockpitReports?.open?.();
}
});
$("#ops-filter")?.addEventListener("change", (e) => {
localStorage.setItem("cockpit_ops_filter", e.target.value);
+1230
View File
File diff suppressed because it is too large Load Diff
+465 -32
View File
@@ -375,41 +375,153 @@
return [...map.entries()].sort((a, b) => b[1] - a[1]);
}
function barRows(entries, maxN, fillClass) {
function barRows(entries, maxN, fillClass, kind) {
const top = entries.slice(0, maxN);
const max = Math.max(1, ...top.map(([, n]) => n));
return top
.map(
([label, n]) => `<div class="an-bar-row">
<div class="an-bar-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>
([label, n]) => `<button type="button" class="an-bar-row an-clickable" data-an-kind="${escapeHtml(kind || "bar")}" data-an-key="${escapeHtml(label)}" data-an-count="${n}" title="Open context for ${escapeHtml(label)}">
<div class="an-bar-label">${escapeHtml(label)}</div>
<div class="an-bar-track"><div class="an-bar-fill ${fillClass || ""}" style="width:${Math.round((n / max) * 100)}%"></div></div>
<div class="an-bar-val">${n}</div>
</div>`
</button>`
)
.join("");
}
function donut(slices) {
function donut(slices, chartId) {
const total = slices.reduce((s, x) => s + x.value, 0) || 1;
let acc = 0;
const parts = slices.map((s) => {
const parts = [];
const meta = [];
slices.forEach((s) => {
const start = (acc / total) * 100;
acc += s.value;
const end = (acc / total) * 100;
return `${s.color} ${start}% ${end}%`;
parts.push(`${s.color} ${start}% ${end}%`);
meta.push({
key: s.key || s.label,
label: s.label,
value: s.value,
color: s.color,
startPct: start,
endPct: end,
});
const legend = slices
});
const legend = meta
.map(
(s) =>
`<li><span class="an-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)} <strong>${s.value}</strong></li>`
`<li><button type="button" class="an-legend-btn an-clickable" data-an-kind="${escapeHtml(chartId || "donut")}" data-an-key="${escapeHtml(s.key)}" data-an-count="${s.value}" title="Open context: ${escapeHtml(s.label)}">
<span class="an-swatch" style="background:${s.color}"></span>
<span class="an-legend-text">${escapeHtml(s.label)}</span>
<strong>${s.value}</strong>
</button></li>`
)
.join("");
return `<div class="an-donut-wrap">
<div class="an-donut" style="--slices:${parts.join(", ")}"></div>
return `<div class="an-donut-wrap" data-an-chart="${escapeHtml(chartId || "")}" data-an-meta="${encodeURIComponent(JSON.stringify(meta))}">
<button type="button" class="an-donut an-donut-hit an-clickable" data-an-kind="${escapeHtml(chartId || "donut")}" data-an-donut="1" style="--slices:${parts.join(", ")}" title="Click a colored segment for context" aria-label="Chart ${escapeHtml(chartId || "")}"></button>
<ul class="an-legend">${legend}</ul>
</div>`;
}
function closeAnContext() {
const modal = $("#an-context-modal");
if (!modal) return;
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "an-context") {
// keep reports drawer open underneath
if ($("#reports-drawer")?.classList.contains("open")) {
scrim.dataset.mode = "reports-drawer";
scrim.classList.add("open");
} else {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
}
function openAnContext({ title, sub, pills, rows, actions }) {
const modal = $("#an-context-modal");
if (!modal) return;
$("#an-ctx-title").textContent = title || "Context";
$("#an-ctx-sub").textContent = sub || "";
$("#an-ctx-pills").innerHTML = (pills || [])
.map((p) => `<span class="pill">${escapeHtml(p)}</span>`)
.join("");
const list = $("#an-ctx-list");
if (!rows?.length) {
list.innerHTML = `<p class="hint">No matching records in the current OME snapshot.</p>`;
} else {
list.innerHTML = `
<table class="an-ctx-table">
<thead><tr>${(rows[0].cols || []).map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>
<tbody>
${rows
.map((r) => {
const cells = (r.cells || []).map((c) => `<td>${c}</td>`).join("");
const attr = r.jumpHw ? ` class="an-row-click" data-jump-hw="${r.jumpHw}"` : "";
return `<tr${attr}>${cells}</tr>`;
})
.join("")}
</tbody>
</table>
${rows.length >= 80 ? `<p class="an-note">Showing first ${rows.length} rows.</p>` : ""}`;
}
$("#an-ctx-actions").innerHTML = (actions || [])
.map(
(a) =>
`<button type="button" class="btn ${a.primary ? "primary" : "compact"}" data-an-action="${escapeHtml(a.id)}">${escapeHtml(a.label)}</button>`
)
.join("");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
const scrim = $("#scrim");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "an-context";
}
$("#an-ctx-close")?.addEventListener("click", closeAnContext, { once: true });
$("#an-ctx-actions")?.querySelectorAll("[data-an-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.anAction;
const act = (actions || []).find((x) => x.id === id);
if (act?.run) act.run();
closeAnContext();
});
});
list.querySelectorAll("[data-jump-hw]").forEach((el) => {
el.addEventListener("click", () => {
const id = Number(el.dataset.jumpHw);
if (!id) return;
closeAnContext();
state.hwDeviceId = id;
loadTab("hardware");
});
});
}
function sliceFromDonutClick(btn, evt) {
const wrap = btn.closest(".an-donut-wrap");
let meta = [];
try {
meta = JSON.parse(decodeURIComponent(wrap?.dataset.anMeta || "%5B%5D"));
} catch (_) {
meta = [];
}
if (!meta.length) return null;
const rect = btn.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = evt.clientX - cx;
const dy = evt.clientY - cy;
let deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
if (deg < 0) deg += 360;
const pct = (deg / 360) * 100;
return meta.find((s) => pct >= s.startPct && pct < s.endPct) || meta[meta.length - 1];
}
function renderAnalytics(mount) {
const fleet = state.fleet?.rows || [];
const sum = state.fleet?.summary || state.brief?.summary || {};
@@ -501,45 +613,55 @@
<div class="an-grid">
<section class="an-card">
<h3 title="Share of component compliance statuses from the Dell Online Baseline">Compliance mix</h3>
${donut([
{ label: "Critical", value: critComps || Number(byStatus.find(([k]) => k === "CRITICAL")?.[1] || 0), color: "#ff5c5c" },
{ label: "Warning", value: warnComps || Number(byStatus.find(([k]) => k === "WARNING")?.[1] || 0), color: "#ffb84d" },
${donut(
[
{ key: "CRITICAL", label: "Critical", value: critComps || Number(byStatus.find(([k]) => k === "CRITICAL")?.[1] || 0), color: "#ff5c5c" },
{ key: "WARNING", label: "Warning", value: warnComps || Number(byStatus.find(([k]) => k === "WARNING")?.[1] || 0), color: "#ffb84d" },
{
key: "OK",
label: "Other / OK",
value: Math.max(0, comps.length - critComps - warnComps),
color: "#3dffe0",
color: "#3dffa0",
},
])}
<p class="an-note">${upgradeComps} components marked UPGRADE against Dell catalog</p>
],
"compliance"
)}
<p class="an-note an-clickable" data-an-kind="compliance" data-an-key="UPGRADE" data-an-count="${upgradeComps}" title="Show all UPGRADE components">${upgradeComps} components marked UPGRADE against Dell catalog click for list</p>
</section>
<section class="an-card">
<h3 title="Which firmware families need the most upgrades">Outdated by component</h3>
<div class="an-bars">${barRows(byComponent, 8, "crit") || '<p class="an-note">No upgrade components</p>'}</div>
<div class="an-bars">${barRows(byComponent, 8, "crit", "component") || '<p class="an-note">No upgrade components</p>'}</div>
</section>
<section class="an-card">
<h3 title="Which server models appear most often in the non-compliant set">Outdated by model</h3>
<div class="an-bars">${barRows(byModel, 8, "warn") || '<p class="an-note">No outdated models</p>'}</div>
<div class="an-bars">${barRows(byModel, 8, "warn", "model") || '<p class="an-note">No outdated models</p>'}</div>
</section>
<section class="an-card">
<h3 title="OME ConnectionState across the fleet snapshot">Connection state</h3>
${donut([
{ label: "Connected", value: connected, color: "#3dffe0" },
{ label: "Offline", value: offline, color: "#5a6a7a" },
])}
${donut(
[
{ key: "connected", label: "Connected", value: connected, color: "#3dffa0" },
{ key: "offline", label: "Offline", value: offline, color: "#e53935" },
],
"connection"
)}
<p class="an-note">${escapeHtml(String(sum.total_watts != null ? Math.round(sum.total_watts) + " W sampled" : "Power samples from live poll"))}</p>
</section>
<section class="an-card">
<h3 title="Days remaining buckets from OME WarrantyService">Warranty health</h3>
${donut([
{ label: "Expired", value: expired, color: "#ff5c5c" },
{ label: "≤ 30 days", value: d30, color: "#ffb84d" },
{ label: "3190 days", value: d90, color: "#00a8e8" },
{ label: "> 90 days", value: okW, color: "#3dffe0" },
])}
${donut(
[
{ key: "expired", label: "Expired", value: expired, color: "#ff5c5c" },
{ key: "d30", label: "≤ 30 days", value: d30, color: "#ffb84d" },
{ key: "d90", label: "3190 days", value: d90, color: "#ff9a3c" },
{ key: "ok", label: "> 90 days", value: okW, color: "#3dffa0" },
],
"warranty"
)}
<p class="an-note">${warranties.length} warranty records from OME</p>
</section>
@@ -579,9 +701,318 @@
state.filters.severity = "critical";
loadTab("firmware");
});
function ctxRowsFromComps(list) {
return list.slice(0, 80).map((c) => ({
cols: ["ST", "Device", "Component", "Current → Catalog", "Status"],
cells: [
`<code class="st">${escapeHtml(c.service_tag || "—")}</code>`,
escapeHtml(c.device_name || "—"),
escapeHtml(shortComponent(c.component)),
`<span class="mono">${escapeHtml(c.current_version || "?")}${escapeHtml(c.catalog_version || "?")}</span>`,
badge(c.compliance_status || c.update_action),
],
jumpHw: c.device_id || null,
}));
}
function ctxRowsFromDevices(list) {
return list.slice(0, 80).map((d) => ({
cols: ["ST", "Name", "Model", "Status"],
cells: [
`<code class="st">${escapeHtml(d.service_tag || "—")}</code>`,
escapeHtml(d.name || d.device_name || "—"),
escapeHtml(d.model || "—"),
badge(d.compliance_status || d.firmware_status || (d.connected ? "connected" : "offline")),
],
jumpHw: d.device_id || d.id || null,
}));
}
function ctxRowsFromFleet(list) {
return list.slice(0, 80).map((d) => ({
cols: ["ST", "Name", "Model", "iDRAC", "State"],
cells: [
`<code class="st">${escapeHtml(d.service_tag || "—")}</code>`,
escapeHtml(d.name || "—"),
escapeHtml(d.model || "—"),
`<span class="mono">${escapeHtml(d.idrac_ip || d.ip || "—")}</span>`,
d.connected
? `<span class="rpt-badge ok">online</span>`
: `<span class="rpt-badge bad">offline</span>`,
],
jumpHw: d.id || d.device_id || null,
}));
}
function ctxRowsFromWarranty(list) {
return list.slice(0, 80).map((w) => ({
cols: ["ST", "Device", "Level", "Ends", "Days left"],
cells: [
`<code class="st">${escapeHtml(w.service_tag || "—")}</code>`,
escapeHtml(w.device_name || "—"),
escapeHtml(w.service_level || "—"),
escapeHtml(w.end_date || "—"),
String(intOr(w.days_remaining)),
],
jumpHw: w.device_id || null,
}));
}
function handleAnContext(kind, key, count, evt) {
let title = key;
let sub = "";
let pills = [`${count ?? "?"} items`];
let rows = [];
let actions = [];
if (kind === "compliance") {
if (key === "UPGRADE") {
const list = comps.filter((c) => String(c.update_action || "").toUpperCase() === "UPGRADE");
title = "UPGRADE components";
sub = "Dell Online Baseline · update_action = UPGRADE";
rows = ctxRowsFromComps(list);
pills = [`${list.length} components`, `baseline ${cs.baseline_name || "—"}`];
actions = [
{
id: "fw",
label: "Open Firmware / BIOS",
primary: true,
run: () => {
state.outdatedOnly = true;
loadTab("firmware");
},
},
];
} else {
const list =
key === "OK"
? comps.filter((c) => {
const s = String(c.compliance_status || "").toUpperCase();
return s !== "CRITICAL" && s !== "WARNING";
})
: comps.filter((c) => String(c.compliance_status || "").toUpperCase() === key);
title = `Compliance · ${key}`;
sub = "Component compliance from Dell catalog baseline";
rows = ctxRowsFromComps(list);
pills = [`${list.length} components`, key];
actions = [
{
id: "fw",
label: "Open Firmware tab",
primary: true,
run: () => {
state.outdatedOnly = key !== "OK";
state.filters.severity = key === "CRITICAL" ? "critical" : key === "WARNING" ? "warning" : "all";
loadTab("firmware");
},
},
];
}
} else if (kind === "component") {
const list = comps.filter(
(c) =>
String(c.update_action || "").toUpperCase() === "UPGRADE" && shortComponent(c.component) === key
);
title = `Outdated · ${key}`;
sub = "Components marked UPGRADE in this firmware family";
rows = ctxRowsFromComps(list);
pills = [`${list.length} components`, key];
actions = [
{
id: "fw",
label: "Open Firmware filtered",
primary: true,
run: () => {
state.outdatedOnly = true;
state.filters.component = key;
loadTab("firmware");
},
},
];
} else if (kind === "model") {
const list = devices.filter(
(d) =>
(d.model || "Unknown") === key &&
(String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
String(d.firmware_status || "").toLowerCase().includes("non-compliant"))
);
title = `Outdated · ${key}`;
sub = "Non-compliant systems for this model";
rows = ctxRowsFromDevices(list);
pills = [`${list.length} systems`, key];
actions = [
{
id: "fw",
label: "Open Firmware / BIOS",
primary: true,
run: () => {
state.outdatedOnly = true;
state.filters.model = key;
loadTab("firmware");
},
},
];
} else if (kind === "connection") {
const list = fleet.filter((d) => (key === "connected" ? d.connected : !d.connected));
title = key === "connected" ? "Connected systems" : "Offline systems";
sub = "OME ConnectionState from live fleet snapshot";
rows = ctxRowsFromFleet(list);
pills = [`${list.length} devices`, key === "connected" ? "online" : "offline"];
actions = [
{
id: "fleet",
label: "Open Fleet inventory",
primary: true,
run: () => {
state.filters.connected = key === "connected" ? "yes" : "no";
loadTab("fleet");
},
},
];
} else if (kind === "warranty") {
const list = warranties.filter((w) => {
const d = intOr(w.days_remaining);
if (key === "expired") return d <= 0;
if (key === "d30") return d > 0 && d <= 30;
if (key === "d90") return d > 30 && d <= 90;
return d > 90;
});
const labels = { expired: "Expired", d30: "≤ 30 days", d90: "3190 days", ok: "> 90 days" };
title = `Warranty · ${labels[key] || key}`;
sub = "OME WarrantyService days_remaining buckets";
rows = ctxRowsFromWarranty(list);
pills = [`${list.length} records`, labels[key] || key];
actions = [
{
id: "war",
label: "Open Warranty tab",
primary: true,
run: () => {
state.filters.warranty =
key === "expired" ? "expired" : key === "d30" ? "d30" : key === "d90" ? "d90" : "ok";
loadTab("warranty");
},
},
];
} else if (kind === "kpi") {
// handled separately
return;
} else {
title = `${kind} · ${key}`;
sub = "Analytics selection";
}
openAnContext({ title, sub, pills, rows, actions });
}
mount.querySelectorAll("[data-an-kind]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
evt.stopPropagation();
let kind = el.dataset.anKind;
let key = el.dataset.anKey;
let count = el.dataset.anCount;
if (el.dataset.anDonut === "1") {
const hit = sliceFromDonutClick(el, evt);
if (!hit) return;
key = hit.key;
count = hit.value;
}
if (!key) return;
handleAnContext(kind, key, count, evt);
});
});
mount.querySelectorAll("[data-jump]").forEach((el) => {
el.addEventListener("click", () => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const jump = el.dataset.jump;
// KPI → context popup first
if (el.classList.contains("an-kpi")) {
let kind = "connection";
let key = "connected";
if (el.dataset.conn === "all") {
openAnContext({
title: "Fleet devices",
sub: "Full OME fleet snapshot",
pills: [`${fleet.length} devices`, `${connected} online`, `${offline} offline`],
rows: ctxRowsFromFleet(fleet),
actions: [
{
id: "fleet",
label: "Open Fleet inventory",
primary: true,
run: () => {
state.filters.connected = "all";
loadTab("fleet");
},
},
],
});
return;
}
if (el.dataset.conn === "yes") {
handleAnContext("connection", "connected", connected);
return;
}
if (el.dataset.conn === "no") {
handleAnContext("connection", "offline", offline);
return;
}
if (el.dataset.sev === "critical") {
const label = (el.querySelector("span")?.textContent || "").toLowerCase();
if (label.includes("component")) {
handleAnContext("compliance", "CRITICAL", critComps);
} else {
const list = devices.filter(
(d) =>
String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
String(d.firmware_status || "").toLowerCase().includes("non-compliant")
);
openAnContext({
title: "Outdated vs Dell",
sub: "Systems non-compliant against Dell Online Baseline",
pills: [`${list.length} systems`, `baseline ${cs.baseline_name || "—"}`],
rows: ctxRowsFromDevices(list),
actions: [
{
id: "fw",
label: "Open Firmware / BIOS",
primary: true,
run: () => {
state.outdatedOnly = true;
state.filters.severity = "critical";
loadTab("firmware");
},
},
],
});
}
return;
}
if (el.dataset.war) {
const list = warranties.filter((w) => {
const d = intOr(w.days_remaining);
return d <= 30;
});
openAnContext({
title: "Warranty risk (≤30d / expired)",
sub: "OME warranty records needing attention",
pills: [`${list.length} records`, `${expired} expired`, `${d30} ≤30d`],
rows: ctxRowsFromWarranty(list),
actions: [
{
id: "war",
label: "Open Warranty tab",
primary: true,
run: () => {
state.filters.warranty = "expired";
loadTab("warranty");
},
},
],
});
return;
}
}
if (el.dataset.conn) state.filters.connected = el.dataset.conn;
if (el.dataset.sev) {
state.filters.severity = el.dataset.sev;
@@ -1386,13 +1817,15 @@
loadTab(chip.dataset.tab);
});
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "reports-drawer") closeReports();
const mode = $("#scrim")?.dataset.mode;
if (mode === "an-context") closeAnContext();
else if (mode === "reports-drawer") closeReports();
});
// Extend ops closeDrawers awareness via custom event
window.addEventListener("cockpit-close-drawers", closeReports);
}
window.cockpitReports = { open: openReports, close: closeReports, loadTab };
window.cockpitReports = { open: openReports, close: closeReports, loadTab, closeAnContext };
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();
+3799 -111
View File
File diff suppressed because it is too large Load Diff