- ${node.idrac_url ? `
iDRAC Web` : ""}
+
+
+
+
+
+ ${node.ip || node.idrac_ip ? `
` : ""}
+ ${node.idrac_url ? `
iDRAC Web ↗` : ""}
${node.ip ? `
` : ""}
${node.ip ? `
` : ""}
@@ -2263,6 +2402,10 @@
});
updateFocusContext();
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
+ $("#btn-power-on")?.addEventListener("click", () => requestDevicePower(node, "on"));
+ $("#btn-power-off")?.addEventListener("click", () => requestDevicePower(node, "off"));
+ $("#btn-power-cycle")?.addEventListener("click", () => requestDevicePower(node, "cycle"));
+ $("#btn-idrac-console")?.addEventListener("click", () => openIdracConsole(node));
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
$("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node));
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
@@ -2617,6 +2760,14 @@
closeKpiPopup();
focusDeviceId(node.id);
showToast("Focused " + (node.name || ""));
+ } else if (a === "power-on") {
+ requestDevicePower(node, "on");
+ } else if (a === "power-off") {
+ requestDevicePower(node, "off");
+ } else if (a === "power-cycle") {
+ requestDevicePower(node, "cycle");
+ } else if (a === "idrac-console") {
+ openIdracConsole(node);
} else if (a === "ssh") {
window.cockpitSsh?.open(node);
} else if (a === "rdp") {
@@ -2857,6 +3008,9 @@
openConnect,
showInspector,
openAi,
+ requestDevicePower,
+ openIdracConsole,
+ closeIdracConsole,
relTime,
escapeHtml,
openTriage: null,
@@ -2924,20 +3078,44 @@
$("#btn-ai")?.addEventListener("click", () => openAi(null));
$("#btn-ai-close").addEventListener("click", closeAi);
$("#btn-connect-close")?.addEventListener("click", closeConnect);
+ $("#btn-idrac-console-close")?.addEventListener("click", closeIdracConsole);
+ $("#btn-idrac-popout")?.addEventListener("click", () => {
+ const modal = $("#idrac-console-modal");
+ const url = modal?.dataset.popout || modal?.dataset.web || modal?.dataset.primary;
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
+ });
+ $("#btn-idrac-web")?.addEventListener("click", () => {
+ const modal = $("#idrac-console-modal");
+ const url = modal?.dataset.web || modal?.dataset.popout || modal?.dataset.primary;
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
+ });
+ $("#btn-idrac-reload")?.addEventListener("click", () => {
+ const frame = $("#idrac-console-frame");
+ const modal = $("#idrac-console-modal");
+ const url = modal?.dataset.primary;
+ if (frame && url) {
+ frame.src = "about:blank";
+ setTimeout(() => {
+ frame.src = url;
+ }, 30);
+ }
+ });
$("#scrim").addEventListener("click", () => {
const mode = $("#scrim")?.dataset.mode;
if (mode === "triage") document.getElementById("btn-triage-close")?.click();
else if (mode === "kpi") closeKpiPopup();
else if (mode === "connect") closeConnect();
+ else if (mode === "idrac-console") closeIdracConsole();
else if (
mode === "chat-drawer" ||
mode === "ops-drawer" ||
mode === "ai-drawer" ||
mode === "reports-drawer" ||
mode === "an-context" ||
- mode === "network-drawer"
+ mode === "network-drawer" ||
+ mode === "console-drawer"
) {
- /* reports.js / ops.js / network.js also listen */
+ /* reports.js / ops.js / network.js / console.js also listen */
} else closeAi();
});
$("#btn-ome-console").addEventListener("click", () => {
diff --git a/ui/console.js b/ui/console.js
new file mode 100644
index 0000000..af56d9c
--- /dev/null
+++ b/ui/console.js
@@ -0,0 +1,629 @@
+/**
+ * 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"].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 };
+})();
diff --git a/ui/index.html b/ui/index.html
index 23df042..33ddd6c 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -7,7 +7,7 @@
-
+
-
+
-
+
+
diff --git a/ui/network.js b/ui/network.js
index f82b6a2..1da34b0 100644
--- a/ui/network.js
+++ b/ui/network.js
@@ -95,7 +95,7 @@
const drawer = $("#network-drawer");
const scrim = $("#scrim");
if (!drawer) return;
- ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => {
+ ["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
diff --git a/ui/present.js b/ui/present.js
index fb5148b..7a04e88 100644
--- a/ui/present.js
+++ b/ui/present.js
@@ -32,7 +32,8 @@
- Why — the gap around OME and why AI alone is not enough.
- Architecture — one design: Browser → Cockpit BFF → OME / AI (with logos).
- - Apps — map, network, reports, ops desk, copilots.
+ - Apps — map, network, reports, Console wall, ops desk, copilots.
+ - Remote ops — power via OME→iDRAC and live consoles in-panel.
- People — who asked, who built, who runs it.
- Value — what customers can take away.
@@ -60,10 +61,10 @@
html: `
One Service Tag story
Map, inventory, compliance, warranty, fabric, and tickets around the same ST.
+
Remote ops in UI
Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.
AI that cites the fleet
Copilot answers from live OME facts — or says it does not know.
Faster briefings
Click a chart → named systems → hand off in Ops desk.
Admin ↔ Data FDE
Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.
-
Demo = daily tool
Same stack you present is the stack you can keep using after the meeting.
Copyable pattern
OME API + BFF + grounded AI — a blueprint, not a Dell SKU.
`,
},
@@ -113,7 +114,7 @@
MapTopology
-
ReportsAnalytics
+
ConsoleiDRAC wall
Cockpit UIDrawers · KPIs
NetworkFabric · Racks
Ops / AITickets · Copilot
@@ -159,13 +160,45 @@
html: `
Live mapFleet topology · power · inspector
+
Console wall4 / 8 live iDRAC embeds · drag
NetworkFabric · 42U racks · VLANs
ReportsCompliance · warranty analytics
Ops deskAdmin ↔ Data FDE tickets
CopilotGrounded chat on Service Tags
-
OpenManage AIFull Open WebUI beside the cockpit
-
One join key everywhere: Service Tag. No second inventory source.
`,
+
One join key everywhere: Service Tag. Power and console actions ride OME → iDRAC — not a second inventory.
`,
+ },
+ {
+ id: "console-wall",
+ kicker: "Remote ops",
+ title: "Console wall · many iDRACs, one screen",
+ anim: "zoom",
+ html: `
+
Operators asked to see systems without tab-hopping. The Console tab is a live iDRAC wall: network-aware, draggable, and embedded through Cockpit so browsers are not blocked by X-Frame-Options.
+
+
4 or 8 screens
Pick wall size · Small / Medium density · Fullscreen popup per tile.
+
Drag & + Wall
Fleet rail grouped by VLAN · drop onto slots · Auto-fill live BMCs.
+
Network badges
VLAN · subnet · LIVE/OFF on every tile — know where you are looking.
+
Same-origin proxy
/api/idrac-proxy/{id}/… strips framing headers · WebSocket bridge for HTML5 console.
+
Real iDRACs only
OOB / BMC endpoints — never bare OS host IPs that cannot speak iDRAC.
+
Still OME truth
Device list and connectivity come from the live fleet snapshot.
+
`,
+ },
+ {
+ id: "power-idrac",
+ kicker: "Remote ops",
+ title: "Power on / off · without leaving the map",
+ anim: "rise",
+ html: `
+
Offline servers with POWER N/A are common in the lab. From Servers KPI, inspector, or Quick Connect you can submit an OME POWER_CONTROL job — on, graceful off, or cycle — with confirm for destructive actions.
+
+
01SelectServer / iDRAC on map or KPI list
+
02Power⏻ On · Off · Cycle via OME JobService
+
03ConsoleiDRAC HTML5 in-panel (proxied)
+
04VerifyNext fleet poll updates powered state
+
05EscalateOps desk ticket if change needs a trail
+
+
Demo power with care — production change windows still belong in official OME / iDRAC process.
`,
},
{
id: "ai",
@@ -173,13 +206,13 @@
title: "AI that starts from OME facts",
anim: "slide",
html: `
-
Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, or Ops.
+
Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, Console, or Ops.
01AskFleet question in Copilot
02EnrichBFF attaches Service Tag facts
03RouteOpen WebUI / vLLM completion
04AnswerStill tied to the live map
-
05ActInspector · report · ticket
+
05ActInspector · console · ticket
OME for truth · AI for speed · Cockpit for the operator experience.
`,
},
@@ -232,6 +265,8 @@
html: `
- Pick a Service Tag on the map and open the inspector.
+ - Open Console — Auto-fill live iDRACs (4 or 8 screens).
+ - From Servers KPI: Power on a cold node · open iDRAC console in-panel.
- Open Reports · Analytics and click a colored segment.
- Ask Copilot a question that must cite Service Tags.
- Switch to Technical architecture for API and trust-boundary depth.
@@ -272,6 +307,7 @@
- OME session / cache behaviour
- AI completion path
- Portal containers & key
/api/* contracts
+ - iDRAC proxy · power jobs · Console wall
Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.
`,
},
@@ -321,7 +357,7 @@