Files
ome-cockpit/ui/console.js
T

630 lines
22 KiB
JavaScript
Raw Normal View History

/**
* 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, "&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"].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 };
})();