diff --git a/api/main.py b/api/main.py index a1c3840..8d9bdb1 100644 --- a/api/main.py +++ b/api/main.py @@ -1,4 +1,5 @@ +import urllib.parse import asyncio import json import re @@ -14,9 +15,9 @@ from typing import Any import httpx import asyncssh -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, Response +from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field from pydantic_settings import BaseSettings @@ -3133,6 +3134,514 @@ async def device_detail(device_id: int): return detail +# OME JobService powerState values (Dell set_power_state.py) +OME_POWER_ACTIONS = { + "on": ("2", "Power On"), + "cycle": ("5", "Power Cycle"), + "force_off": ("8", "Power Off Non-Graceful"), + "off": ("12", "Power Off Graceful"), +} + + +class PowerActionIn(BaseModel): + action: str # on | off | cycle | force_off + confirm: bool = False + + +def _fleet_device(device_id: int) -> dict | None: + return next((d for d in (STATE.get("devices") or []) if d.get("id") == device_id), None) + + +@app.post("/api/devices/{device_id}/power") +async def device_power_control(device_id: int, payload: PowerActionIn): + """Power control via OME JobService DeviceAction_Task (iDRAC through OME).""" + action = (payload.action or "").strip().lower() + if action not in OME_POWER_ACTIONS: + raise HTTPException(400, f"Invalid action. Use one of: {', '.join(OME_POWER_ACTIONS)}") + if action in ("off", "force_off", "cycle") and not payload.confirm: + raise HTTPException(400, "confirm=true required for off/cycle actions") + + node = _fleet_device(device_id) + if not node: + raise HTTPException(404, "Device not found in current fleet snapshot") + if node.get("source") == "inventory" or (isinstance(device_id, int) and device_id < 0): + raise HTTPException(400, "Inventory-only hosts cannot be powered via OME") + if not (node.get("is_server") or node.get("is_idrac") or node.get("type") == 1000): + raise HTTPException(400, "Power control is only supported for servers / iDRAC endpoints") + + power_state, label = OME_POWER_ACTIONS[action] + async with httpx.AsyncClient(verify=False, timeout=60.0) as client: + base, headers, sid = await ome_session(client) + try: + body = { + "Id": 0, + "JobName": f"Cockpit {label}", + "JobDescription": f"OME Cockpit power control · {node.get('name')} · {node.get('service_tag')}", + "State": "Enabled", + "Schedule": "startnow", + "JobType": {"Name": "DeviceAction_Task"}, + "Targets": [ + { + "Id": int(device_id), + "Data": "", + "TargetType": {"Id": 1000, "Name": "DEVICE"}, + } + ], + "Params": [ + {"Key": "override", "Value": "true"}, + {"Key": "powerState", "Value": str(power_state)}, + {"Key": "operationName", "Value": "POWER_CONTROL"}, + {"Key": "deviceTypes", "Value": "1000"}, + ], + } + r = await client.post( + f"{base}/api/JobService/Jobs", + headers={**headers, "Content-Type": "application/json"}, + json=body, + ) + if r.status_code >= 400: + raise HTTPException( + r.status_code, + f"OME power job rejected: {(r.text or '')[:300]}", + ) + job = r.json() if r.content else {} + job_id = job.get("Id") + log.info( + "OME power %s for device %s (%s) → job %s", + action, + device_id, + node.get("service_tag"), + job_id, + ) + return { + "ok": True, + "action": action, + "label": label, + "device_id": device_id, + "name": node.get("name"), + "service_tag": node.get("service_tag"), + "job_id": job_id, + "note": "OME accepted the job. Power state updates on the next fleet poll.", + } + finally: + await ome_session_delete(client, base, headers, sid) + + +@app.get("/api/devices/{device_id}/idrac-console") +async def device_idrac_console(device_id: int, request: Request): + """Return iDRAC web / HTML5 console URLs — prefer same-origin proxy embed.""" + node = _fleet_device(device_id) + if not node: + raise HTTPException(404, "Device not found in current fleet snapshot") + ip = node.get("idrac_ip") or node.get("ip") + if not ip: + raise HTTPException(400, "No iDRAC / management IP for this device") + base = f"https://{ip}" + # Same-origin reverse proxy so X-Frame-Options / CSP cannot block the iframe + embed = f"/api/idrac-proxy/{device_id}/restgui/start.html?console" + urls = { + "web": base + "/", + "html5": base + "/console", + "restgui": base + "/restgui/start.html?console", + "viewer": base + "/virtualconsole", + "embed": str(request.base_url).rstrip("/") + embed, + "embed_path": embed, + } + return { + "device_id": device_id, + "name": node.get("name"), + "service_tag": node.get("service_tag"), + "ip": ip, + "powered_on": node.get("powered_on"), + "connected": node.get("connected"), + "urls": urls, + "primary": embed, + "note": ( + "Console is proxied through Cockpit (same-origin) so it can run in this panel. " + "Log in with your iDRAC credentials. Popout still opens the iDRAC directly." + ), + } + + +_IDRAC_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "content-length", + "content-encoding", + "content-security-policy", + "content-security-policy-report-only", + "x-frame-options", + "x-xss-protection", + "strict-transport-security", +} + + +def _idrac_proxy_prefix(device_id: int) -> str: + return f"/api/idrac-proxy/{device_id}" + + +def _idrac_resolve(device_id: int) -> tuple[dict, str]: + node = _fleet_device(device_id) + if not node: + raise HTTPException(404, "Device not found in current fleet snapshot") + + # Prefer explicit iDRAC management IP; never treat bare OS inventory as iDRAC + ip = node.get("idrac_ip") + if not ip and node.get("is_idrac"): + ip = node.get("ip") + if not ip: + st = (node.get("service_tag") or "").strip() + if st: + sibling = next( + ( + d + for d in (STATE.get("devices") or []) + if d.get("is_idrac") + and (d.get("service_tag") or "").strip().upper() == st.upper() + and (d.get("idrac_ip") or d.get("ip")) + ), + None, + ) + if sibling: + node = sibling + ip = sibling.get("idrac_ip") or sibling.get("ip") + if not ip: + raise HTTPException( + 400, + "No iDRAC management IP for this device — select an iDRAC endpoint (OOB), not the OS host", + ) + try: + ipaddress.ip_address(ip) + except ValueError as e: + raise HTTPException(400, "Invalid management IP") from e + return node, ip + + +def _idrac_rewrite_location(value: str, device_id: int, idrac_ip: str) -> str: + prefix = _idrac_proxy_prefix(device_id) + if not value: + return value + if value.startswith("/"): + return prefix + value + try: + u = urllib.parse.urlparse(value) + except Exception: + return value + host = (u.hostname or "").lower() + if host == idrac_ip.lower() or host.endswith(".dell-atc.lan") or host.endswith(".internal."): + path = u.path or "/" + return prefix + path + (("?" + u.query) if u.query else "") + (("#" + u.fragment) if u.fragment else "") + return value + + +def _idrac_rewrite_cookie(value: str, device_id: int) -> str: + """Point cookies at the proxy path; drop Domain/Secure so HTTP cockpit can store them.""" + prefix = _idrac_proxy_prefix(device_id) + parts = [] + for part in value.split(";"): + p = part.strip() + pl = p.lower() + if pl.startswith("domain="): + continue + if pl == "secure": + continue + if pl.startswith("path="): + parts.append(f"Path={prefix}/") + continue + parts.append(p) + if not any(p.lower().startswith("path=") for p in parts): + parts.append(f"Path={prefix}/") + return "; ".join(parts) + + +def _idrac_inject_bootstrap(html: bytes, device_id: int, idrac_ip: str) -> bytes: + """Rewrite absolute paths + patch fetch/XHR/WebSocket so the SPA stays on the proxy.""" + prefix = _idrac_proxy_prefix(device_id) + try: + text = html.decode("utf-8") + except UnicodeDecodeError: + text = html.decode("latin-1") + + # Absolute root paths in markup + for attr in ("href", "src", "action"): + text = re.sub( + rf'({attr}\s*=\s*["\'])/(?!/)', + rf"\1{prefix}/", + text, + flags=re.I, + ) + text = text.replace(f"https://{idrac_ip}/", f"{prefix}/") + text = text.replace(f"http://{idrac_ip}/", f"{prefix}/") + + boot = f"""""" + if re.search(r"]*>", text, flags=re.I): + text = re.sub(r"(]*>)", r"\1" + boot, text, count=1, flags=re.I) + else: + text = boot + text + return text.encode("utf-8") + + +def _idrac_rewrite_css(css: bytes, device_id: int, idrac_ip: str) -> bytes: + prefix = _idrac_proxy_prefix(device_id) + try: + text = css.decode("utf-8") + except UnicodeDecodeError: + text = css.decode("latin-1") + text = re.sub(r"url\((['\"]?)/", rf"url(\1{prefix}/", text) + text = text.replace(f"https://{idrac_ip}/", f"{prefix}/") + return text.encode("utf-8") + + +async def _idrac_proxy_http(device_id: int, path: str, request: Request): + import gzip + + _, idrac_ip = _idrac_resolve(device_id) + prefix = _idrac_proxy_prefix(device_id) + rel = path or "" + qs = request.url.query + target = f"https://{idrac_ip}/{rel}" + (f"?{qs}" if qs else "") + + # Forward headers; force gzip so iDRAC serves .js/.css (precompressed only) + fwd: dict[str, str] = {} + for k, v in request.headers.items(): + lk = k.lower() + if lk in ("host", "content-length", "connection", "accept-encoding"): + continue + if lk == "referer" and v: + # Map our proxy referer back to iDRAC origin when possible + v = v.replace(str(request.base_url).rstrip("/") + prefix, f"https://{idrac_ip}") + v = v.replace(prefix, f"https://{idrac_ip}") + fwd[k] = v + fwd["Host"] = idrac_ip + fwd["Accept-Encoding"] = "gzip, deflate" + # Avoid long-lived upstream hangs + body = await request.body() + + try: + async with httpx.AsyncClient(verify=False, timeout=120.0, follow_redirects=False) as client: + upstream = await client.request( + request.method, + target, + headers=fwd, + content=body if body else None, + ) + except httpx.RequestError as e: + accept = (request.headers.get("accept") or "").lower() + dest = (request.headers.get("sec-fetch-dest") or "").lower() + if "text/html" in accept or dest == "iframe" or request.method == "GET": + err = ( + str(e) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + html = f"""iDRAC unreachable +
+

