/** * 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, "&") .replace(//g, ">") .replace(/"/g, """); } 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 ` ${bits.join(" · ") || "network —"} ${node?.connected ? `live` : `offline`} `; } 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 = ` ${pool.length} iDRACs ${live} connected ${nets} networks ${filled}/${state.slotCount} wall`; } } 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 = [ ``, ]; [...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( `` ); }); 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 = `

No iDRAC endpoints match. OS hosts are hidden — only BMC/iDRAC IPs.

`; return; } let lastSubnet = null; const parts = []; list.forEach((n) => { if (n.subnet !== lastSubnet) { lastSubnet = n.subnet; const sm = subnetMeta(n.subnet); parts.push(`
${sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : ""}${sm.name ? ` · ${esc(sm.name)}` : ""} · ${esc(sm.cidr)}
`); } const inWall = used.has(String(n.id)); const ip = mgmtIp(n); parts.push(`
${esc(n.name || "device")} ${n.connected ? "LIVE" : "OFF"} ${esc(n.model || "—")} · ${esc(n.service_tag || "no tag")} ${esc(ip || "—")}
`); }); host.innerHTML = parts.join(""); } function tileHtml(index, deviceId) { const node = deviceById(deviceId); if (!node || !isIdracHost(node)) { return `
Slot ${index + 1} Drop an iDRAC here

Only BMC/iDRAC IPs · drag from fleet or click + Wall

`; } const sm = subnetMeta(node.subnet); const ip = mgmtIp(node); return `
#${index + 1} ${esc(node.name || "iDRAC")} ${netBadgeHtml(sm, node)}
${esc(node.model || "—")} ${esc(node.service_tag || "—")} ${esc(ip || "")} ${node.powered_on ? "POWERED" : "POWER N/A"}
`; } 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 }; })();