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