Files
root b4714c70d1 Add ATC Team chat, soft identity, and update Present decks.
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 01:42:00 +02:00

630 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Console workspace — live iDRAC wall (4 or 8 draggable tiles).
* Only real iDRAC management endpoints (not OS host IPs).
*/
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const STORE_KEY = "cockpit_console_slots_v2";
const DENSITY_KEY = "cockpit_console_density_v1";
const SLOTS_KEY = "cockpit_console_slotcount_v1";
const state = {
slotCount: Number(localStorage.getItem(SLOTS_KEY) || 4) === 8 ? 8 : 4,
slots: [],
density: localStorage.getItem(DENSITY_KEY) || "medium",
filter: "",
subnet: "all",
fsDeviceId: null,
dragDeviceId: null,
dragFromSlot: null,
};
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fleetDevices() {
return window.cockpit?.getState?.()?.data?.devices || [];
}
function fleetSubnets() {
return window.cockpit?.getState?.()?.data?.subnets || [];
}
/** Real iDRAC / BMC endpoints only — never bare OS inventory hosts. */
function isIdracHost(n) {
if (!n || n.id == null) return false;
if (n.is_idrac) return !!(n.idrac_ip || n.ip);
if (n.idrac_ip) return true;
if (n.idrac_url && /idrac/i.test(String(n.name || "") + String(n.idrac_url))) return true;
const name = String(n.name || "").toLowerCase();
if (name.includes("idrac") && (n.ip || n.idrac_ip)) return true;
return false;
}
function mgmtIp(n) {
return n?.idrac_ip || (n?.is_idrac ? n.ip : null) || null;
}
function idracPool() {
return fleetDevices()
.filter(isIdracHost)
.filter((n) => mgmtIp(n))
.slice()
.sort((a, b) => {
const sa = String(a.subnet || "");
const sb = String(b.subnet || "");
if (sa !== sb) return sa.localeCompare(sb);
return String(a.name || "").localeCompare(String(b.name || ""));
});
}
function deviceById(id) {
if (id == null) return null;
return fleetDevices().find((d) => String(d.id) === String(id)) || null;
}
function subnetMeta(cidr) {
const meta = fleetSubnets().find((s) => s.cidr === cidr) || {};
const colors = window.cockpit?.getState?.()?.subnetColors;
let color = meta.vlan_color;
if (!color && colors?.get) color = colors.get(cidr || "?");
if (!color) {
let h = 0;
for (const c of String(cidr || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
color = `hsl(${h % 360} 72% 52%)`;
}
return {
cidr: cidr || "—",
vlan: meta.vlan_id,
name: meta.name || meta.label || "",
color,
};
}
function embedUrl(deviceId) {
return `/api/idrac-proxy/${deviceId}/restgui/start.html?console`;
}
function resizeSlots(count) {
const next = Number(count) === 8 ? 8 : 4;
const prev = state.slots.slice();
state.slotCount = next;
state.slots = Array.from({ length: next }, (_, i) => prev[i] ?? null);
localStorage.setItem(SLOTS_KEY, String(next));
saveSlots();
}
function loadSlots() {
try {
const raw = JSON.parse(localStorage.getItem(STORE_KEY) || "[]");
if (Array.isArray(raw) && raw.length) {
state.slots = Array.from({ length: state.slotCount }, (_, i) => {
const id = raw[i] ?? null;
const n = deviceById(id);
return n && isIdracHost(n) ? id : null;
});
return;
}
} catch {
/* ignore */
}
state.slots = Array(state.slotCount).fill(null);
}
function saveSlots() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(state.slots));
} catch {
/* ignore */
}
}
function closeOtherDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer", "#team-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
el.setAttribute("aria-hidden", "true");
}
});
window.cockpitNetwork?.close?.();
}
function openConsole() {
const drawer = $("#console-drawer");
const scrim = $("#scrim");
if (!drawer) return;
closeOtherDrawers();
loadSlots();
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
if (scrim) {
scrim.classList.add("open");
scrim.dataset.mode = "console-drawer";
}
renderAll();
requestAnimationFrame(() => drawer.classList.add("console-ready"));
}
function closeConsole() {
closeFullscreen();
const drawer = $("#console-drawer");
const scrim = $("#scrim");
drawer?.classList.remove("open", "console-ready", "is-dragging");
drawer?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "console-drawer") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function setDensity(mode) {
state.density = mode === "small" ? "small" : "medium";
localStorage.setItem(DENSITY_KEY, state.density);
applyStageClasses();
$$("[data-console-density]").forEach((b) => {
b.classList.toggle("active", b.dataset.consoleDensity === state.density);
});
}
function setSlotCount(count) {
resizeSlots(count);
applyStageClasses();
$$("[data-console-slots]").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.consoleSlots) === state.slotCount);
});
renderAll();
}
function applyStageClasses() {
const stage = $("#console-stage");
if (!stage) return;
stage.classList.toggle("density-small", state.density === "small");
stage.classList.toggle("density-medium", state.density === "medium");
stage.classList.toggle("slots-4", state.slotCount === 4);
stage.classList.toggle("slots-8", state.slotCount === 8);
}
function toast(msg) {
if (typeof window.showToast === "function") window.showToast(msg);
else if (window.cockpit?.showToast) window.cockpit.showToast(msg);
}
function assignSlot(index, deviceId) {
if (index < 0 || index >= state.slotCount) return;
if (deviceId != null) {
const n = deviceById(deviceId);
if (!n || !isIdracHost(n)) {
toast("Kies een iDRAC-endpoint (geen OS-host)");
return;
}
state.slots = state.slots.map((id, i) => (i !== index && String(id) === String(deviceId) ? null : id));
}
state.slots[index] = deviceId;
saveSlots();
renderStage();
renderFleet();
renderStats();
}
function clearSlot(index) {
assignSlot(index, null);
}
function swapSlots(a, b) {
if (a === b || a < 0 || b < 0 || a >= state.slotCount || b >= state.slotCount) return;
const tmp = state.slots[a];
state.slots[a] = state.slots[b];
state.slots[b] = tmp;
saveSlots();
renderStage();
}
function openFullscreen(deviceId) {
const node = deviceById(deviceId);
if (!node) return;
state.fsDeviceId = deviceId;
const modal = $("#console-fs-modal");
const frame = $("#console-fs-frame");
const title = $("#console-fs-title");
const sub = $("#console-fs-sub");
const net = $("#console-fs-net");
if (!modal || !frame) return;
const sm = subnetMeta(node.subnet);
title.textContent = node.name || "iDRAC";
sub.textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · ${mgmtIp(node) || ""}`;
net.innerHTML = netBadgeHtml(sm, node);
$$(`.console-tile[data-device-id="${deviceId}"] iframe`).forEach((f) => {
f.dataset.pausedSrc = f.src;
f.removeAttribute("src");
});
frame.src = embedUrl(deviceId);
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
}
function closeFullscreen() {
const modal = $("#console-fs-modal");
const frame = $("#console-fs-frame");
const id = state.fsDeviceId;
if (frame) frame.removeAttribute("src");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
if (id != null) {
$$(`.console-tile[data-device-id="${id}"] iframe`).forEach((f) => {
if (f.dataset.pausedSrc) {
f.src = f.dataset.pausedSrc;
delete f.dataset.pausedSrc;
} else {
f.src = embedUrl(id);
}
});
}
state.fsDeviceId = null;
}
function netBadgeHtml(sm, node) {
const vlan = sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : "";
const name = sm.name ? esc(sm.name) : "";
const bits = [vlan, name, esc(sm.cidr)].filter(Boolean);
return `<span class="console-net-badge" style="--net:${esc(sm.color)}">
<i class="console-net-dot"></i>
<span>${bits.join(" · ") || "network —"}</span>
${node?.connected ? `<em class="on">live</em>` : `<em class="off">offline</em>`}
</span>`;
}
function renderStats() {
const pool = idracPool();
const live = pool.filter((n) => n.connected).length;
const filled = state.slots.filter(Boolean).length;
const nets = new Set(pool.map((n) => n.subnet).filter(Boolean)).size;
const el = $("#console-stats");
if (el) {
el.innerHTML = `
<span class="cs-pill"><strong>${pool.length}</strong> iDRACs</span>
<span class="cs-pill live"><strong>${live}</strong> connected</span>
<span class="cs-pill"><strong>${nets}</strong> networks</span>
<span class="cs-pill"><strong>${filled}/${state.slotCount}</strong> wall</span>`;
}
}
function filteredPool() {
const q = state.filter.trim().toLowerCase();
return idracPool().filter((n) => {
if (state.subnet !== "all" && n.subnet !== state.subnet) return false;
if (!q) return true;
const hay = [n.name, n.ip, n.idrac_ip, n.model, n.service_tag, n.subnet]
.join(" ")
.toLowerCase();
return hay.includes(q);
});
}
function renderSubnetChips() {
const host = $("#console-subnet-chips");
if (!host) return;
const pool = idracPool();
const counts = new Map();
pool.forEach((n) => {
const c = n.subnet || "unknown";
counts.set(c, (counts.get(c) || 0) + 1);
});
const chips = [
`<button type="button" class="chip ${state.subnet === "all" ? "active" : ""}" data-console-subnet="all">All nets · ${pool.length}</button>`,
];
[...counts.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([cidr, n]) => {
const sm = subnetMeta(cidr);
const label = sm.vlan != null ? `VLAN ${sm.vlan}` : cidr;
chips.push(
`<button type="button" class="chip ${state.subnet === cidr ? "active" : ""}" data-console-subnet="${esc(cidr)}" style="--chip:${esc(sm.color)}">
<i class="console-chip-dot"></i>${esc(label)} · ${n}
</button>`
);
});
host.innerHTML = chips.join("");
}
function renderFleet() {
const host = $("#console-fleet-list");
if (!host) return;
const used = new Set(state.slots.filter(Boolean).map(String));
const list = filteredPool();
if (!list.length) {
host.innerHTML = `<p class="hint">No iDRAC endpoints match. OS hosts are hidden — only BMC/iDRAC IPs.</p>`;
return;
}
let lastSubnet = null;
const parts = [];
list.forEach((n) => {
if (n.subnet !== lastSubnet) {
lastSubnet = n.subnet;
const sm = subnetMeta(n.subnet);
parts.push(`<div class="console-fleet-group" style="--net:${esc(sm.color)}">
<span class="cfg-label">${sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : ""}${sm.name ? ` · ${esc(sm.name)}` : ""} · <code>${esc(sm.cidr)}</code></span>
</div>`);
}
const inWall = used.has(String(n.id));
const ip = mgmtIp(n);
parts.push(`<div class="console-fleet-card ${n.connected ? "is-live" : "is-off"} ${inWall ? "on-wall" : ""}"
draggable="true" data-device-id="${esc(n.id)}" title="Drag onto a wall slot">
<span class="cfc-top">
<strong>${esc(n.name || "device")}</strong>
<span class="cfc-status">${n.connected ? "LIVE" : "OFF"}</span>
</span>
<span class="cfc-meta">${esc(n.model || "—")} · ${esc(n.service_tag || "no tag")}</span>
<span class="cfc-ip">${esc(ip || "—")}</span>
<span class="cfc-actions">
<button type="button" class="btn compact primary" data-fleet-act="add">${inWall ? "On wall" : "+ Wall"}</button>
</span>
</div>`);
});
host.innerHTML = parts.join("");
}
function tileHtml(index, deviceId) {
const node = deviceById(deviceId);
if (!node || !isIdracHost(node)) {
return `<div class="console-tile is-empty" data-slot="${index}" data-device-id="">
<div class="console-dropzone">
<span class="cd-kicker">Slot ${index + 1}</span>
<strong>Drop an iDRAC here</strong>
<p>Only BMC/iDRAC IPs · drag from fleet or click + Wall</p>
</div>
</div>`;
}
const sm = subnetMeta(node.subnet);
const ip = mgmtIp(node);
return `<div class="console-tile is-filled ${node.connected ? "is-live" : "is-off"}" data-slot="${index}" data-device-id="${esc(node.id)}" draggable="true" style="--net:${esc(sm.color)}">
<header class="console-tile-chrome">
<div class="ctc-id">
<span class="ctc-slot">#${index + 1}</span>
<strong class="ctc-name" title="${esc(node.name)}">${esc(node.name || "iDRAC")}</strong>
${netBadgeHtml(sm, node)}
</div>
<div class="ctc-actions">
<button type="button" class="btn ghost compact" data-tile-act="reload" title="Reload">↻</button>
<button type="button" class="btn ghost compact" data-tile-act="pop" title="Open direct iDRAC">↗</button>
<button type="button" class="btn idrac-console compact" data-tile-act="fs" title="Fullscreen popup">Fullscreen</button>
<button type="button" class="btn ghost compact" data-tile-act="clear" title="Clear slot">×</button>
</div>
</header>
<div class="console-tile-meta">
<span>${esc(node.model || "—")}</span>
<span>${esc(node.service_tag || "—")}</span>
<span class="mono">${esc(ip || "")}</span>
<span class="${node.powered_on ? "on" : "off"}">${node.powered_on ? "POWERED" : "POWER N/A"}</span>
</div>
<div class="console-frame-wrap">
<div class="console-drop-shield" aria-hidden="true"></div>
<iframe title="iDRAC ${esc(node.name)}" src="${esc(embedUrl(node.id))}" allow="fullscreen; clipboard-read; clipboard-write" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>`;
}
function renderStage() {
const stage = $("#console-stage");
if (!stage) return;
applyStageClasses();
const prev = new Map();
$$(".console-tile iframe", stage).forEach((f) => {
const tile = f.closest(".console-tile");
if (!tile) return;
prev.set(`${tile.dataset.slot}:${tile.dataset.deviceId}`, f);
});
stage.innerHTML = state.slots.map((id, i) => tileHtml(i, id)).join("");
$$(".console-tile.is-filled", stage).forEach((tile) => {
const key = `${tile.dataset.slot}:${tile.dataset.deviceId}`;
const old = prev.get(key);
const frame = $("iframe", tile);
if (old && frame && old !== frame && old.src) frame.replaceWith(old);
});
}
function renderAll() {
renderStats();
renderSubnetChips();
renderFleet();
renderStage();
setDensity(state.density);
$$("[data-console-slots]").forEach((b) => {
b.classList.toggle("active", Number(b.dataset.consoleSlots) === state.slotCount);
});
}
function fillNextEmpty(deviceId) {
const idx = state.slots.findIndex((id) => id == null);
if (idx === -1) {
assignSlot(state.slotCount - 1, deviceId);
return;
}
assignSlot(idx, deviceId);
}
function autoFillLive() {
const live = idracPool().filter((n) => n.connected);
const picks = (live.length ? live : idracPool()).slice(0, state.slotCount);
state.slots = Array.from({ length: state.slotCount }, (_, i) => (picks[i] ? picks[i].id : null));
saveSlots();
renderAll();
}
function bindDrag() {
const drawer = $("#console-drawer");
if (!drawer || drawer.dataset.dragBound) return;
drawer.dataset.dragBound = "1";
drawer.addEventListener("dragstart", (e) => {
const card = e.target.closest(".console-fleet-card");
const tile = e.target.closest(".console-tile.is-filled");
// don't start drag from action buttons
if (e.target.closest("button")) return;
if (card) {
state.dragDeviceId = card.dataset.deviceId;
state.dragFromSlot = null;
e.dataTransfer.setData("text/plain", String(state.dragDeviceId));
e.dataTransfer.effectAllowed = "copyMove";
card.classList.add("dragging");
drawer.classList.add("is-dragging");
} else if (tile) {
state.dragDeviceId = tile.dataset.deviceId;
state.dragFromSlot = Number(tile.dataset.slot);
e.dataTransfer.setData("text/plain", String(state.dragDeviceId));
e.dataTransfer.effectAllowed = "move";
tile.classList.add("dragging");
drawer.classList.add("is-dragging");
} else {
return;
}
});
drawer.addEventListener("dragend", () => {
$$(".dragging", drawer).forEach((t) => t.classList.remove("dragging"));
$$(".console-tile.drag-over", drawer).forEach((t) => t.classList.remove("drag-over"));
drawer.classList.remove("is-dragging");
state.dragDeviceId = null;
state.dragFromSlot = null;
});
drawer.addEventListener("dragover", (e) => {
const tile = e.target.closest(".console-tile");
if (!tile) return;
e.preventDefault();
tile.classList.add("drag-over");
e.dataTransfer.dropEffect = state.dragFromSlot != null ? "move" : "copy";
});
drawer.addEventListener("dragleave", (e) => {
const tile = e.target.closest(".console-tile");
if (tile && !tile.contains(e.relatedTarget)) tile.classList.remove("drag-over");
});
drawer.addEventListener("drop", (e) => {
const tile = e.target.closest(".console-tile");
if (!tile) return;
e.preventDefault();
tile.classList.remove("drag-over");
const to = Number(tile.dataset.slot);
const deviceId = e.dataTransfer.getData("text/plain") || state.dragDeviceId;
if (deviceId == null || deviceId === "") return;
if (state.dragFromSlot != null) swapSlots(state.dragFromSlot, to);
else assignSlot(to, Number(deviceId) || deviceId);
drawer.classList.remove("is-dragging");
});
}
function bind() {
$("#btn-console")?.addEventListener("click", openConsole);
$("#btn-console-close")?.addEventListener("click", closeConsole);
$("#btn-console-autofill")?.addEventListener("click", autoFillLive);
$("#btn-console-clear")?.addEventListener("click", () => {
state.slots = Array(state.slotCount).fill(null);
saveSlots();
renderAll();
});
$("#console-search")?.addEventListener("input", (e) => {
state.filter = e.target.value || "";
renderFleet();
});
$("#console-subnet-chips")?.addEventListener("click", (e) => {
const chip = e.target.closest("[data-console-subnet]");
if (!chip) return;
state.subnet = chip.dataset.consoleSubnet || "all";
renderSubnetChips();
renderFleet();
});
$$("[data-console-density]").forEach((b) => {
b.addEventListener("click", () => setDensity(b.dataset.consoleDensity));
});
$$("[data-console-slots]").forEach((b) => {
b.addEventListener("click", () => setSlotCount(b.dataset.consoleSlots));
});
$("#console-fleet-list")?.addEventListener("click", (e) => {
const addBtn = e.target.closest("[data-fleet-act='add']");
const card = e.target.closest(".console-fleet-card");
if (addBtn && card) {
e.preventDefault();
e.stopPropagation();
fillNextEmpty(Number(card.dataset.deviceId) || card.dataset.deviceId);
return;
}
if (!card) return;
$$(".console-fleet-card.selected").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
});
$("#console-fleet-list")?.addEventListener("dblclick", (e) => {
const card = e.target.closest(".console-fleet-card");
if (!card || e.target.closest("button")) return;
fillNextEmpty(Number(card.dataset.deviceId) || card.dataset.deviceId);
});
$("#console-stage")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-tile-act]");
if (!btn) return;
const tile = btn.closest(".console-tile");
if (!tile) return;
const slot = Number(tile.dataset.slot);
const id = tile.dataset.deviceId;
const act = btn.dataset.tileAct;
if (act === "clear") clearSlot(slot);
else if (act === "reload") {
const frame = $("iframe", tile);
if (frame?.src) {
const u = frame.src;
frame.src = "about:blank";
setTimeout(() => {
frame.src = u;
}, 40);
}
} else if (act === "fs" && id) openFullscreen(id);
else if (act === "pop" && id) {
const n = deviceById(id);
const url = n?.idrac_url || (mgmtIp(n) ? `https://${mgmtIp(n)}/` : null);
if (url) window.open(url, "_blank", "noopener");
}
});
$("#btn-console-fs-close")?.addEventListener("click", closeFullscreen);
$("#console-fs-modal")?.addEventListener("click", (e) => {
if (e.target.id === "console-fs-modal") closeFullscreen();
});
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "console-drawer") closeConsole();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (!$("#console-fs-modal")?.classList.contains("hidden")) closeFullscreen();
else if ($("#console-drawer")?.classList.contains("open")) closeConsole();
}
});
bindDrag();
setInterval(() => {
if (!$("#console-drawer")?.classList.contains("open")) return;
renderStats();
renderSubnetChips();
renderFleet();
$$(".console-tile.is-filled").forEach((tile) => {
const n = deviceById(tile.dataset.deviceId);
if (!n) return;
tile.classList.toggle("is-live", !!n.connected);
tile.classList.toggle("is-off", !n.connected);
const badge = $(".console-net-badge", tile);
if (badge) badge.outerHTML = netBadgeHtml(subnetMeta(n.subnet), n);
});
}, 8000);
}
state.slots = Array(state.slotCount).fill(null);
loadSlots();
bind();
window.cockpitConsole = { open: openConsole, close: closeConsole, refresh: renderAll };
})();