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:
+516
-2
@@ -1,4 +1,5 @@
|
||||
|
||||
import urllib.parse
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
@@ -14,9 +15,9 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
import asyncssh
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -3133,6 +3134,514 @@ async def device_detail(device_id: int):
|
||||
return detail
|
||||
|
||||
|
||||
# OME JobService powerState values (Dell set_power_state.py)
|
||||
OME_POWER_ACTIONS = {
|
||||
"on": ("2", "Power On"),
|
||||
"cycle": ("5", "Power Cycle"),
|
||||
"force_off": ("8", "Power Off Non-Graceful"),
|
||||
"off": ("12", "Power Off Graceful"),
|
||||
}
|
||||
|
||||
|
||||
class PowerActionIn(BaseModel):
|
||||
action: str # on | off | cycle | force_off
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
def _fleet_device(device_id: int) -> dict | None:
|
||||
return next((d for d in (STATE.get("devices") or []) if d.get("id") == device_id), None)
|
||||
|
||||
|
||||
@app.post("/api/devices/{device_id}/power")
|
||||
async def device_power_control(device_id: int, payload: PowerActionIn):
|
||||
"""Power control via OME JobService DeviceAction_Task (iDRAC through OME)."""
|
||||
action = (payload.action or "").strip().lower()
|
||||
if action not in OME_POWER_ACTIONS:
|
||||
raise HTTPException(400, f"Invalid action. Use one of: {', '.join(OME_POWER_ACTIONS)}")
|
||||
if action in ("off", "force_off", "cycle") and not payload.confirm:
|
||||
raise HTTPException(400, "confirm=true required for off/cycle actions")
|
||||
|
||||
node = _fleet_device(device_id)
|
||||
if not node:
|
||||
raise HTTPException(404, "Device not found in current fleet snapshot")
|
||||
if node.get("source") == "inventory" or (isinstance(device_id, int) and device_id < 0):
|
||||
raise HTTPException(400, "Inventory-only hosts cannot be powered via OME")
|
||||
if not (node.get("is_server") or node.get("is_idrac") or node.get("type") == 1000):
|
||||
raise HTTPException(400, "Power control is only supported for servers / iDRAC endpoints")
|
||||
|
||||
power_state, label = OME_POWER_ACTIONS[action]
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
body = {
|
||||
"Id": 0,
|
||||
"JobName": f"Cockpit {label}",
|
||||
"JobDescription": f"OME Cockpit power control · {node.get('name')} · {node.get('service_tag')}",
|
||||
"State": "Enabled",
|
||||
"Schedule": "startnow",
|
||||
"JobType": {"Name": "DeviceAction_Task"},
|
||||
"Targets": [
|
||||
{
|
||||
"Id": int(device_id),
|
||||
"Data": "",
|
||||
"TargetType": {"Id": 1000, "Name": "DEVICE"},
|
||||
}
|
||||
],
|
||||
"Params": [
|
||||
{"Key": "override", "Value": "true"},
|
||||
{"Key": "powerState", "Value": str(power_state)},
|
||||
{"Key": "operationName", "Value": "POWER_CONTROL"},
|
||||
{"Key": "deviceTypes", "Value": "1000"},
|
||||
],
|
||||
}
|
||||
r = await client.post(
|
||||
f"{base}/api/JobService/Jobs",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(
|
||||
r.status_code,
|
||||
f"OME power job rejected: {(r.text or '')[:300]}",
|
||||
)
|
||||
job = r.json() if r.content else {}
|
||||
job_id = job.get("Id")
|
||||
log.info(
|
||||
"OME power %s for device %s (%s) → job %s",
|
||||
action,
|
||||
device_id,
|
||||
node.get("service_tag"),
|
||||
job_id,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"device_id": device_id,
|
||||
"name": node.get("name"),
|
||||
"service_tag": node.get("service_tag"),
|
||||
"job_id": job_id,
|
||||
"note": "OME accepted the job. Power state updates on the next fleet poll.",
|
||||
}
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
|
||||
|
||||
@app.get("/api/devices/{device_id}/idrac-console")
|
||||
async def device_idrac_console(device_id: int, request: Request):
|
||||
"""Return iDRAC web / HTML5 console URLs — prefer same-origin proxy embed."""
|
||||
node = _fleet_device(device_id)
|
||||
if not node:
|
||||
raise HTTPException(404, "Device not found in current fleet snapshot")
|
||||
ip = node.get("idrac_ip") or node.get("ip")
|
||||
if not ip:
|
||||
raise HTTPException(400, "No iDRAC / management IP for this device")
|
||||
base = f"https://{ip}"
|
||||
# Same-origin reverse proxy so X-Frame-Options / CSP cannot block the iframe
|
||||
embed = f"/api/idrac-proxy/{device_id}/restgui/start.html?console"
|
||||
urls = {
|
||||
"web": base + "/",
|
||||
"html5": base + "/console",
|
||||
"restgui": base + "/restgui/start.html?console",
|
||||
"viewer": base + "/virtualconsole",
|
||||
"embed": str(request.base_url).rstrip("/") + embed,
|
||||
"embed_path": embed,
|
||||
}
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"name": node.get("name"),
|
||||
"service_tag": node.get("service_tag"),
|
||||
"ip": ip,
|
||||
"powered_on": node.get("powered_on"),
|
||||
"connected": node.get("connected"),
|
||||
"urls": urls,
|
||||
"primary": embed,
|
||||
"note": (
|
||||
"Console is proxied through Cockpit (same-origin) so it can run in this panel. "
|
||||
"Log in with your iDRAC credentials. Popout still opens the iDRAC directly."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_IDRAC_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"x-frame-options",
|
||||
"x-xss-protection",
|
||||
"strict-transport-security",
|
||||
}
|
||||
|
||||
|
||||
def _idrac_proxy_prefix(device_id: int) -> str:
|
||||
return f"/api/idrac-proxy/{device_id}"
|
||||
|
||||
|
||||
def _idrac_resolve(device_id: int) -> tuple[dict, str]:
|
||||
node = _fleet_device(device_id)
|
||||
if not node:
|
||||
raise HTTPException(404, "Device not found in current fleet snapshot")
|
||||
|
||||
# Prefer explicit iDRAC management IP; never treat bare OS inventory as iDRAC
|
||||
ip = node.get("idrac_ip")
|
||||
if not ip and node.get("is_idrac"):
|
||||
ip = node.get("ip")
|
||||
if not ip:
|
||||
st = (node.get("service_tag") or "").strip()
|
||||
if st:
|
||||
sibling = next(
|
||||
(
|
||||
d
|
||||
for d in (STATE.get("devices") or [])
|
||||
if d.get("is_idrac")
|
||||
and (d.get("service_tag") or "").strip().upper() == st.upper()
|
||||
and (d.get("idrac_ip") or d.get("ip"))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if sibling:
|
||||
node = sibling
|
||||
ip = sibling.get("idrac_ip") or sibling.get("ip")
|
||||
if not ip:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"No iDRAC management IP for this device — select an iDRAC endpoint (OOB), not the OS host",
|
||||
)
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, "Invalid management IP") from e
|
||||
return node, ip
|
||||
|
||||
|
||||
def _idrac_rewrite_location(value: str, device_id: int, idrac_ip: str) -> str:
|
||||
prefix = _idrac_proxy_prefix(device_id)
|
||||
if not value:
|
||||
return value
|
||||
if value.startswith("/"):
|
||||
return prefix + value
|
||||
try:
|
||||
u = urllib.parse.urlparse(value)
|
||||
except Exception:
|
||||
return value
|
||||
host = (u.hostname or "").lower()
|
||||
if host == idrac_ip.lower() or host.endswith(".dell-atc.lan") or host.endswith(".internal."):
|
||||
path = u.path or "/"
|
||||
return prefix + path + (("?" + u.query) if u.query else "") + (("#" + u.fragment) if u.fragment else "")
|
||||
return value
|
||||
|
||||
|
||||
def _idrac_rewrite_cookie(value: str, device_id: int) -> str:
|
||||
"""Point cookies at the proxy path; drop Domain/Secure so HTTP cockpit can store them."""
|
||||
prefix = _idrac_proxy_prefix(device_id)
|
||||
parts = []
|
||||
for part in value.split(";"):
|
||||
p = part.strip()
|
||||
pl = p.lower()
|
||||
if pl.startswith("domain="):
|
||||
continue
|
||||
if pl == "secure":
|
||||
continue
|
||||
if pl.startswith("path="):
|
||||
parts.append(f"Path={prefix}/")
|
||||
continue
|
||||
parts.append(p)
|
||||
if not any(p.lower().startswith("path=") for p in parts):
|
||||
parts.append(f"Path={prefix}/")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _idrac_inject_bootstrap(html: bytes, device_id: int, idrac_ip: str) -> bytes:
|
||||
"""Rewrite absolute paths + patch fetch/XHR/WebSocket so the SPA stays on the proxy."""
|
||||
prefix = _idrac_proxy_prefix(device_id)
|
||||
try:
|
||||
text = html.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = html.decode("latin-1")
|
||||
|
||||
# Absolute root paths in markup
|
||||
for attr in ("href", "src", "action"):
|
||||
text = re.sub(
|
||||
rf'({attr}\s*=\s*["\'])/(?!/)',
|
||||
rf"\1{prefix}/",
|
||||
text,
|
||||
flags=re.I,
|
||||
)
|
||||
text = text.replace(f"https://{idrac_ip}/", f"{prefix}/")
|
||||
text = text.replace(f"http://{idrac_ip}/", f"{prefix}/")
|
||||
|
||||
boot = f"""<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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
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")
|
||||
async def device_expansion(device_id: int, force: bool = False):
|
||||
if force and device_id in DETAIL_CACHE:
|
||||
@@ -5949,6 +6458,11 @@ async def network_js():
|
||||
return FileResponse(STATIC_DIR / "network.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/console.js")
|
||||
async def console_js():
|
||||
return FileResponse(STATIC_DIR / "console.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/ssh.js")
|
||||
async def ssh_js():
|
||||
return FileResponse(STATIC_DIR / "ssh.js", media_type="application/javascript")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
websockets==14.1
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.0
|
||||
asyncssh==2.18.0
|
||||
|
||||
@@ -1194,12 +1194,18 @@
|
||||
</div>
|
||||
<div class="kpi-actions">
|
||||
<button type="button" class="btn primary" data-kpi-act="focus">Focus on map</button>
|
||||
<div class="power-actions">
|
||||
<button type="button" class="btn power-on" data-kpi-act="power-on" title="Power on via OME → iDRAC">⏻ Power on</button>
|
||||
<button type="button" class="btn power-off" data-kpi-act="power-off" title="Graceful shutdown via OME → iDRAC">Power off</button>
|
||||
<button type="button" class="btn ghost" data-kpi-act="power-cycle" title="Power cycle via OME → iDRAC">Cycle</button>
|
||||
</div>
|
||||
<button type="button" class="btn idrac-console" data-kpi-act="idrac-console" ${node.ip || node.idrac_ip ? "" : "disabled"} title="Open iDRAC HTML5 console in Cockpit">▣ iDRAC console</button>
|
||||
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web ↗</a>` : ""}
|
||||
<button type="button" class="btn conn-link" data-kpi-act="ssh" ${node.ip ? "" : "disabled"}>SSH terminal</button>
|
||||
<button type="button" class="btn conn-link" data-kpi-act="rdp" ${node.ip ? "" : "disabled"}>RDP popout</button>
|
||||
<button type="button" class="btn" data-kpi-act="connect">Quick Connect</button>
|
||||
<button type="button" class="btn" data-kpi-act="inventory">Inspector + inventory</button>
|
||||
<button type="button" class="btn" data-kpi-act="chat">Ask Cockpit chat</button>
|
||||
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
|
||||
</div>
|
||||
<div class="inv-section">
|
||||
<h3>Related alerts</h3>
|
||||
@@ -1845,6 +1851,105 @@
|
||||
showToast._t = setTimeout(() => el.classList.remove("show"), ms);
|
||||
}
|
||||
|
||||
async function requestDevicePower(node, action) {
|
||||
if (!node?.id) return;
|
||||
const labels = {
|
||||
on: "Power ON",
|
||||
off: "graceful Power OFF",
|
||||
cycle: "Power CYCLE",
|
||||
force_off: "FORCE Power OFF",
|
||||
};
|
||||
const label = labels[action] || action;
|
||||
if (action !== "on") {
|
||||
const ok = window.confirm(
|
||||
`${label} for ${node.name || "device"} (${node.service_tag || node.id}) via OME → iDRAC?\n\nThis runs a remote power job.`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
showToast(`Sending ${label}…`);
|
||||
const res = await fetch(`/api/devices/${node.id}/power`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, confirm: action !== "on" }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = data.detail;
|
||||
const msg = Array.isArray(detail)
|
||||
? detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
|
||||
: detail || data.message || res.statusText;
|
||||
throw new Error(msg);
|
||||
}
|
||||
showToast(`${label} accepted · OME job #${data.job_id ?? "—"}`, { ms: 5000 });
|
||||
// Optimistic badge: on → show powered, off → not powered
|
||||
if (action === "on") node.powered_on = true;
|
||||
if (action === "off" || action === "force_off") node.powered_on = false;
|
||||
if (kpiPopup.key) renderKpiPopup();
|
||||
} catch (e) {
|
||||
showToast(`Power failed: ${e.message || e}`, { alert: true, ms: 8000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function openIdracConsole(node) {
|
||||
if (!node?.id) return;
|
||||
const modal = $("#idrac-console-modal");
|
||||
const frame = $("#idrac-console-frame");
|
||||
const title = $("#idrac-console-title");
|
||||
const sub = $("#idrac-console-sub");
|
||||
const note = $("#idrac-console-note");
|
||||
const scrim = $("#scrim");
|
||||
if (!modal || !frame) {
|
||||
const url = node.idrac_url || (node.ip ? `https://${node.ip}` : null);
|
||||
if (url) window.open(url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
title.textContent = node.name || "iDRAC console";
|
||||
sub.textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · ${node.idrac_ip || node.ip || ""}`;
|
||||
note.textContent = "Loading console URLs…";
|
||||
frame.removeAttribute("src");
|
||||
modal.dataset.deviceId = String(node.id);
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
scrim?.classList.add("open");
|
||||
if (scrim) scrim.dataset.mode = "idrac-console";
|
||||
try {
|
||||
const res = await fetch(`/api/devices/${node.id}/idrac-console`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||
// Prefer same-origin proxy embed (strips X-Frame-Options)
|
||||
const primary = data.primary || data.urls?.embed_path || data.urls?.html5 || data.urls?.web;
|
||||
modal.dataset.primary = primary || "";
|
||||
modal.dataset.web = data.urls?.web || "";
|
||||
modal.dataset.popout = data.urls?.web || data.urls?.html5 || primary || "";
|
||||
note.textContent = data.note || "Log in with your iDRAC credentials inside the panel.";
|
||||
if (primary) {
|
||||
frame.src = primary;
|
||||
} else {
|
||||
note.textContent = "No console URL available.";
|
||||
}
|
||||
} catch (e) {
|
||||
note.textContent = `Console lookup failed: ${e.message || e}`;
|
||||
const fallback = `/api/idrac-proxy/${node.id}/restgui/start.html?console`;
|
||||
modal.dataset.primary = fallback;
|
||||
modal.dataset.popout = node.idrac_url || (node.ip ? `https://${node.ip}/` : fallback);
|
||||
frame.src = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function closeIdracConsole() {
|
||||
const modal = $("#idrac-console-modal");
|
||||
const frame = $("#idrac-console-frame");
|
||||
const scrim = $("#scrim");
|
||||
if (frame) frame.removeAttribute("src");
|
||||
modal?.classList.add("hidden");
|
||||
modal?.setAttribute("aria-hidden", "true");
|
||||
if (scrim?.dataset.mode === "idrac-console") {
|
||||
scrim.classList.remove("open");
|
||||
delete scrim.dataset.mode;
|
||||
}
|
||||
}
|
||||
|
||||
function pushNotifyCard(ev) {
|
||||
const host = $("#notify-stack");
|
||||
if (!host) return;
|
||||
@@ -1952,11 +2057,26 @@
|
||||
const ip = node.ip;
|
||||
const idrac = node.idrac_url || (ip ? `https://${ip}` : null);
|
||||
$("#connect-grid").innerHTML = `
|
||||
${idrac ? `<a class="connect-tile primary conn-link" href="${escapeAttr(idrac)}" target="_blank" rel="noopener">
|
||||
<button type="button" class="connect-tile primary" id="btn-modal-power-on">
|
||||
<span class="ct-ico">⏻</span>
|
||||
<span class="ct-t">Power on</span>
|
||||
<span class="ct-s">OME → iDRAC · boot server</span>
|
||||
</button>
|
||||
<button type="button" class="connect-tile danger" id="btn-modal-power-off">
|
||||
<span class="ct-ico">⏻</span>
|
||||
<span class="ct-t">Power off</span>
|
||||
<span class="ct-s">Graceful shutdown via OME</span>
|
||||
</button>
|
||||
${idrac ? `<button type="button" class="connect-tile idrac-console" id="btn-modal-idrac-console">
|
||||
<span class="ct-ico">▣</span>
|
||||
<span class="ct-t">iDRAC console</span>
|
||||
<span class="ct-s">Embed in Cockpit · ${escapeHtml(ip)}</span>
|
||||
</button>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC console</span><span class="ct-s">No management IP</span></div>`}
|
||||
${idrac ? `<a class="connect-tile conn-link" href="${escapeAttr(idrac)}" target="_blank" rel="noopener">
|
||||
<span class="ct-ico">↗</span>
|
||||
<span class="ct-t">iDRAC Web</span>
|
||||
<span class="ct-s">BMC console · ${escapeHtml(ip)}</span>
|
||||
</a>` : `<div class="connect-tile" style="opacity:.45"><span class="ct-t">iDRAC Web</span><span class="ct-s">No management IP</span></div>`}
|
||||
<span class="ct-s">Open in new tab · ${escapeHtml(ip)}</span>
|
||||
</a>` : ""}
|
||||
${ip ? `<button type="button" class="connect-tile conn-link" id="btn-modal-ssh">
|
||||
<span class="ct-ico">〉_</span>
|
||||
<span class="ct-t">SSH terminal</span>
|
||||
@@ -1995,6 +2115,19 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-power-on")) {
|
||||
await requestDevicePower(node, "on");
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-power-off")) {
|
||||
await requestDevicePower(node, "off");
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-idrac-console")) {
|
||||
closeConnect();
|
||||
openIdracConsole(node);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest("#btn-modal-ssh")) {
|
||||
closeConnect();
|
||||
window.cockpitSsh?.open(node);
|
||||
@@ -2227,7 +2360,13 @@
|
||||
<div id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty & Dell compliance…</p></div>
|
||||
<div class="action-stack">
|
||||
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
|
||||
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
|
||||
<div class="power-actions">
|
||||
<button type="button" class="btn power-on" id="btn-power-on">⏻ Power on</button>
|
||||
<button type="button" class="btn power-off" id="btn-power-off">Power off</button>
|
||||
<button type="button" class="btn ghost" id="btn-power-cycle">Cycle</button>
|
||||
</div>
|
||||
${node.ip || node.idrac_ip ? `<button type="button" class="btn idrac-console" id="btn-idrac-console">▣ iDRAC console</button>` : ""}
|
||||
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web ↗</a>` : ""}
|
||||
${node.ip ? `<button type="button" class="btn conn-link" id="btn-ssh-term">SSH terminal · ${escapeHtml(node.ip)}</button>` : ""}
|
||||
${node.ip ? `<button type="button" class="btn conn-link" id="btn-rdp-term">RDP popout · ${escapeHtml(node.rdp_host || node.os_hostname || node.ip)}</button>` : ""}
|
||||
<button type="button" class="btn" id="btn-detail">Load full inventory + app landscape</button>
|
||||
@@ -2263,6 +2402,10 @@
|
||||
});
|
||||
updateFocusContext();
|
||||
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
|
||||
$("#btn-power-on")?.addEventListener("click", () => requestDevicePower(node, "on"));
|
||||
$("#btn-power-off")?.addEventListener("click", () => requestDevicePower(node, "off"));
|
||||
$("#btn-power-cycle")?.addEventListener("click", () => requestDevicePower(node, "cycle"));
|
||||
$("#btn-idrac-console")?.addEventListener("click", () => openIdracConsole(node));
|
||||
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
|
||||
$("#btn-rdp-term")?.addEventListener("click", () => window.cockpitRdp?.open(node));
|
||||
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
|
||||
@@ -2617,6 +2760,14 @@
|
||||
closeKpiPopup();
|
||||
focusDeviceId(node.id);
|
||||
showToast("Focused " + (node.name || ""));
|
||||
} else if (a === "power-on") {
|
||||
requestDevicePower(node, "on");
|
||||
} else if (a === "power-off") {
|
||||
requestDevicePower(node, "off");
|
||||
} else if (a === "power-cycle") {
|
||||
requestDevicePower(node, "cycle");
|
||||
} else if (a === "idrac-console") {
|
||||
openIdracConsole(node);
|
||||
} else if (a === "ssh") {
|
||||
window.cockpitSsh?.open(node);
|
||||
} else if (a === "rdp") {
|
||||
@@ -2857,6 +3008,9 @@
|
||||
openConnect,
|
||||
showInspector,
|
||||
openAi,
|
||||
requestDevicePower,
|
||||
openIdracConsole,
|
||||
closeIdracConsole,
|
||||
relTime,
|
||||
escapeHtml,
|
||||
openTriage: null,
|
||||
@@ -2924,20 +3078,44 @@
|
||||
$("#btn-ai")?.addEventListener("click", () => openAi(null));
|
||||
$("#btn-ai-close").addEventListener("click", closeAi);
|
||||
$("#btn-connect-close")?.addEventListener("click", closeConnect);
|
||||
$("#btn-idrac-console-close")?.addEventListener("click", closeIdracConsole);
|
||||
$("#btn-idrac-popout")?.addEventListener("click", () => {
|
||||
const modal = $("#idrac-console-modal");
|
||||
const url = modal?.dataset.popout || modal?.dataset.web || modal?.dataset.primary;
|
||||
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
$("#btn-idrac-web")?.addEventListener("click", () => {
|
||||
const modal = $("#idrac-console-modal");
|
||||
const url = modal?.dataset.web || modal?.dataset.popout || modal?.dataset.primary;
|
||||
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
$("#btn-idrac-reload")?.addEventListener("click", () => {
|
||||
const frame = $("#idrac-console-frame");
|
||||
const modal = $("#idrac-console-modal");
|
||||
const url = modal?.dataset.primary;
|
||||
if (frame && url) {
|
||||
frame.src = "about:blank";
|
||||
setTimeout(() => {
|
||||
frame.src = url;
|
||||
}, 30);
|
||||
}
|
||||
});
|
||||
$("#scrim").addEventListener("click", () => {
|
||||
const mode = $("#scrim")?.dataset.mode;
|
||||
if (mode === "triage") document.getElementById("btn-triage-close")?.click();
|
||||
else if (mode === "kpi") closeKpiPopup();
|
||||
else if (mode === "connect") closeConnect();
|
||||
else if (mode === "idrac-console") closeIdracConsole();
|
||||
else if (
|
||||
mode === "chat-drawer" ||
|
||||
mode === "ops-drawer" ||
|
||||
mode === "ai-drawer" ||
|
||||
mode === "reports-drawer" ||
|
||||
mode === "an-context" ||
|
||||
mode === "network-drawer"
|
||||
mode === "network-drawer" ||
|
||||
mode === "console-drawer"
|
||||
) {
|
||||
/* reports.js / ops.js / network.js also listen */
|
||||
/* reports.js / ops.js / network.js / console.js also listen */
|
||||
} else closeAi();
|
||||
});
|
||||
$("#btn-ome-console").addEventListener("click", () => {
|
||||
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* Console workspace — live iDRAC wall (4 or 8 draggable tiles).
|
||||
* Only real iDRAC management endpoints (not OS host IPs).
|
||||
*/
|
||||
(() => {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||||
|
||||
const STORE_KEY = "cockpit_console_slots_v2";
|
||||
const DENSITY_KEY = "cockpit_console_density_v1";
|
||||
const SLOTS_KEY = "cockpit_console_slotcount_v1";
|
||||
|
||||
const state = {
|
||||
slotCount: Number(localStorage.getItem(SLOTS_KEY) || 4) === 8 ? 8 : 4,
|
||||
slots: [],
|
||||
density: localStorage.getItem(DENSITY_KEY) || "medium",
|
||||
filter: "",
|
||||
subnet: "all",
|
||||
fsDeviceId: null,
|
||||
dragDeviceId: null,
|
||||
dragFromSlot: null,
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fleetDevices() {
|
||||
return window.cockpit?.getState?.()?.data?.devices || [];
|
||||
}
|
||||
|
||||
function fleetSubnets() {
|
||||
return window.cockpit?.getState?.()?.data?.subnets || [];
|
||||
}
|
||||
|
||||
/** Real iDRAC / BMC endpoints only — never bare OS inventory hosts. */
|
||||
function isIdracHost(n) {
|
||||
if (!n || n.id == null) return false;
|
||||
if (n.is_idrac) return !!(n.idrac_ip || n.ip);
|
||||
if (n.idrac_ip) return true;
|
||||
if (n.idrac_url && /idrac/i.test(String(n.name || "") + String(n.idrac_url))) return true;
|
||||
const name = String(n.name || "").toLowerCase();
|
||||
if (name.includes("idrac") && (n.ip || n.idrac_ip)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function mgmtIp(n) {
|
||||
return n?.idrac_ip || (n?.is_idrac ? n.ip : null) || null;
|
||||
}
|
||||
|
||||
function idracPool() {
|
||||
return fleetDevices()
|
||||
.filter(isIdracHost)
|
||||
.filter((n) => mgmtIp(n))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const sa = String(a.subnet || "");
|
||||
const sb = String(b.subnet || "");
|
||||
if (sa !== sb) return sa.localeCompare(sb);
|
||||
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||
});
|
||||
}
|
||||
|
||||
function deviceById(id) {
|
||||
if (id == null) return null;
|
||||
return fleetDevices().find((d) => String(d.id) === String(id)) || null;
|
||||
}
|
||||
|
||||
function subnetMeta(cidr) {
|
||||
const meta = fleetSubnets().find((s) => s.cidr === cidr) || {};
|
||||
const colors = window.cockpit?.getState?.()?.subnetColors;
|
||||
let color = meta.vlan_color;
|
||||
if (!color && colors?.get) color = colors.get(cidr || "?");
|
||||
if (!color) {
|
||||
let h = 0;
|
||||
for (const c of String(cidr || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
color = `hsl(${h % 360} 72% 52%)`;
|
||||
}
|
||||
return {
|
||||
cidr: cidr || "—",
|
||||
vlan: meta.vlan_id,
|
||||
name: meta.name || meta.label || "",
|
||||
color,
|
||||
};
|
||||
}
|
||||
|
||||
function embedUrl(deviceId) {
|
||||
return `/api/idrac-proxy/${deviceId}/restgui/start.html?console`;
|
||||
}
|
||||
|
||||
function resizeSlots(count) {
|
||||
const next = Number(count) === 8 ? 8 : 4;
|
||||
const prev = state.slots.slice();
|
||||
state.slotCount = next;
|
||||
state.slots = Array.from({ length: next }, (_, i) => prev[i] ?? null);
|
||||
localStorage.setItem(SLOTS_KEY, String(next));
|
||||
saveSlots();
|
||||
}
|
||||
|
||||
function loadSlots() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(STORE_KEY) || "[]");
|
||||
if (Array.isArray(raw) && raw.length) {
|
||||
state.slots = Array.from({ length: state.slotCount }, (_, i) => {
|
||||
const id = raw[i] ?? null;
|
||||
const n = deviceById(id);
|
||||
return n && isIdracHost(n) ? id : null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
state.slots = Array(state.slotCount).fill(null);
|
||||
}
|
||||
|
||||
function saveSlots() {
|
||||
try {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(state.slots));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function closeOtherDrawers() {
|
||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#network-drawer"].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (el) {
|
||||
el.classList.remove("open");
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
});
|
||||
window.cockpitNetwork?.close?.();
|
||||
}
|
||||
|
||||
function openConsole() {
|
||||
const drawer = $("#console-drawer");
|
||||
const scrim = $("#scrim");
|
||||
if (!drawer) return;
|
||||
closeOtherDrawers();
|
||||
loadSlots();
|
||||
drawer.classList.add("open");
|
||||
drawer.setAttribute("aria-hidden", "false");
|
||||
if (scrim) {
|
||||
scrim.classList.add("open");
|
||||
scrim.dataset.mode = "console-drawer";
|
||||
}
|
||||
renderAll();
|
||||
requestAnimationFrame(() => drawer.classList.add("console-ready"));
|
||||
}
|
||||
|
||||
function closeConsole() {
|
||||
closeFullscreen();
|
||||
const drawer = $("#console-drawer");
|
||||
const scrim = $("#scrim");
|
||||
drawer?.classList.remove("open", "console-ready", "is-dragging");
|
||||
drawer?.setAttribute("aria-hidden", "true");
|
||||
if (scrim?.dataset.mode === "console-drawer") {
|
||||
scrim.classList.remove("open");
|
||||
delete scrim.dataset.mode;
|
||||
}
|
||||
}
|
||||
|
||||
function setDensity(mode) {
|
||||
state.density = mode === "small" ? "small" : "medium";
|
||||
localStorage.setItem(DENSITY_KEY, state.density);
|
||||
applyStageClasses();
|
||||
$$("[data-console-density]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.consoleDensity === state.density);
|
||||
});
|
||||
}
|
||||
|
||||
function setSlotCount(count) {
|
||||
resizeSlots(count);
|
||||
applyStageClasses();
|
||||
$$("[data-console-slots]").forEach((b) => {
|
||||
b.classList.toggle("active", Number(b.dataset.consoleSlots) === state.slotCount);
|
||||
});
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function applyStageClasses() {
|
||||
const stage = $("#console-stage");
|
||||
if (!stage) return;
|
||||
stage.classList.toggle("density-small", state.density === "small");
|
||||
stage.classList.toggle("density-medium", state.density === "medium");
|
||||
stage.classList.toggle("slots-4", state.slotCount === 4);
|
||||
stage.classList.toggle("slots-8", state.slotCount === 8);
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
if (typeof window.showToast === "function") window.showToast(msg);
|
||||
else if (window.cockpit?.showToast) window.cockpit.showToast(msg);
|
||||
}
|
||||
|
||||
function assignSlot(index, deviceId) {
|
||||
if (index < 0 || index >= state.slotCount) return;
|
||||
if (deviceId != null) {
|
||||
const n = deviceById(deviceId);
|
||||
if (!n || !isIdracHost(n)) {
|
||||
toast("Kies een iDRAC-endpoint (geen OS-host)");
|
||||
return;
|
||||
}
|
||||
state.slots = state.slots.map((id, i) => (i !== index && String(id) === String(deviceId) ? null : id));
|
||||
}
|
||||
state.slots[index] = deviceId;
|
||||
saveSlots();
|
||||
renderStage();
|
||||
renderFleet();
|
||||
renderStats();
|
||||
}
|
||||
|
||||
function clearSlot(index) {
|
||||
assignSlot(index, null);
|
||||
}
|
||||
|
||||
function swapSlots(a, b) {
|
||||
if (a === b || a < 0 || b < 0 || a >= state.slotCount || b >= state.slotCount) return;
|
||||
const tmp = state.slots[a];
|
||||
state.slots[a] = state.slots[b];
|
||||
state.slots[b] = tmp;
|
||||
saveSlots();
|
||||
renderStage();
|
||||
}
|
||||
|
||||
function openFullscreen(deviceId) {
|
||||
const node = deviceById(deviceId);
|
||||
if (!node) return;
|
||||
state.fsDeviceId = deviceId;
|
||||
const modal = $("#console-fs-modal");
|
||||
const frame = $("#console-fs-frame");
|
||||
const title = $("#console-fs-title");
|
||||
const sub = $("#console-fs-sub");
|
||||
const net = $("#console-fs-net");
|
||||
if (!modal || !frame) return;
|
||||
const sm = subnetMeta(node.subnet);
|
||||
title.textContent = node.name || "iDRAC";
|
||||
sub.textContent = `${node.model || "—"} · ${node.service_tag || "no tag"} · ${mgmtIp(node) || ""}`;
|
||||
net.innerHTML = netBadgeHtml(sm, node);
|
||||
$$(`.console-tile[data-device-id="${deviceId}"] iframe`).forEach((f) => {
|
||||
f.dataset.pausedSrc = f.src;
|
||||
f.removeAttribute("src");
|
||||
});
|
||||
frame.src = embedUrl(deviceId);
|
||||
modal.classList.remove("hidden");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
|
||||
function closeFullscreen() {
|
||||
const modal = $("#console-fs-modal");
|
||||
const frame = $("#console-fs-frame");
|
||||
const id = state.fsDeviceId;
|
||||
if (frame) frame.removeAttribute("src");
|
||||
modal?.classList.add("hidden");
|
||||
modal?.setAttribute("aria-hidden", "true");
|
||||
if (id != null) {
|
||||
$$(`.console-tile[data-device-id="${id}"] iframe`).forEach((f) => {
|
||||
if (f.dataset.pausedSrc) {
|
||||
f.src = f.dataset.pausedSrc;
|
||||
delete f.dataset.pausedSrc;
|
||||
} else {
|
||||
f.src = embedUrl(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
state.fsDeviceId = null;
|
||||
}
|
||||
|
||||
function netBadgeHtml(sm, node) {
|
||||
const vlan = sm.vlan != null ? `VLAN ${esc(sm.vlan)}` : "";
|
||||
const name = sm.name ? esc(sm.name) : "";
|
||||
const bits = [vlan, name, esc(sm.cidr)].filter(Boolean);
|
||||
return `<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
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/styles.css?v=radar1" />
|
||||
<link rel="stylesheet" href="/styles.css?v=console2" />
|
||||
<link rel="icon" href="/dell.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
|
||||
<script>
|
||||
@@ -39,6 +39,7 @@
|
||||
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
|
||||
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
|
||||
<button type="button" class="btn ghost" id="btn-network" title="Fabric map & 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 & Dell compliance">Reports</button>
|
||||
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
|
||||
<button type="button" class="btn present-btn" id="btn-present" title="Customer presentation · mid or fullscreen slides">Present</button>
|
||||
@@ -419,6 +420,63 @@
|
||||
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> & <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
|
||||
</div>
|
||||
|
||||
<!-- Live iDRAC Console wall -->
|
||||
<div class="drawer wide console-drawer" id="console-drawer" aria-hidden="true">
|
||||
<div class="console-aurora" aria-hidden="true"></div>
|
||||
<div class="drawer-head console-head">
|
||||
<div class="drawer-brand">
|
||||
<img src="/dell.png" alt="Dell" height="22" />
|
||||
<div>
|
||||
<span class="console-title">Console · iDRAC wall</span>
|
||||
<p class="console-sub">4 live embeds · drag from fleet · network-aware</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-head-actions">
|
||||
<div id="console-stats" class="console-stats"></div>
|
||||
<div class="console-density" role="group" aria-label="Wall size">
|
||||
<button type="button" class="chip active" data-console-slots="4" title="4 consoles">4 screens</button>
|
||||
<button type="button" class="chip" data-console-slots="8" title="8 consoles">8 screens</button>
|
||||
</div>
|
||||
<div class="console-density" role="group" aria-label="Tile density">
|
||||
<button type="button" class="chip" data-console-density="small" title="Compact tiles">Small</button>
|
||||
<button type="button" class="chip active" data-console-density="medium" title="Larger tiles">Medium</button>
|
||||
</div>
|
||||
<button type="button" class="btn primary" id="btn-console-autofill" title="Fill wall with live iDRACs">Auto-fill live</button>
|
||||
<button type="button" class="btn ghost" id="btn-console-clear">Clear wall</button>
|
||||
<button type="button" class="btn ghost" id="btn-console-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-body">
|
||||
<aside class="console-fleet">
|
||||
<div class="console-fleet-tools">
|
||||
<input type="search" id="console-search" placeholder="Search name, IP, tag, subnet…" autocomplete="off" />
|
||||
<div class="console-subnet-chips" id="console-subnet-chips"></div>
|
||||
<p class="hint">Only real iDRAC/BMC IPs · drag onto a slot or click <strong>+ Wall</strong> · Fullscreen = popup</p>
|
||||
</div>
|
||||
<div class="console-fleet-list" id="console-fleet-list"></div>
|
||||
</aside>
|
||||
<section class="console-stage density-medium" id="console-stage" aria-label="iDRAC console wall"></section>
|
||||
</div>
|
||||
<footer class="drawer-credit">OME Cockpit · Data Forward Deployed Engineers <strong>Mohamed El Kadi</strong> & <strong>Bart Sjerps</strong> · Not an official Dell Technologies product</footer>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden console-fs-modal" id="console-fs-modal" aria-hidden="true" role="dialog" aria-labelledby="console-fs-title">
|
||||
<div class="modal-card console-fs-card">
|
||||
<button type="button" class="modal-close" id="btn-console-fs-close" aria-label="Close">×</button>
|
||||
<div class="console-fs-head">
|
||||
<div>
|
||||
<p class="modal-kicker">Fullscreen iDRAC</p>
|
||||
<h2 id="console-fs-title">Console</h2>
|
||||
<p class="modal-sub" id="console-fs-sub">—</p>
|
||||
<div id="console-fs-net"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="console-fs-frame-wrap">
|
||||
<iframe id="console-fs-frame" title="iDRAC fullscreen" allow="fullscreen; clipboard-read; clipboard-write"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports / compliance / export -->
|
||||
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
|
||||
<div class="drawer-head">
|
||||
@@ -592,6 +650,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="idrac-console-modal" aria-hidden="true" role="dialog" aria-labelledby="idrac-console-title">
|
||||
<div class="modal-card idrac-console-card">
|
||||
<button type="button" class="modal-close" id="btn-idrac-console-close" aria-label="Close">×</button>
|
||||
<div class="modal-brand">
|
||||
<img src="/dell.png" alt="Dell" width="36" height="36" />
|
||||
<div>
|
||||
<p class="modal-kicker">iDRAC console</p>
|
||||
<h2 id="idrac-console-title">Console</h2>
|
||||
<p class="modal-sub" id="idrac-console-sub">—</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="idrac-console-toolbar">
|
||||
<button type="button" class="btn primary" id="btn-idrac-popout">Open popout</button>
|
||||
<button type="button" class="btn ghost" id="btn-idrac-web">iDRAC Web</button>
|
||||
<button type="button" class="btn ghost" id="btn-idrac-reload">Reload embed</button>
|
||||
</div>
|
||||
<p class="hint" id="idrac-console-note"></p>
|
||||
<div class="idrac-console-frame-wrap">
|
||||
<iframe id="idrac-console-frame" title="iDRAC console" allow="fullscreen; clipboard-read; clipboard-write" referrerpolicy="no-referrer-when-downgrade"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
|
||||
|
||||
@@ -634,12 +714,13 @@
|
||||
|
||||
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
|
||||
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
|
||||
<script src="/app.js?v=radar1"></script>
|
||||
<script src="/app.js?v=power4"></script>
|
||||
<script src="/ops.js?v=opsfde2"></script>
|
||||
<script src="/ssh.js?v=fabric4"></script>
|
||||
<script src="/rdp.js?v=fabric4"></script>
|
||||
<script src="/reports.js?v=anctx1"></script>
|
||||
<script src="/network.js?v=vlanall1"></script>
|
||||
<script src="/present.js?v=present16"></script>
|
||||
<script src="/console.js?v=console2"></script>
|
||||
<script src="/present.js?v=present17"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@
|
||||
const drawer = $("#network-drawer");
|
||||
const scrim = $("#scrim");
|
||||
if (!drawer) return;
|
||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => {
|
||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer", "#console-drawer"].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (el) {
|
||||
el.classList.remove("open");
|
||||
|
||||
+68
-16
@@ -32,7 +32,8 @@
|
||||
<ol class="ps-steps ps-stagger">
|
||||
<li><strong>Why</strong> — the gap around OME and why AI alone is not enough.</li>
|
||||
<li><strong>Architecture</strong> — one design: Browser → Cockpit BFF → OME / AI (with logos).</li>
|
||||
<li><strong>Apps</strong> — map, network, reports, ops desk, copilots.</li>
|
||||
<li><strong>Apps</strong> — map, network, reports, <em>Console wall</em>, ops desk, copilots.</li>
|
||||
<li><strong>Remote ops</strong> — power via OME→iDRAC and live consoles in-panel.</li>
|
||||
<li><strong>People</strong> — who asked, who built, who runs it.</li>
|
||||
<li><strong>Value</strong> — what customers can take away.</li>
|
||||
</ol>
|
||||
@@ -60,10 +61,10 @@
|
||||
html: `
|
||||
<div class="ps-grid3">
|
||||
<div class="ps-card"><h4>One Service Tag story</h4><p>Map, inventory, compliance, warranty, fabric, and tickets around the same ST.</p></div>
|
||||
<div class="ps-card"><h4>Remote ops in UI</h4><p>Power via OME jobs · live iDRAC Console wall (4/8) without tab sprawl.</p></div>
|
||||
<div class="ps-card"><h4>AI that cites the fleet</h4><p>Copilot answers from live OME facts — or says it does not know.</p></div>
|
||||
<div class="ps-card"><h4>Faster briefings</h4><p>Click a chart → named systems → hand off in Ops desk.</p></div>
|
||||
<div class="ps-card"><h4>Admin ↔ Data FDE</h4><p>Clear roles: ATC admins own the estate; Data FDEs deliver the AI/ops surface.</p></div>
|
||||
<div class="ps-card"><h4>Demo = daily tool</h4><p>Same stack you present is the stack you can keep using after the meeting.</p></div>
|
||||
<div class="ps-card"><h4>Copyable pattern</h4><p>OME API + BFF + grounded AI — a blueprint, not a Dell SKU.</p></div>
|
||||
</div>`,
|
||||
},
|
||||
@@ -113,7 +114,7 @@
|
||||
</div>
|
||||
<div class="ps-arch-layer apps">
|
||||
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
|
||||
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
|
||||
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
|
||||
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
|
||||
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
|
||||
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
|
||||
@@ -159,13 +160,45 @@
|
||||
html: `
|
||||
<div class="ps-apps">
|
||||
<div class="ps-app"><i></i><strong>Live map</strong><span>Fleet topology · power · inspector</span></div>
|
||||
<div class="ps-app"><i></i><strong>Console wall</strong><span>4 / 8 live iDRAC embeds · drag</span></div>
|
||||
<div class="ps-app"><i></i><strong>Network</strong><span>Fabric · 42U racks · VLANs</span></div>
|
||||
<div class="ps-app"><i></i><strong>Reports</strong><span>Compliance · warranty analytics</span></div>
|
||||
<div class="ps-app"><i></i><strong>Ops desk</strong><span>Admin ↔ Data FDE tickets</span></div>
|
||||
<div class="ps-app"><i></i><strong>Copilot</strong><span>Grounded chat on Service Tags</span></div>
|
||||
<div class="ps-app"><i></i><strong>OpenManage AI</strong><span>Full Open WebUI beside the cockpit</span></div>
|
||||
</div>
|
||||
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. No second inventory source.</p>`,
|
||||
<p class="ps-sub">One join key everywhere: <strong>Service Tag</strong>. Power and console actions ride OME → iDRAC — not a second inventory.</p>`,
|
||||
},
|
||||
{
|
||||
id: "console-wall",
|
||||
kicker: "Remote ops",
|
||||
title: "Console wall · many iDRACs, one screen",
|
||||
anim: "zoom",
|
||||
html: `
|
||||
<p class="ps-lead">Operators asked to <strong>see</strong> systems without tab-hopping. The Console tab is a live iDRAC wall: network-aware, draggable, and embedded through Cockpit so browsers are not blocked by <code>X-Frame-Options</code>.</p>
|
||||
<div class="ps-grid3">
|
||||
<div class="ps-card"><h4>4 or 8 screens</h4><p>Pick wall size · Small / Medium density · Fullscreen popup per tile.</p></div>
|
||||
<div class="ps-card"><h4>Drag & + Wall</h4><p>Fleet rail grouped by VLAN · drop onto slots · Auto-fill live BMCs.</p></div>
|
||||
<div class="ps-card"><h4>Network badges</h4><p>VLAN · subnet · LIVE/OFF on every tile — know where you are looking.</p></div>
|
||||
<div class="ps-card"><h4>Same-origin proxy</h4><p><code>/api/idrac-proxy/{id}/…</code> strips framing headers · WebSocket bridge for HTML5 console.</p></div>
|
||||
<div class="ps-card"><h4>Real iDRACs only</h4><p>OOB / BMC endpoints — never bare OS host IPs that cannot speak iDRAC.</p></div>
|
||||
<div class="ps-card"><h4>Still OME truth</h4><p>Device list and connectivity come from the live fleet snapshot.</p></div>
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
id: "power-idrac",
|
||||
kicker: "Remote ops",
|
||||
title: "Power on / off · without leaving the map",
|
||||
anim: "rise",
|
||||
html: `
|
||||
<p class="ps-lead">Offline servers with POWER N/A are common in the lab. From Servers KPI, inspector, or Quick Connect you can submit an OME <strong>POWER_CONTROL</strong> job — on, graceful off, or cycle — with confirm for destructive actions.</p>
|
||||
<div class="ps-seq">
|
||||
<div class="ps-seq-step"><span>01</span><strong>Select</strong><p>Server / iDRAC on map or KPI list</p></div>
|
||||
<div class="ps-seq-step"><span>02</span><strong>Power</strong><p>⏻ On · Off · Cycle via OME JobService</p></div>
|
||||
<div class="ps-seq-step"><span>03</span><strong>Console</strong><p>iDRAC HTML5 in-panel (proxied)</p></div>
|
||||
<div class="ps-seq-step"><span>04</span><strong>Verify</strong><p>Next fleet poll updates powered state</p></div>
|
||||
<div class="ps-seq-step"><span>05</span><strong>Escalate</strong><p>Ops desk ticket if change needs a trail</p></div>
|
||||
</div>
|
||||
<p class="ps-sub">Demo power with care — production change windows still belong in official OME / iDRAC process.</p>`,
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
@@ -173,13 +206,13 @@
|
||||
title: "AI that starts from OME facts",
|
||||
anim: "slide",
|
||||
html: `
|
||||
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, or Ops.</p>
|
||||
<p class="ps-lead">Ask → Cockpit enriches with live OME context → Open WebUI and/or vLLM → answer → act in map, reports, Console, or Ops.</p>
|
||||
<div class="ps-seq">
|
||||
<div class="ps-seq-step"><span>01</span><strong>Ask</strong><p>Fleet question in Copilot</p></div>
|
||||
<div class="ps-seq-step"><span>02</span><strong>Enrich</strong><p>BFF attaches Service Tag facts</p></div>
|
||||
<div class="ps-seq-step"><span>03</span><strong>Route</strong><p>Open WebUI / vLLM completion</p></div>
|
||||
<div class="ps-seq-step"><span>04</span><strong>Answer</strong><p>Still tied to the live map</p></div>
|
||||
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · report · ticket</p></div>
|
||||
<div class="ps-seq-step"><span>05</span><strong>Act</strong><p>Inspector · console · ticket</p></div>
|
||||
</div>
|
||||
<p class="ps-sub"><strong>OME for truth · AI for speed · Cockpit for the operator experience.</strong></p>`,
|
||||
},
|
||||
@@ -232,6 +265,8 @@
|
||||
html: `
|
||||
<ol class="ps-steps ps-stagger">
|
||||
<li>Pick a Service Tag on the map and open the inspector.</li>
|
||||
<li>Open <strong>Console</strong> — Auto-fill live iDRACs (4 or 8 screens).</li>
|
||||
<li>From Servers KPI: Power on a cold node · open iDRAC console in-panel.</li>
|
||||
<li>Open Reports · Analytics and click a colored segment.</li>
|
||||
<li>Ask Copilot a question that must cite Service Tags.</li>
|
||||
<li>Switch to <strong>Technical architecture</strong> for API and trust-boundary depth.</li>
|
||||
@@ -272,6 +307,7 @@
|
||||
<li>OME session / cache behaviour</li>
|
||||
<li>AI completion path</li>
|
||||
<li>Portal containers & key <code>/api/*</code> contracts</li>
|
||||
<li>iDRAC proxy · power jobs · Console wall</li>
|
||||
</ol>
|
||||
<p class="ps-sub">Delivered for ATC admins Jody & Laurens by Data FDEs Mo & Bart.</p>`,
|
||||
},
|
||||
@@ -321,7 +357,7 @@
|
||||
</div>
|
||||
<div class="ps-arch-layer apps">
|
||||
<div class="ps-arch-node app"><strong>Map</strong><span>Topology</span></div>
|
||||
<div class="ps-arch-node app"><strong>Reports</strong><span>Analytics</span></div>
|
||||
<div class="ps-arch-node app"><strong>Console</strong><span>iDRAC wall</span></div>
|
||||
<div class="ps-arch-node app core-app"><strong>Cockpit UI</strong><span>Drawers · KPIs</span></div>
|
||||
<div class="ps-arch-node app"><strong>Network</strong><span>Fabric · Racks</span></div>
|
||||
<div class="ps-arch-node app"><strong>Ops / AI</strong><span>Tickets · Copilot</span></div>
|
||||
@@ -458,13 +494,27 @@
|
||||
html: `
|
||||
<div class="ps-grid3">
|
||||
<div class="ps-card tech"><h4>/api/fleet</h4><p>Cached device graph for canvas & KPIs.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/devices/…</h4><p>Inventory, landscape, expanded NICs.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/devices/…/power</h4><p>OME JobService POWER_CONTROL · on / off / cycle.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/idrac-proxy/…</h4><p>Same-origin HTML + WebSocket bridge · strips XFO.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/reports/*</h4><p>Analytics, firmware, warranty, brief.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/chat · /api/models</h4><p>Grounded completions · model list.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/tickets</h4><p>Ops desk — local SQLite, not OME write-back.</p></div>
|
||||
<div class="ps-card tech"><h4>/api/network/*</h4><p>Fabric ports · racks · VLANs.</p></div>
|
||||
</div>`,
|
||||
},
|
||||
{
|
||||
id: "idrac-proxy",
|
||||
kicker: "Remote console",
|
||||
title: "Why the iDRAC proxy exists",
|
||||
anim: "slide",
|
||||
html: `
|
||||
<p class="ps-lead">Browsers refuse to iframe most iDRAC UIs (<code>X-Frame-Options: SAMEORIGIN|DENY</code>). Cockpit proxies the session so the Console wall and in-panel viewer stay on <code>:3090</code>.</p>
|
||||
<div class="ps-grid3">
|
||||
<div class="ps-card tech"><h4>HTTP reverse proxy</h4><p>Gzip passthrough · cookie Path rewrite · CSP <code>frame-ancestors *</code>.</p></div>
|
||||
<div class="ps-card tech"><h4>JS bootstrap</h4><p>Patches fetch / XHR / WebSocket so absolute <code>/sysmgmt</code> paths stay on the proxy prefix.</p></div>
|
||||
<div class="ps-card tech"><h4>SSRF guard</h4><p>Only fleet management IPs · prefers <code>idrac_ip</code> · rejects bare OS hosts.</p></div>
|
||||
</div>
|
||||
<p class="ps-sub">Operators still authenticate to iDRAC with their own credentials inside the embed.</p>`,
|
||||
},
|
||||
{
|
||||
id: "trust",
|
||||
kicker: "Trust & data",
|
||||
@@ -473,9 +523,10 @@
|
||||
html: `
|
||||
<div class="ps-cols">
|
||||
<div>
|
||||
<h4>From OME (read)</h4>
|
||||
<h4>From OME (read + jobs)</h4>
|
||||
<ul class="ps-bullets ps-stagger">
|
||||
<li>Devices, power, inventory, warranty, baselines</li>
|
||||
<li>Devices, power samples, inventory, warranty, baselines</li>
|
||||
<li>DeviceAction power jobs (on / off / cycle) when operators confirm</li>
|
||||
</ul>
|
||||
<h4 style="margin-top:0.85rem">Cockpit-local (write)</h4>
|
||||
<ul class="ps-bullets ps-stagger">
|
||||
@@ -486,8 +537,8 @@
|
||||
<h4>Security notes</h4>
|
||||
<ul class="ps-bullets ps-stagger">
|
||||
<li>Secrets only in portal environment variables</li>
|
||||
<li>Primary OME usage is read for awareness & demos</li>
|
||||
<li>Production change windows stay in official OME / iDRAC</li>
|
||||
<li>iDRAC proxy is an ops convenience — credentials stay with the user</li>
|
||||
<li>Production change windows still belong in official OME / iDRAC process</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>`,
|
||||
@@ -500,6 +551,7 @@
|
||||
html: `
|
||||
<ol class="ps-steps ps-stagger">
|
||||
<li>Return to the map and pick a Service Tag.</li>
|
||||
<li>Open <strong>Console</strong> and drop two iDRACs onto the wall.</li>
|
||||
<li>Open Reports and click a compliance segment.</li>
|
||||
<li>Ask Copilot a question that must cite that ST.</li>
|
||||
<li>Use <strong>Customer story</strong> for business / ops audiences.</li>
|
||||
@@ -508,13 +560,13 @@
|
||||
},
|
||||
];
|
||||
|
||||
const BUILTIN_VERSION = 8;
|
||||
const BUILTIN_VERSION = 9;
|
||||
|
||||
const BUILTIN_DECKS = [
|
||||
{
|
||||
id: "story",
|
||||
name: "Customer story",
|
||||
description: "Customer briefing — ask, architecture, apps, value",
|
||||
description: "Customer briefing — ask, Console wall, power, architecture, value",
|
||||
builtin: true,
|
||||
slides: structuredClone(STORY_SLIDES),
|
||||
},
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@
|
||||
const drawer = $("#reports-drawer");
|
||||
const scrim = $("#scrim");
|
||||
if (!drawer) return;
|
||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer"].forEach((id) => {
|
||||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer", "#console-drawer"].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (el) {
|
||||
el.classList.remove("open");
|
||||
|
||||
+581
-1
@@ -2100,7 +2100,7 @@ a.conn-link:hover {
|
||||
background: rgba(0,20,40,0.35);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: min(56vh, 520px);
|
||||
max-height: min(70vh, 680px);
|
||||
}
|
||||
.kpi-list { padding: 0.35rem; }
|
||||
.kpi-detail { padding: 0.75rem; }
|
||||
@@ -2136,6 +2136,83 @@ a.conn-link:hover {
|
||||
.kpi-detail h3 { margin: 0 0 0.35rem; color: var(--dell-bright); font-size: 1rem; }
|
||||
.kpi-detail .meta { margin: 0 0 0.65rem; color: var(--muted); font-family: var(--mono); font-size: 0.72rem; }
|
||||
.kpi-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.65rem 0; }
|
||||
.power-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
margin: 0.15rem 0 0.25rem;
|
||||
}
|
||||
.btn.power-on {
|
||||
background: linear-gradient(135deg, #1a7a3c, #2db85a);
|
||||
border-color: #2db85a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.power-on:hover { filter: brightness(1.08); }
|
||||
.btn.power-off {
|
||||
background: linear-gradient(135deg, #8a1f1f, #c43a3a);
|
||||
border-color: #c43a3a;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.power-off:hover { filter: brightness(1.08); }
|
||||
.btn.idrac-console,
|
||||
button.btn.idrac-console {
|
||||
background: linear-gradient(135deg, #c45a12, #e87a2a) !important;
|
||||
border-color: #e87a2a !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.btn.idrac-console:hover {
|
||||
filter: brightness(1.08);
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.btn.idrac-console:disabled {
|
||||
opacity: 0.45;
|
||||
filter: none;
|
||||
}
|
||||
.connect-tile.idrac-console {
|
||||
border-color: rgba(232, 122, 42, 0.55);
|
||||
background: rgba(196, 90, 18, 0.18);
|
||||
}
|
||||
.connect-tile.idrac-console .ct-ico,
|
||||
.connect-tile.idrac-console .ct-t { color: #ffb06a; }
|
||||
.connect-tile.danger .ct-ico { color: #e86a6a; }
|
||||
.connect-tile.danger:hover {
|
||||
border-color: rgba(200, 70, 70, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(200, 70, 70, 0.25);
|
||||
}
|
||||
|
||||
/* iDRAC console embed modal */
|
||||
#idrac-console-modal .idrac-console-card {
|
||||
width: min(1120px, 96vw);
|
||||
max-height: 94vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
.idrac-console-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
}
|
||||
.idrac-console-frame-wrap {
|
||||
flex: 1;
|
||||
min-height: min(68vh, 720px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #0a0e14;
|
||||
}
|
||||
#idrac-console-frame {
|
||||
width: 100%;
|
||||
height: min(68vh, 720px);
|
||||
border: 0;
|
||||
background: #0a0e14;
|
||||
}
|
||||
|
||||
.kpi-kv { display: grid; gap: 0.3rem; margin-top: 0.5rem; }
|
||||
.kpi-kv .row {
|
||||
display: grid;
|
||||
@@ -8232,3 +8309,506 @@ html[data-theme="light"] .chip.active {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* ===== Console · iDRAC wall ===== */
|
||||
.btn.console-btn {
|
||||
background: linear-gradient(135deg, rgba(196, 90, 18, 0.35), rgba(232, 122, 42, 0.55));
|
||||
border-color: rgba(232, 122, 42, 0.75);
|
||||
color: #ffe8d2;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.console-btn:hover { filter: brightness(1.08); color: #fff; }
|
||||
|
||||
.console-drawer.drawer,
|
||||
.drawer.console-drawer {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
transform: translateY(110%);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 8% 0%, rgba(232, 122, 42, 0.18), transparent 55%),
|
||||
radial-gradient(ellipse 50% 45% at 92% 10%, rgba(0, 168, 232, 0.16), transparent 50%),
|
||||
radial-gradient(ellipse 40% 35% at 50% 100%, rgba(61, 255, 160, 0.08), transparent 45%),
|
||||
#050b12;
|
||||
z-index: 80;
|
||||
}
|
||||
.console-drawer.drawer.open { transform: translateY(0); }
|
||||
.console-drawer.console-ready .console-tile {
|
||||
animation: consoleTileIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.console-drawer.console-ready .console-tile:nth-child(1) { animation-delay: 0.04s; }
|
||||
.console-drawer.console-ready .console-tile:nth-child(2) { animation-delay: 0.1s; }
|
||||
.console-drawer.console-ready .console-tile:nth-child(3) { animation-delay: 0.16s; }
|
||||
.console-drawer.console-ready .console-tile:nth-child(4) { animation-delay: 0.22s; }
|
||||
@keyframes consoleTileIn {
|
||||
from { opacity: 0; transform: translateY(18px) scale(0.97); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.console-aurora {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(120deg, transparent 30%, rgba(255, 154, 60, 0.06) 50%, transparent 70%),
|
||||
linear-gradient(300deg, transparent 40%, rgba(0, 168, 232, 0.05) 55%, transparent 75%);
|
||||
background-size: 200% 200%;
|
||||
animation: consoleAurora 14s ease-in-out infinite;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@keyframes consoleAurora {
|
||||
0%, 100% { background-position: 0% 40%, 100% 60%; }
|
||||
50% { background-position: 100% 60%, 0% 40%; }
|
||||
}
|
||||
|
||||
.console-head {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid rgba(255, 154, 60, 0.22);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.console-title {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: #ffb06a;
|
||||
}
|
||||
.console-sub {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #9eb4c8;
|
||||
}
|
||||
.console-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.console-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.cs-pill {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--mono);
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: #d7e6f4;
|
||||
}
|
||||
.cs-pill strong { color: #fff; margin-right: 0.2rem; }
|
||||
.cs-pill.live {
|
||||
border-color: rgba(61, 255, 160, 0.45);
|
||||
box-shadow: 0 0 12px rgba(61, 255, 160, 0.15);
|
||||
}
|
||||
.console-density .chip.active {
|
||||
background: #e87a2a;
|
||||
border-color: #e87a2a;
|
||||
color: #041018;
|
||||
}
|
||||
|
||||
.console-body {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: min(340px, 32vw) 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.console-fleet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(255, 154, 60, 0.18);
|
||||
background: rgba(0, 12, 24, 0.45);
|
||||
}
|
||||
.console-fleet-tools {
|
||||
padding: 0.65rem 0.75rem 0.4rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.console-fleet-tools input[type="search"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0.45rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 154, 60, 0.28);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.console-subnet-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
max-height: 5.5rem;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.console-subnet-chips .chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.console-chip-dot,
|
||||
.console-net-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: var(--chip, var(--net, #e87a2a));
|
||||
box-shadow: 0 0 8px var(--chip, var(--net, #e87a2a));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.console-fleet-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
.console-fleet-group {
|
||||
margin: 0.65rem 0 0.3rem;
|
||||
padding-left: 0.35rem;
|
||||
border-left: 3px solid var(--net, #e87a2a);
|
||||
}
|
||||
.cfg-label {
|
||||
font-size: 0.66rem;
|
||||
color: #c5d6e6;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.cfg-label code { color: #ffb06a; }
|
||||
.console-fleet-card {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 0.35rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(8, 18, 30, 0.75);
|
||||
color: inherit;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.console-fleet-card:hover {
|
||||
border-color: rgba(232, 122, 42, 0.55);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.console-fleet-card.dragging { opacity: 0.45; }
|
||||
.console-fleet-card.selected {
|
||||
border-color: #e87a2a;
|
||||
box-shadow: 0 0 0 1px rgba(232, 122, 42, 0.35);
|
||||
}
|
||||
.console-fleet-card.is-live {
|
||||
border-color: rgba(61, 255, 160, 0.28);
|
||||
}
|
||||
.console-fleet-card.on-wall {
|
||||
background: linear-gradient(135deg, rgba(232, 122, 42, 0.12), rgba(0, 40, 70, 0.4));
|
||||
}
|
||||
.cfc-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.cfc-top strong {
|
||||
font-size: 0.78rem;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cfc-status {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: #7a8a9a;
|
||||
}
|
||||
.console-fleet-card.is-live .cfc-status { color: #3dffa0; }
|
||||
.cfc-meta, .cfc-ip, .cfc-hint, .cfc-wall {
|
||||
font-size: 0.66rem;
|
||||
color: #9eb4c8;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.cfc-wall { color: #ffb06a; }
|
||||
|
||||
.console-stage {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
align-content: stretch;
|
||||
}
|
||||
.console-stage.density-medium {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.console-stage.density-small {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.console-body { grid-template-columns: 1fr; }
|
||||
.console-fleet { max-height: 38vh; border-right: none; border-bottom: 1px solid rgba(255, 154, 60, 0.18); }
|
||||
.console-stage.density-small {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.console-tile {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(4, 12, 22, 0.82);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.console-tile.is-filled {
|
||||
border-color: color-mix(in srgb, var(--net, #e87a2a) 55%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--net, #e87a2a) 25%, transparent),
|
||||
0 14px 36px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.console-tile.is-live::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #3dffa0;
|
||||
box-shadow: 0 0 12px #3dffa0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
.console-tile.drag-over {
|
||||
outline: 2px dashed #e87a2a;
|
||||
outline-offset: -4px;
|
||||
background: rgba(232, 122, 42, 0.08);
|
||||
}
|
||||
.console-tile.dragging { opacity: 0.5; }
|
||||
.console-tile.is-empty .console-dropzone {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1.25rem;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 10px,
|
||||
rgba(255, 154, 60, 0.03) 10px,
|
||||
rgba(255, 154, 60, 0.03) 20px
|
||||
);
|
||||
}
|
||||
.cd-kicker {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #e87a2a;
|
||||
}
|
||||
.console-dropzone strong {
|
||||
font-size: 1rem;
|
||||
color: #f2f7fc;
|
||||
}
|
||||
.console-dropzone p {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: #8aa0b4;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.console-tile-chrome {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.55rem 0.25rem;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent);
|
||||
cursor: grab;
|
||||
}
|
||||
.ctc-id { min-width: 0; display: grid; gap: 0.2rem; }
|
||||
.ctc-slot {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8aa0b4;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.ctc-name {
|
||||
font-size: 0.82rem;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.console-net-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--mono);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--net, #e87a2a) 50%, transparent);
|
||||
background: color-mix(in srgb, var(--net, #e87a2a) 14%, transparent);
|
||||
color: #e8f0f8;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.console-net-badge em {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.console-net-badge em.on { color: #3dffa0; }
|
||||
.console-net-badge em.off { color: #ff7a7a; }
|
||||
.ctc-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.ctc-actions .btn.compact {
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.console-tile-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.55rem 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--mono);
|
||||
color: #9eb4c8;
|
||||
}
|
||||
.console-tile-meta .on { color: #3dffa0; }
|
||||
.console-tile-meta .off { color: #ff8a8a; }
|
||||
.console-frame-wrap {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
background: #0a0e14;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.console-frame-wrap iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
border: 0;
|
||||
background: #0a0e14;
|
||||
}
|
||||
.console-stage.density-medium .console-frame-wrap { min-height: 220px; }
|
||||
.console-stage.density-medium .console-frame-wrap iframe { min-height: 240px; }
|
||||
.console-stage.density-small .ctc-name { font-size: 0.72rem; }
|
||||
.console-stage.density-small .console-tile-meta { display: none; }
|
||||
|
||||
/* Fullscreen popup */
|
||||
.console-fs-modal .console-fs-card {
|
||||
width: min(1280px, 98vw);
|
||||
max-height: 96vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
.console-fs-head { margin-bottom: 0.5rem; }
|
||||
.console-fs-frame-wrap {
|
||||
flex: 1;
|
||||
min-height: min(78vh, 820px);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(232, 122, 42, 0.35);
|
||||
background: #0a0e14;
|
||||
}
|
||||
#console-fs-frame {
|
||||
width: 100%;
|
||||
height: min(78vh, 820px);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .console-drawer.drawer {
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 8% 0%, rgba(232, 122, 42, 0.12), transparent 55%),
|
||||
radial-gradient(ellipse 50% 45% at 92% 10%, rgba(0, 118, 206, 0.1), transparent 50%),
|
||||
#f4f7fb;
|
||||
}
|
||||
html[data-theme="light"] .console-fleet {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
html[data-theme="light"] .console-fleet-card {
|
||||
background: #fff;
|
||||
color: #102033;
|
||||
}
|
||||
html[data-theme="light"] .cfc-top strong { color: #102033; }
|
||||
html[data-theme="light"] .console-tile {
|
||||
background: #fff;
|
||||
}
|
||||
html[data-theme="light"] .ctc-name { color: #102033; }
|
||||
|
||||
/* Console wall · 4 / 8 screens + drag shields */
|
||||
.console-stage.slots-4.density-medium {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.console-stage.slots-4.density-small {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.console-stage.slots-8.density-medium,
|
||||
.console-stage.slots-8.density-small {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
.console-stage.slots-8 .console-frame-wrap { min-height: 120px; }
|
||||
.console-stage.slots-8 .console-frame-wrap iframe { min-height: 140px; }
|
||||
.console-stage.slots-8.density-medium .console-frame-wrap { min-height: 160px; }
|
||||
.console-stage.slots-8.density-medium .console-frame-wrap iframe { min-height: 170px; }
|
||||
|
||||
.console-frame-wrap { position: relative; }
|
||||
.console-drop-shield {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
}
|
||||
.console-drawer.is-dragging .console-drop-shield { display: block; }
|
||||
.console-drawer.is-dragging .console-frame-wrap iframe { pointer-events: none; }
|
||||
|
||||
.cfc-actions { margin-top: 0.25rem; }
|
||||
.cfc-actions .btn.compact {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.66rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.console-fleet-card { cursor: grab; }
|
||||
|
||||
Reference in New Issue
Block a user