iDRAC not reachable via proxy

+

Target {idrac_ip} did not accept a connection from Cockpit.

+

Use an iDRAC / OOB endpoint (VLAN 40/41…), not the OS host IP.

+

{err}

+
""" + return Response(content=html, status_code=502, media_type="text/html") + raise HTTPException(502, f"iDRAC proxy unreachable: {e}") from e + + raw = upstream.content + enc = (upstream.headers.get("content-encoding") or "").lower() + if "gzip" in enc and raw: + try: + raw = gzip.decompress(raw) + except Exception: + pass + elif "deflate" in enc and raw: + try: + import zlib + + raw = zlib.decompress(raw) + except Exception: + try: + import zlib + + raw = zlib.decompress(raw, -zlib.MAX_WBITS) + except Exception: + pass + + ctype = (upstream.headers.get("content-type") or "").lower() + if "text/html" in ctype and raw: + raw = _idrac_inject_bootstrap(raw, device_id, idrac_ip) + elif "text/css" in ctype and raw: + raw = _idrac_rewrite_css(raw, device_id, idrac_ip) + elif ("javascript" in ctype or "ecmascript" in ctype) and raw: + # Light touch: rewrite absolute iDRAC URLs embedded as strings + try: + t = raw.decode("utf-8") + except UnicodeDecodeError: + t = raw.decode("latin-1") + t = t.replace(f"https://{idrac_ip}", prefix) + t = t.replace(f"wss://{idrac_ip}", f"ws://{request.url.hostname}:{request.url.port or (443 if request.url.scheme == 'https' else 80)}{prefix}") + raw = t.encode("utf-8") + + out_headers: list[tuple[str, str]] = [] + for k, v in upstream.headers.multi_items(): + lk = k.lower() + if lk in _IDRAC_HOP_HEADERS: + continue + if lk == "location": + out_headers.append((k, _idrac_rewrite_location(v, device_id, idrac_ip))) + continue + if lk == "set-cookie": + out_headers.append((k, _idrac_rewrite_cookie(v, device_id))) + continue + out_headers.append((k, v)) + # Allow embedding in Cockpit UI + out_headers.append(("Content-Security-Policy", "frame-ancestors *")) + out_headers.append(("Cache-Control", "no-store")) + + resp = Response(content=raw, status_code=upstream.status_code) + for k, v in out_headers: + resp.headers.append(k, v) + if not any(k.lower() == "content-type" for k, _ in out_headers): + ct = upstream.headers.get("content-type") + if ct: + resp.headers["content-type"] = ct + return resp + + +@app.api_route( + "/api/idrac-proxy/{device_id}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +@app.api_route( + "/api/idrac-proxy/{device_id}/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +async def idrac_http_proxy(device_id: int, request: Request, path: str = ""): + return await _idrac_proxy_http(device_id, path, request) + + +@app.websocket("/api/idrac-proxy/{device_id}/{path:path}") +async def idrac_ws_proxy(ws: WebSocket, device_id: int, path: str): + """Bridge browser WebSocket ↔ iDRAC (virtual console / live sessions).""" + import ssl + import websockets + + _, idrac_ip = _idrac_resolve(device_id) + await ws.accept() + qs = ws.scope.get("query_string", b"").decode() + target = f"wss://{idrac_ip}/{path}" + (f"?{qs}" if qs else "") + sub = ws.scope.get("subprotocols") or [] + ssl_ctx = ssl._create_unverified_context() + try: + async with websockets.connect( + target, + ssl=ssl_ctx, + subprotocols=list(sub) if sub else None, + open_timeout=30, + max_size=8 * 1024 * 1024, + ) as upstream: + + async def client_to_upstream(): + while True: + msg = await ws.receive() + if msg.get("type") == "websocket.disconnect": + break + if "text" in msg and msg["text"] is not None: + await upstream.send(msg["text"]) + elif "bytes" in msg and msg["bytes"] is not None: + await upstream.send(msg["bytes"]) + + async def upstream_to_client(): + async for message in upstream: + if isinstance(message, (bytes, bytearray)): + await ws.send_bytes(message) + else: + await ws.send_text(message) + + _done, pending = await asyncio.wait( + [ + asyncio.create_task(client_to_upstream()), + asyncio.create_task(upstream_to_client()), + ], + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + except HTTPException: + try: + await ws.close() + except Exception: + pass + raise + except Exception as e: + log.warning("iDRAC WS proxy error device=%s: %s", device_id, e) + try: + await ws.close(code=1011) + except Exception: + pass + + @app.get("/api/devices/{device_id}/expansion") async def device_expansion(device_id: int, force: bool = False): if force and device_id in DETAIL_CACHE: @@ -5949,6 +6458,11 @@ async def network_js(): return FileResponse(STATIC_DIR / "network.js", media_type="application/javascript") +@app.get("/console.js") +async def console_js(): + return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript") + + @app.get("/ssh.js") async def ssh_js(): return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript") diff --git a/api/requirements.txt b/api/requirements.txt index 6de65ef..4803cce 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,6 +1,7 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 httpx==0.28.1 +websockets==14.1 pydantic==2.10.4 pydantic-settings==2.7.0 asyncssh==2.18.0 diff --git a/ui/app.js b/ui/app.js index 553483c..a271ee2 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1194,12 +1194,18 @@
+
+ + + +
+ + ${node.idrac_url ? `iDRAC Web ↗` : ""} - ${node.idrac_url ? `iDRAC Web` : ""}

Related alerts

@@ -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 ? ` + + + ${idrac ? `` : `
iDRAC consoleNo management IP
`} + ${idrac ? `
+ iDRAC Web - BMC console · ${escapeHtml(ip)} - ` : `
iDRAC WebNo management IP
`} + Open in new tab · ${escapeHtml(ip)} + ` : ""} ${ip ? ` - ${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 @@
  1. Why — the gap around OME and why AI alone is not enough.
  2. Architecture — one design: Browser → Cockpit BFF → OME / AI (with logos).
  3. -
  4. Apps — map, network, reports, ops desk, copilots.
  5. +
  6. Apps — map, network, reports, Console wall, ops desk, copilots.
  7. +
  8. Remote ops — power via OME→iDRAC and live consoles in-panel.
  9. People — who asked, who built, who runs it.
  10. 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.

+
+
01Select

Server / iDRAC on map or KPI list

+
02Power

⏻ On · Off · Cycle via OME JobService

+
03Console

iDRAC HTML5 in-panel (proxied)

+
04Verify

Next fleet poll updates powered state

+
05Escalate

Ops 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.

01Ask

Fleet question in Copilot

02Enrich

BFF attaches Service Tag facts

03Route

Open WebUI / vLLM completion

04Answer

Still tied to the live map

-
05Act

Inspector · report · ticket

+
05Act

Inspector · console · ticket

OME for truth · AI for speed · Cockpit for the operator experience.

`, }, @@ -232,6 +265,8 @@ html: `
  1. Pick a Service Tag on the map and open the inspector.
  2. +
  3. Open Console — Auto-fill live iDRACs (4 or 8 screens).
  4. +
  5. From Servers KPI: Power on a cold node · open iDRAC console in-panel.
  6. Open Reports · Analytics and click a colored segment.
  7. Ask Copilot a question that must cite Service Tags.
  8. Switch to Technical architecture for API and trust-boundary depth.
  9. @@ -272,6 +307,7 @@
  10. OME session / cache behaviour
  11. AI completion path
  12. Portal containers & key /api/* contracts
  13. +
  14. iDRAC proxy · power jobs · Console wall

Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.

`, }, @@ -321,7 +357,7 @@
MapTopology
-
ReportsAnalytics
+
ConsoleiDRAC wall
Cockpit UIDrawers · KPIs
NetworkFabric · Racks
Ops / AITickets · Copilot
@@ -458,13 +494,27 @@ html: `

/api/fleet

Cached device graph for canvas & KPIs.

-

/api/devices/…

Inventory, landscape, expanded NICs.

+

/api/devices/…/power

OME JobService POWER_CONTROL · on / off / cycle.

+

/api/idrac-proxy/…

Same-origin HTML + WebSocket bridge · strips XFO.

/api/reports/*

Analytics, firmware, warranty, brief.

/api/chat · /api/models

Grounded completions · model list.

-

/api/tickets

Ops desk — local SQLite, not OME write-back.

/api/network/*

Fabric ports · racks · VLANs.

`, }, + { + id: "idrac-proxy", + kicker: "Remote console", + title: "Why the iDRAC proxy exists", + anim: "slide", + html: ` +

Browsers refuse to iframe most iDRAC UIs (X-Frame-Options: SAMEORIGIN|DENY). Cockpit proxies the session so the Console wall and in-panel viewer stay on :3090.

+
+

HTTP reverse proxy

Gzip passthrough · cookie Path rewrite · CSP frame-ancestors *.

+

JS bootstrap

Patches fetch / XHR / WebSocket so absolute /sysmgmt paths stay on the proxy prefix.

+

SSRF guard

Only fleet management IPs · prefers idrac_ip · rejects bare OS hosts.

+
+

Operators still authenticate to iDRAC with their own credentials inside the embed.

`, + }, { id: "trust", kicker: "Trust & data", @@ -473,9 +523,10 @@ html: `
-

From OME (read)

+

From OME (read + jobs)

    -
  • Devices, power, inventory, warranty, baselines
  • +
  • Devices, power samples, inventory, warranty, baselines
  • +
  • DeviceAction power jobs (on / off / cycle) when operators confirm

Cockpit-local (write)

    @@ -486,8 +537,8 @@

    Security notes

    • Secrets only in portal environment variables
    • -
    • Primary OME usage is read for awareness & demos
    • -
    • Production change windows stay in official OME / iDRAC
    • +
    • iDRAC proxy is an ops convenience — credentials stay with the user
    • +
    • Production change windows still belong in official OME / iDRAC process
`, @@ -500,6 +551,7 @@ html: `
  1. Return to the map and pick a Service Tag.
  2. +
  3. Open Console and drop two iDRACs onto the wall.
  4. Open Reports and click a compliance segment.
  5. Ask Copilot a question that must cite that ST.
  6. Use Customer story for business / ops audiences.
  7. @@ -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), }, diff --git a/ui/reports.js b/ui/reports.js index c54dfa8..1ebf1c7 100644 --- a/ui/reports.js +++ b/ui/reports.js @@ -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"); diff --git a/ui/styles.css b/ui/styles.css index 19e3274..fedd25c 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -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; }