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
+516 -2
View File
@@ -1,4 +1,5 @@
import urllib.parse
import asyncio import asyncio
import json import json
import re import re
@@ -14,9 +15,9 @@ from typing import Any
import httpx import httpx
import asyncssh 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.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -3133,6 +3134,514 @@ async def device_detail(device_id: int):
return detail 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"""<script>(function(){{
var PREFIX={json.dumps(prefix)};
var IDRAC_IP={json.dumps(idrac_ip)};
function abs(u){{
if(!u || typeof u!=="string") return u;
try{{
if(u.indexOf("blob:")===0 || u.indexOf("data:")===0 || u.indexOf("mailto:")===0) return u;
var x=new URL(u, location.href);
var h=(x.hostname||"").toLowerCase();
if(h===IDRAC_IP.toLowerCase() || (h && h.indexOf("dell-atc.lan")!==-1)){{
return location.origin + PREFIX + (x.pathname||"/") + x.search + x.hash;
}}
if(x.origin===location.origin && x.pathname.indexOf(PREFIX)!==0){{
return location.origin + PREFIX + (x.pathname||"/") + x.search + x.hash;
}}
return u;
}}catch(e){{ return u; }}
}}
function wsAbs(u){{
var a=abs(u);
if(typeof a!=="string") return u;
if(location.protocol==="http:" && a.indexOf("wss://"+location.host)===0)
return "ws://"+location.host+a.slice(("wss://"+location.host).length);
if(location.protocol==="https:" && a.indexOf("ws://"+location.host)===0)
return "wss://"+location.host+a.slice(("ws://"+location.host).length);
return a;
}}
var _f=window.fetch;
window.fetch=function(input, init){{
if(typeof input==="string") input=abs(input);
else if(input && typeof Request!=="undefined" && input instanceof Request)
input=new Request(abs(input.url), input);
return _f.call(this, input, init);
}};
var _o=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(method, url){{
var args=Array.prototype.slice.call(arguments, 2);
return _o.apply(this, [method, abs(url)].concat(args));
}};
var _WS=window.WebSocket;
window.WebSocket=function(url, protocols){{
var u=wsAbs(url);
return protocols===undefined ? new _WS(u) : new _WS(u, protocols);
}};
window.WebSocket.prototype=_WS.prototype;
window.WebSocket.CONNECTING=_WS.CONNECTING;
window.WebSocket.OPEN=_WS.OPEN;
window.WebSocket.CLOSING=_WS.CLOSING;
window.WebSocket.CLOSED=_WS.CLOSED;
}})();</script>"""
if re.search(r"<head[^>]*>", text, flags=re.I):
text = re.sub(r"(<head[^>]*>)", 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>iDRAC unreachable</title>
<style>
body{{margin:0;font:14px/1.45 system-ui,sans-serif;background:#0a0e14;color:#e8f0f8;
display:grid;place-items:center;min-height:100vh;padding:1.5rem}}
.card{{max-width:28rem;border:1px solid rgba(232,122,42,.45);border-radius:12px;padding:1.25rem 1.4rem;
background:rgba(20,12,8,.9)}}
h1{{margin:0 0 .4rem;font-size:1.05rem;color:#ffb06a}}
p{{margin:.35rem 0;color:#9eb4c8}}
code{{color:#fff;font-size:.85em}}
</style></head><body><div class="card">
<h1>iDRAC not reachable via proxy</h1>
<p>Target <code>{idrac_ip}</code> did not accept a connection from Cockpit.</p>
<p>Use an <strong>iDRAC / OOB</strong> endpoint (VLAN 40/41), not the OS host IP.</p>
<p style="font-size:.78rem;opacity:.75">{err}</p>
</div></body></html>"""
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") @app.get("/api/devices/{device_id}/expansion")
async def device_expansion(device_id: int, force: bool = False): async def device_expansion(device_id: int, force: bool = False):
if force and device_id in DETAIL_CACHE: 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") 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") @app.get("/ssh.js")
async def ssh_js(): async def ssh_js():
return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript") return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript")
+1
View File
@@ -1,6 +1,7 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
httpx==0.28.1 httpx==0.28.1
websockets==14.1
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.0 pydantic-settings==2.7.0
asyncssh==2.18.0 asyncssh==2.18.0
+185 -7
View File
@@ -1194,12 +1194,18 @@
</div> </div>
<div class="kpi-actions"> <div class="kpi-actions">
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button> <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="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 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="connect">Quick Connect</button>
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</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> <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>
<div class="inv-section"> <div class="inv-section">
<h3>Related alerts</h3> <h3>Related alerts</h3>
@@ -1845,6 +1851,105 @@
showToast._t = setTimeout(() => el.classList.remove("show"), ms); 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) { function pushNotifyCard(ev) {
const host = $("#notify-stack"); const host = $("#notify-stack");
if (!host) return; if (!host) return;
@@ -1952,11 +2057,26 @@
const ip = node.ip; const ip = node.ip;
const idrac = node.idrac_url || (ip ? `https://${ip}` : null); const idrac = node.idrac_url || (ip ? `https://${ip}` : null);
$("#connect-grid").innerHTML = ` $("#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-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-t">iDRAC Web</span>
<span class="ct-s">BMC console · ${escapeHtml(ip)}</span> <span class="ct-s">Open in new tab · ${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>`} </a>` : ""}
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh"> ${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh">
<span class="ct-ico">〉_</span> <span class="ct-ico">〉_</span>
<span class="ct-t">SSH terminal</span> <span class="ct-t">SSH terminal</span>
@@ -1995,6 +2115,19 @@
} }
return; 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")) { if (e.target.closest("#btn-modal-ssh")) {
closeConnect(); closeConnect();
window.cockpitSsh?.open(node); 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 id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty &amp; Dell compliance…</p></div>
<div class="action-stack"> <div class="action-stack">
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button> <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-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>` : ""} ${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> <button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
@@ -2263,6 +2402,10 @@
}); });
updateFocusContext(); updateFocusContext();
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node)); $("#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-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
$("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node)); $("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node));
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node)); $("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
@@ -2617,6 +2760,14 @@
closeKpiPopup(); closeKpiPopup();
focusDeviceId(node.id); focusDeviceId(node.id);
showToast("Focused " + (node.name || "")); 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") { } else if (a === "ssh") {
window.cockpitSsh?.open(node); window.cockpitSsh?.open(node);
} else if (a === "rdp") { } else if (a === "rdp") {
@@ -2857,6 +3008,9 @@
openConnect, openConnect,
showInspector, showInspector,
openAi, openAi,
requestDevicePower,
openIdracConsole,
closeIdracConsole,
relTime, relTime,
escapeHtml, escapeHtml,
openTriage: null, openTriage: null,
@@ -2924,20 +3078,44 @@
$("#btn-ai")?.addEventListener("click", () => openAi(null)); $("#btn-ai")?.addEventListener("click", () => openAi(null));
$("#btn-ai-close").addEventListener("click", closeAi); $("#btn-ai-close").addEventListener("click", closeAi);
$("#btn-connect-close")?.addEventListener("click", closeConnect); $("#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", () => { $("#scrim").addEventListener("click", () => {
const mode = $("#scrim")?.dataset.mode; const mode = $("#scrim")?.dataset.mode;
if (mode === "triage") document.getElementById("btn-triage-close")?.click(); if (mode === "triage") document.getElementById("btn-triage-close")?.click();
else if (mode === "kpi") closeKpiPopup(); else if (mode === "kpi") closeKpiPopup();
else if (mode === "connect") closeConnect(); else if (mode === "connect") closeConnect();
else if (mode === "idrac-console") closeIdracConsole();
else if ( else if (
mode === "chat-drawer" || mode === "chat-drawer" ||
mode === "ops-drawer" || mode === "ops-drawer" ||
mode === "ai-drawer" || mode === "ai-drawer" ||
mode === "reports-drawer" || mode === "reports-drawer" ||
mode === "an-context" || 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(); } else closeAi();
}); });
$("#btn-ome-console").addEventListener("click", () => { $("#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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" /> <link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
<script> <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-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-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 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-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 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> <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> <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>
<!-- 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 --> <!-- Reports / compliance / export -->
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true"> <div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
<div class="drawer-head"> <div class="drawer-head">
@@ -592,6 +650,28 @@
</div> </div>
</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"> <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.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.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="/ops.js?v=opsfde2"></script>
<script src="/ssh.js?v=fabric4"></script> <script src="/ssh.js?v=fabric4"></script>
<script src="/rdp.js?v=fabric4"></script> <script src="/rdp.js?v=fabric4"></script>
<script src="/reports.js?v=anctx1"></script> <script src="/reports.js?v=anctx1"></script>
<script src="/network.js?v=vlanall1"></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> </body>
</html> </html>
+1 -1
View File
@@ -95,7 +95,7 @@
const drawer = $("#network-drawer"); const drawer = $("#network-drawer");
const scrim = $("#scrim"); const scrim = $("#scrim");
if (!drawer) return; 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); const el = $(id);
if (el) { if (el) {
el.classList.remove("open"); el.classList.remove("open");
+68 -16
View File
@@ -32,7 +32,8 @@
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li> <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>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>People</strong> — who asked, who built, who runs it.</li>
<li><strong>Value</strong> — what customers can take away.</li> <li><strong>Value</strong> — what customers can take away.</li>
</ol> </ol>
@@ -60,10 +61,10 @@
html: ` html: `
<div class="ps-grid3"> <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>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>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>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>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 class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
</div>`, </div>`,
}, },
@@ -113,7 +114,7 @@
</div> </div>
<div class="ps-arch-layer apps"> <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>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 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>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div> <div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
@@ -159,13 +160,45 @@
html: ` html: `
<div class="ps-apps"> <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>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>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>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>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>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> </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", id: "ai",
@@ -173,13 +206,13 @@
title: "AI that starts from OME facts", title: "AI that starts from OME facts",
anim: "slide", anim: "slide",
html: ` 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">
<div class="ps-seq-step"><span>01</span><strong>Ask</strong><p>Fleet question in Copilot</p></div> <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>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>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>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> </div>
<p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>`, <p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>`,
}, },
@@ -232,6 +265,8 @@
html: ` html: `
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li>Pick a Service Tag on the map and open the inspector.</li> <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>Open Reports · Analytics and click a colored segment.</li>
<li>Ask Copilot a question that must cite Service Tags.</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> <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>OME session / cache behaviour</li>
<li>AI completion path</li> <li>AI completion path</li>
<li>Portal containers &amp; key <code>/api/*</code> contracts</li> <li>Portal containers &amp; key <code>/api/*</code> contracts</li>
<li>iDRAC proxy · power jobs · Console wall</li>
</ol> </ol>
<p class="ps-sub">Delivered for ATC admins Jody &amp; Laurens by Data FDEs Mo &amp; Bart.</p>`, <p class="ps-sub">Delivered for ATC admins Jody &amp; Laurens by Data FDEs Mo &amp; Bart.</p>`,
}, },
@@ -321,7 +357,7 @@
</div> </div>
<div class="ps-arch-layer apps"> <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>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 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>Network</strong><span>Fabric · Racks</span></div>
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div> <div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
@@ -458,13 +494,27 @@
html: ` html: `
<div class="ps-grid3"> <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/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/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/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 class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
</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", id: "trust",
kicker: "Trust &amp; data", kicker: "Trust &amp; data",
@@ -473,9 +523,10 @@
html: ` html: `
<div class="ps-cols"> <div class="ps-cols">
<div> <div>
<h4>From OME (read)</h4> <h4>From OME (read + jobs)</h4>
<ul class="ps-bullets ps-stagger"> <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> </ul>
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4> <h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
@@ -486,8 +537,8 @@
<h4>Security notes</h4> <h4>Security notes</h4>
<ul class="ps-bullets ps-stagger"> <ul class="ps-bullets ps-stagger">
<li>Secrets only in portal environment variables</li> <li>Secrets only in portal environment variables</li>
<li>Primary OME usage is read for awareness &amp; demos</li> <li>iDRAC proxy is an ops convenience — credentials stay with the user</li>
<li>Production change windows stay in official OME / iDRAC</li> <li>Production change windows still belong in official OME / iDRAC process</li>
</ul> </ul>
</div> </div>
</div>`, </div>`,
@@ -500,6 +551,7 @@
html: ` html: `
<ol class="ps-steps ps-stagger"> <ol class="ps-steps ps-stagger">
<li>Return to the map and pick a Service Tag.</li> <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>Open Reports and click a compliance segment.</li>
<li>Ask Copilot a question that must cite that ST.</li> <li>Ask Copilot a question that must cite that ST.</li>
<li>Use <strong>Customer story</strong> for business / ops audiences.</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 = [ const BUILTIN_DECKS = [
{ {
id: "story", id: "story",
name: "Customer story", name: "Customer story",
description: "Customer briefing — ask, architecture, apps, value", description: "Customer briefing — ask, Console wall, power, architecture, value",
builtin: true, builtin: true,
slides: structuredClone(STORY_SLIDES), slides: structuredClone(STORY_SLIDES),
}, },
+1 -1
View File
@@ -77,7 +77,7 @@
const drawer = $("#reports-drawer"); const drawer = $("#reports-drawer");
const scrim = $("#scrim"); const scrim = $("#scrim");
if (!drawer) return; 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); const el = $(id);
if (el) { if (el) {
el.classList.remove("open"); el.classList.remove("open");
+581 -1
View File
@@ -2100,7 +2100,7 @@ a.conn-link:hover {
background: rgba(0,20,40,0.35); background: rgba(0,20,40,0.35);
border-radius: 8px; border-radius: 8px;
overflow: auto; overflow: auto;
max-height: min(56vh, 520px); max-height: min(70vh, 680px);
} }
.kpi-list { padding: 0.35rem; } .kpi-list { padding: 0.35rem; }
.kpi-detail { padding: 0.75rem; } .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 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-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; } .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 { display: grid; gap: 0.3rem; margin-top: 0.5rem; }
.kpi-kv .row { .kpi-kv .row {
display: grid; display: grid;
@@ -8232,3 +8309,506 @@ html[data-theme="light"] .chip.active {
background: linear-gradient(135deg, #ef4444, #b91c1c) !important; background: linear-gradient(135deg, #ef4444, #b91c1c) !important;
color: #fff !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; }