Add iDRAC Console wall, power control, and proxy embeds.

Operators can power nodes via OME jobs and view multiple live iDRAC
consoles in-panel (4/8 wall) through a same-origin reverse proxy;
Present decks updated for the new remote-ops story.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-07-19 00:16:19 +02:00
parent 795707e47b
commit 03e207b46d
9 changed files with 2066 additions and 31 deletions
+185 -7
View File
@@ -1194,12 +1194,18 @@
</div>
<div class="kpi-actions">
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button>
<div class="power-actions">
<button type="button" class="btn power-on" data-kpi-act="power-on" title="Power on via OME → iDRAC">⏻ Power on</button>
<button type="button" class="btn power-off" data-kpi-act="power-off" title="Graceful shutdown via OME → iDRAC">Power off</button>
<button type="button" class="btn ghost" data-kpi-act="power-cycle" title="Power cycle via OME → iDRAC">Cycle</button>
</div>
<button type="button" class="btn idrac-console" data-kpi-act="idrac-console" ${node.ip || node.idrac_ip ? "" : "disabled"} title="Open iDRAC HTML5 console in Cockpit">▣ iDRAC console</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web ↗</a>` : ""}
<button type="button" class="btn conn-link" data-kpi-act="ssh" ${node.ip ? "" : "disabled"}>SSH terminal</button>
<button type="button" class="btn conn-link" data-kpi-act="rdp" ${node.ip ? "" : "disabled"}>RDP popout</button>
<button type="button" class="btn" data-kpi-act="connect">Quick Connect</button>
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button>
<button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
</div>
<div class="inv-section">
<h3>Related alerts</h3>
@@ -1845,6 +1851,105 @@
showToast._t = setTimeout(() => el.classList.remove("show"), ms);
}
async function requestDevicePower(node, action) {
if (!node?.id) return;
const labels = {
on: "Power ON",
off: "graceful Power OFF",
cycle: "Power CYCLE",
force_off: "FORCE Power OFF",
};
const label = labels[action] || action;
if (action !== "on") {
const ok = window.confirm(
`${label} for ${node.name || "device"} (${node.service_tag || node.id}) via OME → iDRAC?\n\nThis runs a remote power job.`
);
if (!ok) return;
}
try {
showToast(`Sending ${label}`);
const res = await fetch(`/api/devices/${node.id}/power`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, confirm: action !== "on" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
const msg = Array.isArray(detail)
? detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
: detail || data.message || res.statusText;
throw new Error(msg);
}
showToast(`${label} accepted · OME job #${data.job_id ?? "—"}`, { ms: 5000 });
// Optimistic badge: on → show powered, off → not powered
if (action === "on") node.powered_on = true;
if (action === "off" || action === "force_off") node.powered_on = false;
if (kpiPopup.key) renderKpiPopup();
} catch (e) {
showToast(`Power failed: ${e.message || e}`, { alert: true, ms: 8000 });
}
}
async function openIdracConsole(node) {
if (!node?.id) return;
const modal = $("#idrac-console-modal");
const frame = $("#idrac-console-frame");
const title = $("#idrac-console-title");
const sub = $("#idrac-console-sub");
const note = $("#idrac-console-note");
const scrim = $("#scrim");
if (!modal || !frame) {
const url = node.idrac_url || (node.ip ? `https://${node.ip}` : null);
if (url) window.open(url, "_blank", "noopener");
return;
}
title.textContent = node.name || "iDRAC console";
sub.textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · ${node.idrac_ip || node.ip || ""}`;
note.textContent = "Loading console URLs…";
frame.removeAttribute("src");
modal.dataset.deviceId = String(node.id);
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "idrac-console";
try {
const res = await fetch(`/api/devices/${node.id}/idrac-console`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
// Prefer same-origin proxy embed (strips X-Frame-Options)
const primary = data.primary || data.urls?.embed_path || data.urls?.html5 || data.urls?.web;
modal.dataset.primary = primary || "";
modal.dataset.web = data.urls?.web || "";
modal.dataset.popout = data.urls?.web || data.urls?.html5 || primary || "";
note.textContent = data.note || "Log in with your iDRAC credentials inside the panel.";
if (primary) {
frame.src = primary;
} else {
note.textContent = "No console URL available.";
}
} catch (e) {
note.textContent = `Console lookup failed: ${e.message || e}`;
const fallback = `/api/idrac-proxy/${node.id}/restgui/start.html?console`;
modal.dataset.primary = fallback;
modal.dataset.popout = node.idrac_url || (node.ip ? `https://${node.ip}/` : fallback);
frame.src = fallback;
}
}
function closeIdracConsole() {
const modal = $("#idrac-console-modal");
const frame = $("#idrac-console-frame");
const scrim = $("#scrim");
if (frame) frame.removeAttribute("src");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
if (scrim?.dataset.mode === "idrac-console") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function pushNotifyCard(ev) {
const host = $("#notify-stack");
if (!host) return;
@@ -1952,11 +2057,26 @@
const ip = node.ip;
const idrac = node.idrac_url || (ip ? `https://${ip}` : null);
$("#connect-grid").innerHTML = `
${idrac ? `<a class="connect-tile primary conn-link" href="${escapeAttr(idrac)}" target="_blank" rel="noopener">
<button type="button" class="connect-tile primary" id="btn-modal-power-on">
<span class="ct-ico">⏻</span>
<span class="ct-t">Power on</span>
<span class="ct-s">OME → iDRAC · boot server</span>
</button>
<button type="button" class="connect-tile danger" id="btn-modal-power-off">
<span class="ct-ico">⏻</span>
<span class="ct-t">Power off</span>
<span class="ct-s">Graceful shutdown via OME</span>
</button>
${idrac ? `<button type="button" class="connect-tile idrac-console" id="btn-modal-idrac-console">
<span class="ct-ico">▣</span>
<span class="ct-t">iDRAC console</span>
<span class="ct-s">Embed in Cockpit · ${escapeHtml(ip)}</span>
</button>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC console</span><span class="ct-s">No management IP</span></div>`}
${idrac ? `<a class="connect-tile conn-link" href="${escapeAttr(idrac)}" target="_blank" rel="noopener">
<span class="ct-ico">↗</span>
<span class="ct-t">iDRAC Web</span>
<span class="ct-s">BMC console · ${escapeHtml(ip)}</span>
</a>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC Web</span><span class="ct-s">No management IP</span></div>`}
<span class="ct-s">Open in new tab · ${escapeHtml(ip)}</span>
</a>` : ""}
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh">
<span class="ct-ico">〉_</span>
<span class="ct-t">SSH terminal</span>
@@ -1995,6 +2115,19 @@
}
return;
}
if (e.target.closest("#btn-modal-power-on")) {
await requestDevicePower(node, "on");
return;
}
if (e.target.closest("#btn-modal-power-off")) {
await requestDevicePower(node, "off");
return;
}
if (e.target.closest("#btn-modal-idrac-console")) {
closeConnect();
openIdracConsole(node);
return;
}
if (e.target.closest("#btn-modal-ssh")) {
closeConnect();
window.cockpitSsh?.open(node);
@@ -2227,7 +2360,13 @@
<div id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty &amp; Dell compliance…</p></div>
<div class="action-stack">
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
<div class="power-actions">
<button type="button" class="btn power-on" id="btn-power-on">⏻ Power on</button>
<button type="button" class="btn power-off" id="btn-power-off">Power off</button>
<button type="button" class="btn ghost" id="btn-power-cycle">Cycle</button>
</div>
${node.ip || node.idrac_ip ? `<button type="button" class="btn idrac-console" id="btn-idrac-console">▣ iDRAC console</button>` : ""}
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web ↗</a>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${escapeHtml(node.ip)}</button>` : ""}
${node.ip ? `<button type="button" class="btn conn-link" id="btn-rdp-term">RDP popout · ${escapeHtml(node.rdp_host || node.os_hostname || node.ip)}</button>` : ""}
<button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
@@ -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", () => {
+629
View File
@@ -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, "&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"].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 };
})();
+84 -3
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=radar1" />
<link rel="stylesheet" href="/styles.css?v=console2" />
<link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script>
@@ -39,6 +39,7 @@
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
<button type="button" class="btn ghost" id="btn-network" title="Fabric map &amp; ATC racks">Network</button>
<button type="button" class="btn console-btn" id="btn-console" title="Live iDRAC console wall">Console</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>
@@ -419,6 +420,63 @@
<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>
<!-- Live iDRAC Console wall -->
<div class="drawer wide console-drawer" id="console-drawer" aria-hidden="true">
<div class="console-aurora" aria-hidden="true"></div>
<div class="drawer-head console-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<div>
<span class="console-title">Console · iDRAC wall</span>
<p class="console-sub">4 live embeds · drag from fleet · network-aware</p>
</div>
</div>
<div class="console-head-actions">
<div id="console-stats" class="console-stats"></div>
<div class="console-density" role="group" aria-label="Wall size">
<button type="button" class="chip active" data-console-slots="4" title="4 consoles">4 screens</button>
<button type="button" class="chip" data-console-slots="8" title="8 consoles">8 screens</button>
</div>
<div class="console-density" role="group" aria-label="Tile density">
<button type="button" class="chip" data-console-density="small" title="Compact tiles">Small</button>
<button type="button" class="chip active" data-console-density="medium" title="Larger tiles">Medium</button>
</div>
<button type="button" class="btn primary" id="btn-console-autofill" title="Fill wall with live iDRACs">Auto-fill live</button>
<button type="button" class="btn ghost" id="btn-console-clear">Clear wall</button>
<button type="button" class="btn ghost" id="btn-console-close">Close</button>
</div>
</div>
<div class="console-body">
<aside class="console-fleet">
<div class="console-fleet-tools">
<input type="search" id="console-search" placeholder="Search name, IP, tag, subnet…" autocomplete="off" />
<div class="console-subnet-chips" id="console-subnet-chips"></div>
<p class="hint">Only real iDRAC/BMC IPs · drag onto a slot or click <strong>+ Wall</strong> · Fullscreen = popup</p>
</div>
<div class="console-fleet-list" id="console-fleet-list"></div>
</aside>
<section class="console-stage density-medium" id="console-stage" aria-label="iDRAC console wall"></section>
</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="modal hidden console-fs-modal" id="console-fs-modal" aria-hidden="true" role="dialog" aria-labelledby="console-fs-title">
<div class="modal-card console-fs-card">
<button type="button" class="modal-close" id="btn-console-fs-close" aria-label="Close">×</button>
<div class="console-fs-head">
<div>
<p class="modal-kicker">Fullscreen iDRAC</p>
<h2 id="console-fs-title">Console</h2>
<p class="modal-sub" id="console-fs-sub"></p>
<div id="console-fs-net"></div>
</div>
</div>
<div class="console-fs-frame-wrap">
<iframe id="console-fs-frame" title="iDRAC fullscreen" allow="fullscreen; clipboard-read; clipboard-write"></iframe>
</div>
</div>
</div>
<!-- Reports / compliance / export -->
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
<div class="drawer-head">
@@ -592,6 +650,28 @@
</div>
</div>
<div class="modal hidden" id="idrac-console-modal" aria-hidden="true" role="dialog" aria-labelledby="idrac-console-title">
<div class="modal-card idrac-console-card">
<button type="button" class="modal-close" id="btn-idrac-console-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">iDRAC console</p>
<h2 id="idrac-console-title">Console</h2>
<p class="modal-sub" id="idrac-console-sub"></p>
</div>
</div>
<div class="idrac-console-toolbar">
<button type="button" class="btn primary" id="btn-idrac-popout">Open popout</button>
<button type="button" class="btn ghost" id="btn-idrac-web">iDRAC Web</button>
<button type="button" class="btn ghost" id="btn-idrac-reload">Reload embed</button>
</div>
<p class="hint" id="idrac-console-note"></p>
<div class="idrac-console-frame-wrap">
<iframe id="idrac-console-frame" title="iDRAC console" allow="fullscreen; clipboard-read; clipboard-write" referrerpolicy="no-referrer-when-downgrade"></iframe>
</div>
</div>
</div>
<div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
@@ -634,12 +714,13 @@
<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=radar1"></script>
<script src="/app.js?v=power4"></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>
<script src="/console.js?v=console2"></script>
<script src="/present.js?v=present17"></script>
</body>
</html>
+1 -1
View File
@@ -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");
+68 -16
View File
@@ -32,7 +32,8 @@
<ol class="ps-steps ps-stagger">
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li>
<li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li>
<li><strong>Apps</strong> — map, network, reports, ops desk, copilots.</li>
<li><strong>Apps</strong> — map, network, reports, <em>Console wall</em>, ops desk, copilots.</li>
<li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li>
<li><strong>People</strong> — who asked, who built, who runs it.</li>
<li><strong>Value</strong> — what customers can take away.</li>
</ol>
@@ -60,10 +61,10 @@
html: `
<div class="ps-grid3">
<div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div>
<div class="ps-card"><h4>Remote ops in UI</h4><p>Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.</p></div>
<div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div>
<div class="ps-card"><h4>Faster briefings</h4><p>Click a chart → named systems → hand off in Ops desk.</p></div>
<div class="ps-card"><h4>Admin ↔ Data FDE</h4><p>Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.</p></div>
<div class="ps-card"><h4>Demo = daily tool</h4><p>Same stack you present is the stack you can keep using after the meeting.</p></div>
<div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
</div>`,
},
@@ -113,7 +114,7 @@
</div>
<div class="ps-arch-layer apps">
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
@@ -159,13 +160,45 @@
html: `
<div class="ps-apps">
<div class="ps-app"><i></i><strong>Live map</strong><span>Fleet topology · power · inspector</span></div>
<div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div>
<div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div>
<div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div>
<div class="ps-app"><i></i><strong>Ops desk</strong><span>Admin ↔ Data FDE tickets</span></div>
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
<div class="ps-app"><i></i><strong>OpenManage AI</strong><span>Full Open WebUI beside the cockpit</span></div>
</div>
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. No second inventory source.</p>`,
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Power and console actions ride OME → iDRAC — not a second inventory.</p>`,
},
{
id: "console-wall",
kicker: "Remote ops",
title: "Console wall · many iDRACs, one screen",
anim: "zoom",
html: `
<p class="ps-lead">Operators asked to <strong>see</strong> 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 <code>X-Frame-Options</code>.</p>
<div class="ps-grid3">
<div class="ps-card"><h4>4 or 8 screens</h4><p>Pick wall size · Small / Medium density · Fullscreen popup per tile.</p></div>
<div class="ps-card"><h4>Drag &amp; + Wall</h4><p>Fleet rail grouped by VLAN · drop onto slots · Auto-fill live BMCs.</p></div>
<div class="ps-card"><h4>Network badges</h4><p>VLAN · subnet · LIVE/OFF on every tile — know where you are looking.</p></div>
<div class="ps-card"><h4>Same-origin proxy</h4><p><code>/api/idrac-proxy/{id}/…</code> strips framing headers · WebSocket bridge for HTML5 console.</p></div>
<div class="ps-card"><h4>Real iDRACs only</h4><p>OOB / BMC endpoints — never bare OS host IPs that cannot speak iDRAC.</p></div>
<div class="ps-card"><h4>Still OME truth</h4><p>Device list and connectivity come from the live fleet snapshot.</p></div>
</div>`,
},
{
id: "power-idrac",
kicker: "Remote ops",
title: "Power on / off · without leaving the map",
anim: "rise",
html: `
<p class="ps-lead">Offline servers with POWER N/A are common in the lab. From Servers KPI, inspector, or Quick Connect you can submit an OME <strong>POWER_CONTROL</strong> job — on, graceful off, or cycle — with confirm for destructive actions.</p>
<div class="ps-seq">
<div class="ps-seq-step"><span>01</span><strong>Select</strong><p>Server / iDRAC on map or KPI list</p></div>
<div class="ps-seq-step"><span>02</span><strong>Power</strong><p>⏻ On · Off · Cycle via OME JobService</p></div>
<div class="ps-seq-step"><span>03</span><strong>Console</strong><p>iDRAC HTML5 in-panel (proxied)</p></div>
<div class="ps-seq-step"><span>04</span><strong>Verify</strong><p>Next fleet poll updates powered state</p></div>
<div class="ps-seq-step"><span>05</span><strong>Escalate</strong><p>Ops desk ticket if change needs a trail</p></div>
</div>
<p class="ps-sub">Demo power with care — production change windows still belong in official OME / iDRAC process.</p>`,
},
{
id: "ai",
@@ -173,13 +206,13 @@
title: "AI that starts from OME facts",
anim: "slide",
html: `
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, or Ops.</p>
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, Console, or Ops.</p>
<div class="ps-seq">
<div class="ps-seq-step"><span>01</span><strong>Ask</strong><p>Fleet question in Copilot</p></div>
<div class="ps-seq-step"><span>02</span><strong>Enrich</strong><p>BFF attaches Service Tag facts</p></div>
<div class="ps-seq-step"><span>03</span><strong>Route</strong><p>Open WebUI / vLLM completion</p></div>
<div class="ps-seq-step"><span>04</span><strong>Answer</strong><p>Still tied to the live map</p></div>
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · report · ticket</p></div>
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · console · ticket</p></div>
</div>
<p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>`,
},
@@ -232,6 +265,8 @@
html: `
<ol class="ps-steps ps-stagger">
<li>Pick a Service Tag on the map and open the inspector.</li>
<li>Open <strong>Console</strong> — Auto-fill live iDRACs (4 or 8 screens).</li>
<li>From Servers KPI: Power on a cold node · open iDRAC console in-panel.</li>
<li>Open Reports · Analytics and click a colored segment.</li>
<li>Ask Copilot a question that must cite Service Tags.</li>
<li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li>
@@ -272,6 +307,7 @@
<li>OME session / cache behaviour</li>
<li>AI completion path</li>
<li>Portal containers &amp; key <code>/api/*</code> contracts</li>
<li>iDRAC proxy · power jobs · Console wall</li>
</ol>
<p class="ps-sub">Delivered for ATC admins Jody &amp; Laurens by Data FDEs Mo &amp; Bart.</p>`,
},
@@ -321,7 +357,7 @@
</div>
<div class="ps-arch-layer apps">
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
@@ -458,13 +494,27 @@
html: `
<div class="ps-grid3">
<div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas &amp; KPIs.</p></div>
<div class="ps-card tech"><h4>/api/devices/…</h4><p>Inventory, landscape, expanded NICs.</p></div>
<div class="ps-card tech"><h4>/api/devices/…/power</h4><p>OME JobService POWER_CONTROL · on / off / cycle.</p></div>
<div class="ps-card tech"><h4>/api/idrac-proxy/…</h4><p>Same-origin HTML + WebSocket bridge · strips XFO.</p></div>
<div class="ps-card tech"><h4>/api/reports/*</h4><p>Analytics, firmware, warranty, brief.</p></div>
<div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div>
<div class="ps-card tech"><h4>/api/tickets</h4><p>Ops desk — local SQLite, not OME write-back.</p></div>
<div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
</div>`,
},
{
id: "idrac-proxy",
kicker: "Remote console",
title: "Why the iDRAC proxy exists",
anim: "slide",
html: `
<p class="ps-lead">Browsers refuse to iframe most iDRAC UIs (<code>X-Frame-Options: SAMEORIGIN|DENY</code>). Cockpit proxies the session so the Console wall and in-panel viewer stay on <code>:3090</code>.</p>
<div class="ps-grid3">
<div class="ps-card tech"><h4>HTTP reverse proxy</h4><p>Gzip passthrough · cookie Path rewrite · CSP <code>frame-ancestors *</code>.</p></div>
<div class="ps-card tech"><h4>JS bootstrap</h4><p>Patches fetch / XHR / WebSocket so absolute <code>/sysmgmt</code> paths stay on the proxy prefix.</p></div>
<div class="ps-card tech"><h4>SSRF guard</h4><p>Only fleet management IPs · prefers <code>idrac_ip</code> · rejects bare OS hosts.</p></div>
</div>
<p class="ps-sub">Operators still authenticate to iDRAC with their own credentials inside the embed.</p>`,
},
{
id: "trust",
kicker: "Trust &amp; data",
@@ -473,9 +523,10 @@
html: `
<div class="ps-cols">
<div>
<h4>From OME (read)</h4>
<h4>From OME (read + jobs)</h4>
<ul class="ps-bullets ps-stagger">
<li>Devices, power, inventory, warranty, baselines</li>
<li>Devices, power samples, inventory, warranty, baselines</li>
<li>DeviceAction power jobs (on / off / cycle) when operators confirm</li>
</ul>
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
<ul class="ps-bullets ps-stagger">
@@ -486,8 +537,8 @@
<h4>Security notes</h4>
<ul class="ps-bullets ps-stagger">
<li>Secrets only in portal environment variables</li>
<li>Primary OME usage is read for awareness &amp; demos</li>
<li>Production change windows stay in official OME / iDRAC</li>
<li>iDRAC proxy is an ops convenience — credentials stay with the user</li>
<li>Production change windows still belong in official OME / iDRAC process</li>
</ul>
</div>
</div>`,
@@ -500,6 +551,7 @@
html: `
<ol class="ps-steps ps-stagger">
<li>Return to the map and pick a Service Tag.</li>
<li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li>
<li>Open Reports and click a compliance segment.</li>
<li>Ask Copilot a question that must cite that ST.</li>
<li>Use <strong>Customer story</strong> for business / ops audiences.</li>
@@ -508,13 +560,13 @@
},
];
const BUILTIN_VERSION = 8;
const BUILTIN_VERSION = 9;
const BUILTIN_DECKS = [
{
id: "story",
name: "Customer story",
description: "Customer briefing — ask, architecture, apps, value",
description: "Customer briefing — ask, Console wall, power, architecture, value",
builtin: true,
slides: structuredClone(STORY_SLIDES),
},
+1 -1
View File
@@ -77,7 +77,7 @@
const drawer = $("#reports-drawer");
const scrim = $("#scrim");
if (!drawer) return;
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer"].forEach((id) => {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer", "#console-drawer"].forEach((id) => {
const el = $(id);
if (el) {
el.classList.remove("open");
+581 -1
View File
@@ -2100,7 +2100,7 @@ a.conn-link:hover {
background: rgba(0,20,40,0.35);
border-radius: 8px;
overflow: auto;
max-height: min(56vh, 520px);
max-height: min(70vh, 680px);
}
.kpi-list { padding: 0.35rem; }
.kpi-detail { padding: 0.75rem; }
@@ -2136,6 +2136,83 @@ a.conn-link:hover {
.kpi-detail h3 { margin: 0 0 0.35rem; color: var(--dell-bright); font-size: 1rem; }
.kpi-detail .meta { margin: 0 0 0.65rem; color: var(--muted); font-family: var(--mono); font-size: 0.72rem; }
.kpi-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.65rem 0; }
.power-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
width: 100%;
margin: 0.15rem 0 0.25rem;
}
.btn.power-on {
background: linear-gradient(135deg, #1a7a3c, #2db85a);
border-color: #2db85a;
color: #fff;
}
.btn.power-on:hover { filter: brightness(1.08); }
.btn.power-off {
background: linear-gradient(135deg, #8a1f1f, #c43a3a);
border-color: #c43a3a;
color: #fff;
}
.btn.power-off:hover { filter: brightness(1.08); }
.btn.idrac-console,
button.btn.idrac-console {
background: linear-gradient(135deg, #c45a12, #e87a2a) !important;
border-color: #e87a2a !important;
color: #fff !important;
font-weight: 600;
text-decoration: none !important;
}
.btn.idrac-console:hover {
filter: brightness(1.08);
color: #fff !important;
text-decoration: none !important;
}
.btn.idrac-console:disabled {
opacity: 0.45;
filter: none;
}
.connect-tile.idrac-console {
border-color: rgba(232, 122, 42, 0.55);
background: rgba(196, 90, 18, 0.18);
}
.connect-tile.idrac-console .ct-ico,
.connect-tile.idrac-console .ct-t { color: #ffb06a; }
.connect-tile.danger .ct-ico { color: #e86a6a; }
.connect-tile.danger:hover {
border-color: rgba(200, 70, 70, 0.55);
box-shadow: 0 0 0 1px rgba(200, 70, 70, 0.25);
}
/* iDRAC console embed modal */
#idrac-console-modal .idrac-console-card {
width: min(1120px, 96vw);
max-height: 94vh;
display: flex;
flex-direction: column;
padding-bottom: 0.85rem;
}
.idrac-console-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.35rem 0 0.5rem;
}
.idrac-console-frame-wrap {
flex: 1;
min-height: min(68vh, 720px);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #0a0e14;
}
#idrac-console-frame {
width: 100%;
height: min(68vh, 720px);
border: 0;
background: #0a0e14;
}
.kpi-kv { display: grid; gap: 0.3rem; margin-top: 0.5rem; }
.kpi-kv .row {
display: grid;
@@ -8232,3 +8309,506 @@ html[data-theme="light"] .chip.active {
background: linear-gradient(135deg, #ef4444, #b91c1c) !important;
color: #fff !important;
}
/* ===== Console · iDRAC wall ===== */
.btn.console-btn {
background: linear-gradient(135deg, rgba(196, 90, 18, 0.35), rgba(232, 122, 42, 0.55));
border-color: rgba(232, 122, 42, 0.75);
color: #ffe8d2;
font-weight: 600;
}
.btn.console-btn:hover { filter: brightness(1.08); color: #fff; }
.console-drawer.drawer,
.drawer.console-drawer {
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
border-left: none;
border-right: none;
transform: translateY(110%);
overflow: hidden;
box-sizing: border-box;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse 60% 40% at 8% 0%, rgba(232, 122, 42, 0.18), transparent 55%),
radial-gradient(ellipse 50% 45% at 92% 10%, rgba(0, 168, 232, 0.16), transparent 50%),
radial-gradient(ellipse 40% 35% at 50% 100%, rgba(61, 255, 160, 0.08), transparent 45%),
#050b12;
z-index: 80;
}
.console-drawer.drawer.open { transform: translateY(0); }
.console-drawer.console-ready .console-tile {
animation: consoleTileIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.console-drawer.console-ready .console-tile:nth-child(1) { animation-delay: 0.04s; }
.console-drawer.console-ready .console-tile:nth-child(2) { animation-delay: 0.1s; }
.console-drawer.console-ready .console-tile:nth-child(3) { animation-delay: 0.16s; }
.console-drawer.console-ready .console-tile:nth-child(4) { animation-delay: 0.22s; }
@keyframes consoleTileIn {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: none; }
}
.console-aurora {
pointer-events: none;
position: absolute;
inset: 0;
background:
linear-gradient(120deg, transparent 30%, rgba(255, 154, 60, 0.06) 50%, transparent 70%),
linear-gradient(300deg, transparent 40%, rgba(0, 168, 232, 0.05) 55%, transparent 75%);
background-size: 200% 200%;
animation: consoleAurora 14s ease-in-out infinite;
opacity: 0.9;
}
@keyframes consoleAurora {
0%, 100% { background-position: 0% 40%, 100% 60%; }
50% { background-position: 100% 60%, 0% 40%; }
}
.console-head {
position: relative;
z-index: 2;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-start;
border-bottom: 1px solid rgba(255, 154, 60, 0.22);
padding: 0.75rem 1rem;
}
.console-title {
display: block;
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.02em;
color: #ffb06a;
}
.console-sub {
margin: 0.1rem 0 0;
font-size: 0.72rem;
color: #9eb4c8;
}
.console-head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-left: auto;
}
.console-stats {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.cs-pill {
font-size: 0.68rem;
font-family: var(--mono);
padding: 0.28rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.28);
color: #d7e6f4;
}
.cs-pill strong { color: #fff; margin-right: 0.2rem; }
.cs-pill.live {
border-color: rgba(61, 255, 160, 0.45);
box-shadow: 0 0 12px rgba(61, 255, 160, 0.15);
}
.console-density .chip.active {
background: #e87a2a;
border-color: #e87a2a;
color: #041018;
}
.console-body {
position: relative;
z-index: 2;
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: min(340px, 32vw) 1fr;
gap: 0;
}
.console-fleet {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid rgba(255, 154, 60, 0.18);
background: rgba(0, 12, 24, 0.45);
}
.console-fleet-tools {
padding: 0.65rem 0.75rem 0.4rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.console-fleet-tools input[type="search"] {
width: 100%;
box-sizing: border-box;
margin-bottom: 0.45rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 154, 60, 0.28);
color: #fff;
border-radius: 8px;
padding: 0.45rem 0.6rem;
font-size: 0.8rem;
}
.console-subnet-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
max-height: 5.5rem;
overflow: auto;
margin-bottom: 0.35rem;
}
.console-subnet-chips .chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.66rem;
}
.console-chip-dot,
.console-net-dot {
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--chip, var(--net, #e87a2a));
box-shadow: 0 0 8px var(--chip, var(--net, #e87a2a));
flex-shrink: 0;
}
.console-fleet-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0.5rem 0.65rem 1rem;
}
.console-fleet-group {
margin: 0.65rem 0 0.3rem;
padding-left: 0.35rem;
border-left: 3px solid var(--net, #e87a2a);
}
.cfg-label {
font-size: 0.66rem;
color: #c5d6e6;
font-family: var(--mono);
}
.cfg-label code { color: #ffb06a; }
.console-fleet-card {
appearance: none;
width: 100%;
text-align: left;
display: grid;
gap: 0.15rem;
margin-bottom: 0.35rem;
padding: 0.55rem 0.6rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 18, 30, 0.75);
color: inherit;
cursor: grab;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.console-fleet-card:hover {
border-color: rgba(232, 122, 42, 0.55);
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
.console-fleet-card.dragging { opacity: 0.45; }
.console-fleet-card.selected {
border-color: #e87a2a;
box-shadow: 0 0 0 1px rgba(232, 122, 42, 0.35);
}
.console-fleet-card.is-live {
border-color: rgba(61, 255, 160, 0.28);
}
.console-fleet-card.on-wall {
background: linear-gradient(135deg, rgba(232, 122, 42, 0.12), rgba(0, 40, 70, 0.4));
}
.cfc-top {
display: flex;
justify-content: space-between;
gap: 0.4rem;
align-items: baseline;
}
.cfc-top strong {
font-size: 0.78rem;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cfc-status {
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.06em;
color: #7a8a9a;
}
.console-fleet-card.is-live .cfc-status { color: #3dffa0; }
.cfc-meta, .cfc-ip, .cfc-hint, .cfc-wall {
font-size: 0.66rem;
color: #9eb4c8;
font-family: var(--mono);
}
.cfc-wall { color: #ffb06a; }
.console-stage {
min-height: 0;
overflow: auto;
padding: 0.75rem;
display: grid;
gap: 0.75rem;
align-content: stretch;
}
.console-stage.density-medium {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.console-stage.density-small {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr;
}
@media (max-width: 1100px) {
.console-body { grid-template-columns: 1fr; }
.console-fleet { max-height: 38vh; border-right: none; border-bottom: 1px solid rgba(255, 154, 60, 0.18); }
.console-stage.density-small {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
}
.console-tile {
position: relative;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(4, 12, 22, 0.82);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
}
.console-tile.is-filled {
border-color: color-mix(in srgb, var(--net, #e87a2a) 55%, transparent);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--net, #e87a2a) 25%, transparent),
0 14px 36px rgba(0, 0, 0, 0.4);
}
.console-tile.is-live::after {
content: "";
position: absolute;
top: 10px;
right: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #3dffa0;
box-shadow: 0 0 12px #3dffa0;
z-index: 3;
pointer-events: none;
}
.console-tile.drag-over {
outline: 2px dashed #e87a2a;
outline-offset: -4px;
background: rgba(232, 122, 42, 0.08);
}
.console-tile.dragging { opacity: 0.5; }
.console-tile.is-empty .console-dropzone {
flex: 1;
display: grid;
place-content: center;
text-align: center;
gap: 0.25rem;
padding: 1.25rem;
background:
repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(255, 154, 60, 0.03) 10px,
rgba(255, 154, 60, 0.03) 20px
);
}
.cd-kicker {
font-size: 0.65rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #e87a2a;
}
.console-dropzone strong {
font-size: 1rem;
color: #f2f7fc;
}
.console-dropzone p {
margin: 0;
font-size: 0.72rem;
color: #8aa0b4;
max-width: 22rem;
}
.console-tile-chrome {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.55rem 0.25rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent);
cursor: grab;
}
.ctc-id { min-width: 0; display: grid; gap: 0.2rem; }
.ctc-slot {
font-size: 0.58rem;
letter-spacing: 0.08em;
color: #8aa0b4;
font-family: var(--mono);
}
.ctc-name {
font-size: 0.82rem;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-net-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.62rem;
font-family: var(--mono);
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--net, #e87a2a) 50%, transparent);
background: color-mix(in srgb, var(--net, #e87a2a) 14%, transparent);
color: #e8f0f8;
width: fit-content;
max-width: 100%;
}
.console-net-badge em {
font-style: normal;
font-weight: 700;
letter-spacing: 0.04em;
}
.console-net-badge em.on { color: #3dffa0; }
.console-net-badge em.off { color: #ff7a7a; }
.ctc-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
justify-content: flex-end;
}
.ctc-actions .btn.compact {
padding: 0.2rem 0.45rem;
font-size: 0.68rem;
min-height: 0;
}
.console-tile-meta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0 0.55rem 0.35rem;
font-size: 0.62rem;
font-family: var(--mono);
color: #9eb4c8;
}
.console-tile-meta .on { color: #3dffa0; }
.console-tile-meta .off { color: #ff8a8a; }
.console-frame-wrap {
flex: 1;
min-height: 140px;
background: #0a0e14;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.console-frame-wrap iframe {
width: 100%;
height: 100%;
min-height: 160px;
border: 0;
background: #0a0e14;
}
.console-stage.density-medium .console-frame-wrap { min-height: 220px; }
.console-stage.density-medium .console-frame-wrap iframe { min-height: 240px; }
.console-stage.density-small .ctc-name { font-size: 0.72rem; }
.console-stage.density-small .console-tile-meta { display: none; }
/* Fullscreen popup */
.console-fs-modal .console-fs-card {
width: min(1280px, 98vw);
max-height: 96vh;
display: flex;
flex-direction: column;
padding-bottom: 0.75rem;
}
.console-fs-head { margin-bottom: 0.5rem; }
.console-fs-frame-wrap {
flex: 1;
min-height: min(78vh, 820px);
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(232, 122, 42, 0.35);
background: #0a0e14;
}
#console-fs-frame {
width: 100%;
height: min(78vh, 820px);
border: 0;
}
html[data-theme="light"] .console-drawer.drawer {
background:
radial-gradient(ellipse 60% 40% at 8% 0%, rgba(232, 122, 42, 0.12), transparent 55%),
radial-gradient(ellipse 50% 45% at 92% 10%, rgba(0, 118, 206, 0.1), transparent 50%),
#f4f7fb;
}
html[data-theme="light"] .console-fleet {
background: rgba(255, 255, 255, 0.72);
}
html[data-theme="light"] .console-fleet-card {
background: #fff;
color: #102033;
}
html[data-theme="light"] .cfc-top strong { color: #102033; }
html[data-theme="light"] .console-tile {
background: #fff;
}
html[data-theme="light"] .ctc-name { color: #102033; }
/* Console wall · 4 / 8 screens + drag shields */
.console-stage.slots-4.density-medium {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.console-stage.slots-4.density-small {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr;
}
.console-stage.slots-8.density-medium,
.console-stage.slots-8.density-small {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: 1fr 1fr;
}
.console-stage.slots-8 .console-frame-wrap { min-height: 120px; }
.console-stage.slots-8 .console-frame-wrap iframe { min-height: 140px; }
.console-stage.slots-8.density-medium .console-frame-wrap { min-height: 160px; }
.console-stage.slots-8.density-medium .console-frame-wrap iframe { min-height: 170px; }
.console-frame-wrap { position: relative; }
.console-drop-shield {
display: none;
position: absolute;
inset: 0;
z-index: 2;
background: transparent;
}
.console-drawer.is-dragging .console-drop-shield { display: block; }
.console-drawer.is-dragging .console-frame-wrap iframe { pointer-events: none; }
.cfc-actions { margin-top: 0.25rem; }
.cfc-actions .btn.compact {
padding: 0.2rem 0.5rem;
font-size: 0.66rem;
min-height: 0;
}
.console-fleet-card { cursor: grab; }