Initial import of Dell OpenManage Cockpit for ATC.

Interactive OME fleet UI with topology, KPI popups, in-browser SSH, chat, tickets, and full inventory landscape.
This commit is contained in:
mo
2026-07-17 04:00:38 +02:00
commit b73ff9465c
20 changed files with 7477 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
OME_URL=https://cov-omeprod01.dell-atc.lan
OME_USER=admin
OME_PASSWORD=CHANGE_ME
POLL_INTERVAL=12
OPENWEBUI_URL=http://atc-portal01.dell-atc.lan:3080
CORS_ORIGINS=*
GPU_METRICS_URL=http://10.0.10.106:9110
VLLM_URL=http://10.0.10.106:8000/v1
VLLM_MODEL=llama3-70b-gptq
COCKPIT_DATA=/data
OPENWEBUI_EMAIL=mohamed.el.kadi@dell.com
OPENWEBUI_PASSWORD=CHANGE_ME
VLLM_MAX_TOKENS=700
CHAT_SYSTEM_CHARS=5500
+9
View File
@@ -0,0 +1,9 @@
.env
data/
*.db
*.db-journal
__pycache__/
*.pyc
*.bak*
ui/ome-cockpit-*
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
# OME Cockpit — demo path
URL: http://atc-portal01.dell-atc.lan:3090
1. Watch live pulse lines from OME hub to connected iDRACs/servers
2. Filter a subnet (10.0.40 / 10.0.41) in the left rail
3. Click a glowing node → inspector (IP, model, service tag, power)
4. Click **OpenManage AI** → chat drawer with OME Copilot
5. Use **Connected only** to focus the live fabric
OpenManage AI standalone remains at :3080
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY api/main.py /app/main.py
COPY ui /ui
# main.py expects ../ui from api parent — adjust path: STATIC_DIR = /ui
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]
+23
View File
@@ -0,0 +1,23 @@
# Dell OpenManage Cockpit (ATC)
Interactive fleet operations UI for Dell OpenManage Enterprise.
## Features
- Live topology (Orbit / Galaxy / Clusters / Lanes / Helix)
- KPI context popups, triage, ops desk tickets
- In-browser SSH terminal (own username/password)
- Cockpit chat against vLLM / OpenManage AI models
- Full inventory + application/firmware landscape from OME
## Run
```bash
cp .env.example .env # fill OME + vLLM settings
docker compose up -d --build
```
UI: `http://<host>:3090`
## Layout
- `api/main.py` — FastAPI (OME poll, chat, SSH bridge, tickets)
- `ui/` — static frontend
- `docker-compose.yml` / `Dockerfile`
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
# UI copied at compose build context level
COPY ../ui /ui
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8090"]
+1576
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic==2.10.4
pydantic-settings==2.7.0
asyncssh==2.18.0
+19
View File
@@ -0,0 +1,19 @@
services:
ome-cockpit:
build: .
container_name: ome-cockpit
restart: unless-stopped
ports:
- "3090:8090"
env_file:
- .env
volumes:
- ./ui:/ui:ro
- ./api/main.py:/app/main.py:ro
- ./data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8090/api/health\")"]
interval: 20s
timeout: 5s
retries: 5
start_period: 25s
+2253
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 56" role="img" aria-label="Dell">
<rect width="220" height="56" rx="6" fill="#0076CE"/>
<g fill="#FFFFFF" transform="translate(18,8)">
<!-- D -->
<path d="M0 0h22c16.5 0 27 9.2 27 20.5S38.5 41 22 41H0V0zm16.2 29.8h5c7.4 0 11.8-4.2 11.8-9.3S28.6 11.2 21.2 11.2h-5v18.6z"/>
<!-- E (classic open E) -->
<path d="M58 0h40v9.8H74.2v6.6h20.5v9.2H74.2v6.4H99V41H58V0z"/>
<!-- L -->
<path d="M108 0h16.2v31.2H158V41h-50V0z"/>
<!-- L -->
<path d="M166 0h16.2v31.2H214V41h-48V0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 579 B

+450
View File
@@ -0,0 +1,450 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dell OpenManage Cockpit — ATC</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=kpi1" />
<link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
</head>
<body>
<div id="notify-stack" aria-live="polite" aria-atomic="false"></div>
<div id="app">
<header class="topbar">
<a class="brand" href="/" title="OpenManage Cockpit home">
<img class="dell-logo" src="/dell.png" alt="Dell" width="44" height="44" />
<div class="brand-text">
<h1>OpenManage <span>Cockpit</span></h1>
<p id="ome-meta">OME · connecting…</p>
</div>
</a>
<div class="kpi-strip" id="kpi-strip" aria-label="Fleet KPIs"></div>
<div class="top-actions">
<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-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit chat</button>
</div>
</header>
<aside class="rail left" id="left-rail">
<section class="rail-block open" data-module="viz">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Visualization</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Interactive topology layout</p>
<div class="viz-grid" id="layout-modes">
<button type="button" class="viz-card active" data-layout="orbit"><span class="viz-ico"></span><span class="viz-t">Orbit</span><span class="viz-s">Hub &amp; rings</span></button>
<button type="button" class="viz-card" data-layout="galaxy"><span class="viz-ico"></span><span class="viz-t">Galaxy</span><span class="viz-s">Spiral arms</span></button>
<button type="button" class="viz-card" data-layout="clusters"><span class="viz-ico"></span><span class="viz-t">Clusters</span><span class="viz-s">Subnet islands</span></button>
<button type="button" class="viz-card" data-layout="lanes"><span class="viz-ico"></span><span class="viz-t">Lanes</span><span class="viz-s">Fiber highways</span></button>
<button type="button" class="viz-card" data-layout="helix"><span class="viz-ico"></span><span class="viz-t">Helix</span><span class="viz-s">3D depth coil</span></button>
</div>
</div>
</section>
<section class="rail-block open" data-module="filters">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Filters</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Servers / iDRACs = include types. Both off = empty map.</p>
<div class="filters" id="filters">
<label class="toggle"><input type="checkbox" id="f-servers" checked /> Servers</label>
<label class="toggle"><input type="checkbox" id="f-idrac" /> iDRACs</label>
<label class="toggle"><input type="checkbox" id="f-connected" /> Connected only</label>
<label class="toggle"><input type="checkbox" id="f-powered" /> Powered on</label>
<label class="toggle"><input type="checkbox" id="f-has-power" /> Has power reading</label>
<label class="toggle"><input type="checkbox" id="f-ghost-links" /> Ghost offline links</label>
<label class="toggle"><input type="checkbox" id="f-pulse-links" checked /> Animate link pulse</label>
<label class="range">
<span>Min watts <em id="min-w-label">0</em></span>
<input type="range" id="f-min-watts" min="0" max="800" step="10" value="0" />
</label>
</div>
<div class="filter-actions">
<button type="button" class="btn ghost compact" id="btn-clear-filters" title="Reset filters to defaults">Reset filters</button>
</div>
<input type="search" id="search" class="search" placeholder="Search name, tag, IP, model…" autocomplete="off" />
</div>
</section>
<section class="rail-block open" data-module="view">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Color mode</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Node colors by status, power, model…</p>
<div class="chip-grid" id="view-modes">
<button type="button" class="chip active" data-mode="status">Status</button>
<button type="button" class="chip" data-mode="power">Power (W)</button>
<button type="button" class="chip" data-mode="connection">Connection</button>
<button type="button" class="chip" data-mode="model">Model</button>
<button type="button" class="chip" data-mode="subnet">Subnet</button>
</div>
</div>
</section>
<section class="rail-block open" data-module="gpu">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>V100 GPU matrix</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Live atc-gpu-prod · llama3-70b-gptq</p>
<div id="gpu-summary" class="gpu-summary">Waiting for GPU telemetry…</div>
<div id="gpu-matrix" class="gpu-matrix"></div>
</div>
</section>
<section class="rail-block open" data-module="live">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Realtime context</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Live OME alerts + fleet deltas — click to focus</p>
<div class="ctx-stats" id="ctx-stats"></div>
<div class="list" id="alert-list"></div>
</div>
</section>
<section class="rail-block open" data-module="networks">
<button type="button" class="rail-toggle" aria-expanded="true">
<span>Networks</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Management subnets — click to focus</p>
<div class="list" id="subnet-list"></div>
</div>
</section>
<section class="rail-block" data-module="models">
<button type="button" class="rail-toggle" aria-expanded="false">
<span>Models</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<p class="hint">Server platforms in inventory</p>
<div class="list" id="model-list"></div>
</div>
</section>
<section class="rail-block" data-module="groups">
<button type="button" class="rail-toggle" aria-expanded="false">
<span>Groups</span>
<span class="chev" aria-hidden="true"></span>
</button>
<div class="rail-panel">
<div class="list" id="group-list"></div>
</div>
</section>
</aside>
<main class="stage">
<div class="ctx-bar" id="ctx-bar">
<div class="ctx-live">
<span class="pulse-dot" aria-hidden="true"></span>
<span id="ctx-live-text">Realtime context · waiting for OME…</span>
</div>
<div class="ctx-focus" id="ctx-focus">Focus: all networks</div>
</div>
<canvas id="topo"></canvas>
<div class="stage-hud">
<div class="legend" id="legend"></div>
<div class="hint-bar">Scroll = zoom · drag = pan · hover tip · double-click = Quick Connect</div>
</div>
<aside class="ctx-feed" id="ctx-feed" aria-label="Realtime feed">
<header class="ctx-feed-head" id="ctx-feed-drag">
<strong>Live feed</strong>
<span id="ctx-feed-meta"></span>
<span class="feed-docks">
<button type="button" class="feed-dock" id="btn-feed-collapse" title="Minimize feed"></button>
<button type="button" class="feed-dock hidden" id="btn-feed-expand" title="Expand feed"></button>
</span>
</header>
<div class="ctx-feed-body" id="ctx-feed-body"></div>
</aside>
</main>
<aside class="rail right" id="inspector">
<div class="inspector-empty" id="inspector-empty">
<img src="/dell.png" alt="" class="ghost-logo" />
<h2>Inspector</h2>
<p>Select a node, KPI, or subnet for live details. Everything is clickable.</p>
</div>
<div class="inspector-body hidden" id="inspector-body"></div>
</aside>
<footer class="ticker" id="ticker"></footer>
</div>
<!-- Cockpit chat -->
<div class="drawer" id="chat-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>Cockpit chat</span>
</div>
<button type="button" class="btn ghost" id="btn-chat-close">Close</button>
</div>
<div class="chat-context" id="chat-context">
<label class="chat-model-wrap" title="Select model">
<span class="chat-model-label">Model</span>
<select id="chat-model" class="chat-model-select" aria-label="Chat model">
<option value="llama3-70b-gptq">llama3-70b-gptq</option>
</select>
</label>
<span class="chat-ctx-pill" id="chat-ctx-fleet">fleet …</span>
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
</div>
<div class="chat-quick" id="chat-quick">
<button type="button" data-q="What is the current fleet health? Name critical systems.">Fleet health</button>
<button type="button" data-q="List all critical alerts with system name and IP.">Critical alerts</button>
<button type="button" data-q="Which connected nodes use the most power right now?">Top power</button>
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod.">GPU status</button>
</div>
<div class="chat-body" id="chat-body"></div>
<form class="chat-form" id="chat-form">
<input type="text" id="chat-input" placeholder="Ask about live OME fleet, alerts, power, iDRACs, GPUs…" autocomplete="off" />
<button type="submit" class="btn primary">Send</button>
</form>
</div>
<!-- Ops desk / tickets -->
<div class="drawer wide" id="ops-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>ATC Ops desk</span>
</div>
<button type="button" class="btn ghost" id="btn-ops-close">Close</button>
</div>
<div class="ops-layout">
<div class="ops-side">
<label class="ops-identity">Acting as
<select id="ops-actor">
<option value="jody">Jody van Dongen</option>
<option value="laurens">Laurens Rammers</option>
</select>
</label>
<button type="button" class="btn primary" id="btn-new-ticket">New ticket</button>
<div class="ops-filters">
<select id="ops-filter">
<option value="openish">Active</option>
<option value="all">All</option>
<option value="open">Open</option>
<option value="accepted">Accepted</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
<option value="mine">Assigned to me</option>
</select>
</div>
<div class="list" id="ticket-list"></div>
</div>
<div class="ops-main" id="ops-main">
<div class="ops-empty" id="ops-empty">
<h3>Admin collaboration</h3>
<p>Jody van Dongen and Laurens Rammers can exchange notes and tickets for ATC datacenter operations.</p>
</div>
<div class="ops-thread hidden" id="ops-thread"></div>
</div>
</div>
</div>
<div class="drawer" id="ai-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>OpenManage AI</span>
</div>
<button type="button" class="btn ghost" id="btn-ai-close">Close</button>
</div>
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
</div>
<div class="scrim" id="scrim"></div>
<div class="node-tip hidden" id="node-tip" role="tooltip"></div>
<div class="modal hidden" id="ticket-modal" aria-hidden="true" role="dialog" aria-labelledby="ticket-modal-title">
<div class="modal-card ticket-card">
<button type="button" class="modal-close" id="btn-ticket-modal-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">ATC Ops desk</p>
<h2 id="ticket-modal-title">Create ticket</h2>
<p class="modal-sub">Professional handoff between Jody van Dongen and Laurens Rammers</p>
</div>
</div>
<form id="ticket-form" class="ticket-form">
<label>Title
<input type="text" id="ticket-title" required maxlength="180" placeholder="Short operational title" />
</label>
<div class="ticket-form-row">
<label>Priority
<select id="ticket-priority">
<option value="low">Low</option>
<option value="normal" selected>Normal</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</label>
<label>Assign to
<select id="ticket-assignee">
<option value="laurens">Laurens Rammers</option>
<option value="jody">Jody van Dongen</option>
</select>
</label>
</div>
<label>Details / note
<textarea id="ticket-body" rows="7" required placeholder="Context, system, IP, observed impact, requested action…"></textarea>
</label>
<div class="ticket-form-actions">
<button type="button" class="btn ghost" id="btn-ticket-cancel">Cancel</button>
<button type="submit" class="btn primary">Create ticket</button>
</div>
</form>
</div>
</div>
<div class="modal hidden" id="kpi-modal" aria-hidden="true" role="dialog" aria-labelledby="kpi-title">
<div class="modal-card kpi-card">
<button type="button" class="modal-close" id="btn-kpi-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">Fleet context</p>
<h2 id="kpi-title">KPI</h2>
<p class="modal-sub" id="kpi-sub">Interactive breakdown — click a system for actions</p>
</div>
</div>
<div class="kpi-summary" id="kpi-summary"></div>
<div class="kpi-toolbar">
<input type="search" id="kpi-search" class="search" placeholder="Search name, IP, model, tag, subnet…" autocomplete="off" />
<select id="kpi-sort" aria-label="Sort">
<option value="name">Sort: name</option>
<option value="watts">Sort: power</option>
<option value="status">Sort: status</option>
<option value="subnet">Sort: subnet</option>
</select>
<button type="button" class="btn" id="btn-kpi-apply">Apply as map filter</button>
<button type="button" class="btn ghost" id="btn-kpi-clear">Clear map filter</button>
</div>
<div class="kpi-layout">
<div class="kpi-list" id="kpi-list"></div>
<div class="kpi-detail" id="kpi-detail">
<p class="hint">Select a system on the left for live context and actions.</p>
</div>
</div>
</div>
</div>
<div class="modal hidden" id="triage-modal" aria-hidden="true" role="dialog" aria-labelledby="triage-title">
<div class="modal-card triage-card">
<button type="button" class="modal-close" id="btn-triage-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">Realtime triage</p>
<h2 id="triage-title">Critical alerts</h2>
<p class="modal-sub" id="triage-sub">Click a system to inspect · create ATC ticket instantly</p>
</div>
</div>
<div class="triage-toolbar">
<div class="chip-grid" id="triage-tabs">
<button type="button" class="chip active" data-triage="critical">Critical</button>
<button type="button" class="chip" data-triage="warning">Warning</button>
<button type="button" class="chip" data-triage="events">Fleet deltas</button>
<button type="button" class="chip" data-triage="hottest">Live power</button>
<button type="button" class="chip" data-triage="all">All feed</button>
</div>
<input type="search" id="triage-search" class="search" placeholder="Filter by system, IP, message, tag…" />
</div>
<div class="triage-layout">
<div class="triage-list" id="triage-list"></div>
<div class="triage-detail" id="triage-detail">
<p class="hint">Select an alert or system on the left.</p>
</div>
</div>
</div>
</div>
<div class="modal hidden" id="connect-modal" aria-hidden="true" role="dialog" aria-labelledby="connect-title">
<div class="modal-card">
<button type="button" class="modal-close" id="btn-connect-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">Quick Connect</p>
<h2 id="connect-title">Device</h2>
<p class="modal-sub" id="connect-sub"></p>
</div>
</div>
<div class="modal-badges" id="connect-badges"></div>
<div class="modal-context" id="connect-context"></div>
<div class="connect-grid" id="connect-grid"></div>
<p class="modal-foot">iDRAC opens in a new tab · SSH opens an in-browser terminal — enter your own username</p>
</div>
</div>
<div class="modal hidden" id="ssh-modal" aria-hidden="true" role="dialog" aria-labelledby="ssh-device">
<div class="modal-card ssh-card">
<button type="button" class="modal-close" id="btn-ssh-close" aria-label="Close">×</button>
<div class="modal-brand">
<img src="/dell.png" alt="Dell" width="36" height="36" />
<div>
<p class="modal-kicker">SSH terminal</p>
<h2 id="ssh-device">SSH session</h2>
<p class="modal-sub" id="ssh-sub"></p>
</div>
</div>
<p class="ssh-status" id="ssh-status">Enter username and password, then Connect.</p>
<form class="ssh-login" id="ssh-login" autocomplete="on">
<div class="ssh-grid">
<label>Host
<input type="text" id="ssh-host" name="host" autocomplete="off" spellcheck="false" />
</label>
<label>Port
<input type="number" id="ssh-port" name="port" value="22" min="1" max="65535" />
</label>
<label>Username
<input type="text" id="ssh-user" name="username" autocomplete="username" spellcheck="false" placeholder="your account (not root)" />
</label>
<label>Password
<input type="password" id="ssh-pass" name="password" autocomplete="current-password" placeholder="••••••••" />
</label>
</div>
<div class="ssh-actions">
<button type="submit" class="btn primary" id="ssh-connect-btn">Connect</button>
<button type="button" class="btn ghost" id="btn-ssh-disconnect">Disconnect</button>
</div>
</form>
<div class="ssh-term-wrap hidden" id="ssh-term-wrap">
<div id="ssh-term" class="ssh-term"></div>
</div>
</div>
</div>
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=kpi1"></script>
<script src="/ops.js?v=kpi1"></script>
<script src="/ssh.js?v=kpi1"></script>
</body>
</html>
+866
View File
@@ -0,0 +1,866 @@
(() => {
const $ = (sel) => document.querySelector(sel);
const state = window.__cockpitState || null;
// Hook into app.js state if exposed; else work via DOM + fetch
function ensureApi() {
return {
async get(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async post(url, body) {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async patch(url, body) {
const r = await fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
},
async del(url) {
const r = await fetch(url, { method: "DELETE" });
if (!r.ok) throw new Error(await r.text());
return r.json();
},
};
}
const api = ensureApi();
const chatHistory = [];
let tickets = [];
let activeTicketId = null;
let gpuData = null;
let chatModelsLoaded = false;
function selectedChatModel() {
const sel = $("#chat-model");
const saved = localStorage.getItem("cockpit_chat_model");
return (sel?.value || saved || "llama3-70b-gptq").trim();
}
async function loadChatModels() {
const sel = $("#chat-model");
if (!sel) return;
try {
const data = await api.get("/api/models");
const models = data.models || [];
const preferred = localStorage.getItem("cockpit_chat_model") || data.default || "llama3-70b-gptq";
if (models.length) {
sel.innerHTML = models
.map((m) => {
const id = m.id || m.name;
const label = m.name && m.name !== id ? `${m.name} (${id})` : id;
return `<option value="${id}">${label}</option>`;
})
.join("");
const ids = models.map((m) => m.id);
sel.value = ids.includes(preferred) ? preferred : ids[0];
}
chatModelsLoaded = true;
} catch (e) {
console.warn("model list failed", e);
}
}
function actor() {
return $("#ops-actor")?.value || localStorage.getItem("atc_actor") || "jody";
}
function adminName(id) {
return id === "laurens" ? "Laurens Rammers" : "Jody van Dongen";
}
function openDrawer(id) {
closeDrawers();
const el = $(id);
el?.classList.add("open");
el?.setAttribute("aria-hidden", "false");
const scrim = $("#scrim");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = id.replace("#", "");
}
function closeDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer"].forEach((id) => {
const el = $(id);
el?.classList.remove("open");
el?.setAttribute("aria-hidden", "true");
});
const scrim = $("#scrim");
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer"].includes(scrim.dataset.mode)) {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function renderGpu(data) {
gpuData = data;
const box = $("#gpu-matrix");
const sum = $("#gpu-summary");
if (!box) return;
if (!data || data.error) {
if (sum) sum.textContent = "GPU telemetry unreachable";
box.innerHTML = "";
return;
}
const s = data.summary || {};
if (sum) {
sum.innerHTML = `<strong>${s.avg_util ?? 0}%</strong> avg util · <strong>${Math.round(s.total_power_w || 0)} W</strong> · mem ${Math.round((s.total_mem_used_mb || 0) / 1024)} / ${Math.round((s.total_mem_mb || 0) / 1024)} GB`;
}
box.innerHTML = (data.gpus || [])
.map((g) => {
const busy = g.util_gpu >= 5;
const pct = Math.max(0, Math.min(100, g.util_gpu || 0));
return `<div class="gpu-card ${busy ? "busy" : "idle"}">
<div class="gpu-top"><span>GPU ${g.index}</span><span>${pct.toFixed(0)}%</span></div>
<div class="gpu-bar"><i style="width:${pct}%"></i></div>
<div class="gpu-meta">${Math.round(g.mem_used_mb)} / ${Math.round(g.mem_total_mb)} MB · ${Math.round(g.temp_c)}°C · ${Math.round(g.power_w)} W</div>
<div class="gpu-name">${g.name || "V100"}</div>
</div>`;
})
.join("");
}
function appendChat(role, text) {
const body = $("#chat-body");
if (!body) return;
const div = document.createElement("div");
div.className = `chat-bubble ${role}`;
div.textContent = text;
body.appendChild(div);
body.scrollTop = body.scrollHeight;
}
function refreshChatContext() {
const d = window.cockpit?.getState?.()?.data || {};
const s = d.summary || {};
const fleet = $("#chat-ctx-fleet");
if (fleet) {
fleet.textContent = `${s.connected ?? "—"} connected · ${s.total_watts != null ? Math.round(s.total_watts) + "W" : "—"} · ${(d.alerts || []).filter((a) => a.severity === "Critical").length} crit`;
}
const focus = $("#chat-ctx-focus");
const sel = window.cockpit?.getState?.()?.selectedId;
const node = (d.devices || []).find((x) => x.id === sel);
if (focus) focus.textContent = node ? `focus: ${(node.name || "").slice(0, 28)}` : "focus: none";
}
async function sendChat(msg) {
refreshChatContext();
appendChat("user", msg);
chatHistory.push({ role: "user", content: msg });
const model = selectedChatModel();
appendChat("assistant", `Thinking with ${model}`);
const thinking = $("#chat-body")?.lastElementChild;
try {
const focusId = window.cockpit?.getState?.()?.selectedId || null;
const res = await api.post("/api/chat", {
message: msg,
history: chatHistory.slice(0, -1),
focus_device_id: focusId,
model,
});
if (thinking) thinking.textContent = res.reply || "(empty reply)";
chatHistory.push({ role: "assistant", content: res.reply || "" });
const g = await api.get("/api/gpu");
renderGpu(g);
refreshChatContext();
} catch (e) {
if (thinking) thinking.textContent = "Chat error: " + e.message;
}
}
function ticketFilterValue() {
return $("#ops-filter")?.value || "openish";
}
async function loadTickets() {
const data = await api.get("/api/tickets");
tickets = data.tickets || [];
const list = $("#ticket-list");
if (!list) return;
const f = ticketFilterValue();
const me = actor();
const filtered = tickets.filter((t) => {
if (f === "all") return true;
if (f === "mine") return t.assignee === me;
if (f === "openish") return ["open", "accepted", "in_progress"].includes(t.status);
return t.status === f;
});
if (!filtered.length) {
list.innerHTML = `<p class="hint">No tickets in this filter.</p>`;
return;
}
list.innerHTML = filtered
.map((tkt) => {
const active = tkt.id === activeTicketId ? " active" : "";
return `<button type="button" class="card${active}" data-ticket="${tkt.id}">
<span class="t">${escape(tkt.title)}</span>
<span class="s"><span class="st-${escape(tkt.status)}">${escape(tkt.status)}</span> · ${escape(tkt.priority)} · ${escape(adminName(tkt.created_by))}${escape(adminName(tkt.assignee))}${tkt.accepted_by ? " · accepted " + escape(adminName(tkt.accepted_by)) : ""}</span>
</button>`;
})
.join("");
}
function escape(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function openTicket(id) {
activeTicketId = id;
await loadTickets();
const data = await api.get(`/api/tickets/${id}`);
const tkt = data.ticket;
const msgs = data.messages || [];
$("#ops-empty")?.classList.add("hidden");
const thread = $("#ops-thread");
thread?.classList.remove("hidden");
if (!thread) return;
const me = actor();
const canAccept = tkt.assignee === me && !tkt.accepted_by && tkt.status === "open";
thread.innerHTML = `
<div class="ops-ticket-head">
<h3>#${tkt.id} · ${escape(tkt.title)}</h3>
<div class="ops-meta-grid">
<div><span>Status</span>${escape(tkt.status)}</div>
<div><span>Priority</span>${escape(tkt.priority)}</div>
<div><span>Created by</span>${escape(adminName(tkt.created_by))}</div>
<div><span>Assignee</span>${escape(adminName(tkt.assignee))}</div>
<div><span>Accepted by</span>${tkt.accepted_by ? escape(adminName(tkt.accepted_by)) : "—"}</div>
<div><span>Updated</span>${new Date(tkt.updated_at * 1000).toLocaleString()}</div>
</div>
<div class="ops-toolbar">
<select id="ops-status">
<option value="open">Open</option>
<option value="accepted">Accepted</option>
<option value="in_progress">In progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select id="ops-assign">
<option value="jody">Assign Jody</option>
<option value="laurens">Assign Laurens</option>
</select>
<select id="ops-priority">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button type="button" class="btn" id="ops-save-meta">Update</button>
${canAccept ? `<button type="button" class="btn primary" id="ops-accept">Accept as ${escape(adminName(me))}</button>` : ""}
<button type="button" class="btn" id="ops-close-ticket">Close ticket</button>
<button type="button" class="btn danger" id="ops-delete-ticket">Delete</button>
</div>
</div>
<div class="ops-msgs" id="ops-msgs">
${msgs
.map(
(m) => `<div class="ops-msg"><strong>${escape(adminName(m.author))}</strong>
<span class="ops-time">${new Date(m.created_at * 1000).toLocaleString()}</span>
<p>${escape(m.body)}</p></div>`
)
.join("")}
</div>
<form class="ops-reply" id="ops-reply">
<textarea id="ops-reply-body" rows="3" placeholder="Write a note to the other admin…"></textarea>
<button class="btn primary" type="submit">Send note</button>
</form>`;
$("#ops-status").value = tkt.status || "open";
$("#ops-assign").value = tkt.assignee || "jody";
$("#ops-priority").value = tkt.priority || "normal";
$("#ops-save-meta")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, {
status: $("#ops-status").value,
assignee: $("#ops-assign").value,
priority: $("#ops-priority").value,
});
openTicket(id);
});
$("#ops-accept")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, { accepted_by: me, status: "accepted" });
openTicket(id);
});
$("#ops-close-ticket")?.addEventListener("click", async () => {
await api.patch(`/api/tickets/${id}`, { status: "closed" });
openTicket(id);
});
$("#ops-delete-ticket")?.addEventListener("click", async () => {
if (!confirm(`Delete ticket #${id}? This cannot be undone.`)) return;
await api.del(`/api/tickets/${id}`);
activeTicketId = null;
$("#ops-thread")?.classList.add("hidden");
$("#ops-empty")?.classList.remove("hidden");
await loadTickets();
});
$("#ops-reply")?.addEventListener("submit", async (e) => {
e.preventDefault();
const body = $("#ops-reply-body")?.value?.trim();
if (!body) return;
await api.post(`/api/tickets/${id}/messages`, { author: actor(), body });
openTicket(id);
});
}
let ticketDraft = null;
function openTicketModal(draft = null) {
ticketDraft = draft;
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
$("#ticket-title").value = draft?.title || "";
$("#ticket-body").value = draft?.body || "";
$("#ticket-priority").value = draft?.priority || "normal";
$("#ticket-assignee").value = draft?.assignee || other;
const modal = $("#ticket-modal");
const scrim = $("#scrim");
modal?.classList.remove("hidden");
modal?.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "ticket";
setTimeout(() => $("#ticket-title")?.focus(), 50);
}
function closeTicketModal() {
const modal = $("#ticket-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "ticket") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
ticketDraft = null;
}
async function newTicket() {
openTicketModal(null);
}
// Collapsible + lightly draggable live feed
function initFeedDrag() {
const feed = $("#ctx-feed");
const handle = $("#ctx-feed-drag");
if (!feed || !handle) return;
const applyCollapsed = (collapsed) => {
feed.classList.toggle("collapsed", collapsed);
document.querySelector(".stage")?.classList.toggle("feed-collapsed", collapsed);
$("#btn-feed-collapse")?.classList.toggle("hidden", collapsed);
$("#btn-feed-expand")?.classList.toggle("hidden", !collapsed);
localStorage.setItem("feed_collapsed", collapsed ? "1" : "0");
const body = $("#ctx-feed-body");
if (collapsed) {
// park as compact bar near bottom-right; purge DOM to avoid Chromium ghost text
feed.style.bottom = "0.55rem";
feed.style.left = "";
feed.style.right = "0.75rem";
feed.style.top = "";
feed.style.width = "";
feed.style.height = "";
if (body) body.innerHTML = "";
} else {
const saved = localStorage.getItem("feed_bottom");
feed.style.bottom = saved && !saved.includes("rem") ? saved : "2.4rem";
// ask app to refill feed on next snapshot
window.dispatchEvent(new CustomEvent("cockpit-feed-expand"));
}
};
// migrate: clear broken bottom values that pinned feed mid-screen
const savedBottom = localStorage.getItem("feed_bottom");
if (savedBottom) {
const n = parseFloat(savedBottom);
if (!Number.isFinite(n) || n > 200) localStorage.removeItem("feed_bottom");
}
applyCollapsed(localStorage.getItem("feed_collapsed") === "1");
$("#btn-feed-collapse")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
applyCollapsed(true);
});
$("#btn-feed-expand")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
applyCollapsed(false);
});
// click header when collapsed = expand
handle.addEventListener("click", (e) => {
if (e.target.closest("button")) return;
if (feed.classList.contains("collapsed")) applyCollapsed(false);
});
const feedBottomPx = () => {
const raw = feed.style.bottom || getComputedStyle(feed).bottom || "38px";
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 38;
};
const setFeedBottom = (px) => {
if (feed.classList.contains("collapsed")) return;
const next = Math.max(8, Math.min(160, px));
feed.style.bottom = next + "px";
localStorage.setItem("feed_bottom", feed.style.bottom);
};
let dragging = false;
let startY = 0;
let startBottom = 0;
handle.addEventListener("mousedown", (e) => {
if (e.target.closest("button")) return;
if (feed.classList.contains("collapsed")) return;
dragging = true;
startY = e.clientY;
startBottom = feedBottomPx();
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const dy = e.clientY - startY;
setFeedBottom(startBottom - dy);
});
window.addEventListener("mouseup", () => {
dragging = false;
});
}
// GPU via fleet snapshot + dedicated WS messages: monkey-patch WebSocket if needed
function hookGpuFromFleet() {
const orig = window.__applyGpu;
// poll GPU every 2s as backup
async function tick() {
try {
renderGpu(await api.get("/api/gpu"));
} catch (_) {}
}
tick();
setInterval(tick, 2500);
// listen custom event from app.js if dispatched
window.addEventListener("cockpit-gpu", (e) => renderGpu(e.detail));
window.addEventListener("cockpit-snapshot", (e) => {
if (e.detail?.gpu) renderGpu(e.detail.gpu);
});
}
// ===== Realtime triage popup =====
let triageMode = "critical";
let triageSelected = null;
let triageFilter = "";
function closeTriage() {
const modal = $("#triage-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "triage") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function openTriage(mode = "critical", opts = {}) {
triageMode = mode || "critical";
triageFilter = "";
const search = $("#triage-search");
if (search) search.value = "";
const modal = $("#triage-modal");
const scrim = $("#scrim");
if (!modal) return;
// close other overlays lightly
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "triage";
$("#triage-tabs")?.querySelectorAll("[data-triage]").forEach((c) => {
c.classList.toggle("active", c.dataset.triage === triageMode);
});
const titles = {
critical: "Critical alerts",
warning: "Warning alerts",
events: "Fleet deltas",
hottest: "Live power ranking",
all: "Realtime alert feed",
};
const elTitle = $("#triage-title");
if (elTitle) elTitle.textContent = titles[triageMode] || "Realtime triage";
renderTriageList(opts);
}
function cockpitData() {
return window.cockpit?.getState?.()?.data || {};
}
function deviceById(id) {
if (id == null || id === "") return null;
return (cockpitData().devices || []).find((d) => String(d.id) === String(id)) || null;
}
function triageItems(mode) {
const d = cockpitData();
const items = [];
if (mode === "critical" || mode === "warning" || mode === "all") {
const want = mode === "all" ? null : mode === "critical" ? "Critical" : "Warning";
for (const a of d.alerts || []) {
if (want && String(a.severity) !== want) continue;
items.push({
kind: "alert",
id: "alert-" + a.id,
alertId: a.id,
deviceId: a.device_id,
severity: a.severity,
title: a.device || "Unknown system",
message: a.message || "",
meta: [a.ip || "no-ip", a.category, a.subcategory, a.message_id, a.time].filter(Boolean).join(" · "),
raw: a,
sort: Date.parse(String(a.time || "").replace(" ", "T") + "Z") || 0,
});
}
}
if (mode === "events" || mode === "all") {
for (const e of d.events || []) {
items.push({
kind: "event",
id: "event-" + e.ts + "-" + e.device_id + "-" + e.kind,
deviceId: e.device_id,
severity: e.severity || "info",
title: e.title || e.kind,
message: e.text || "",
meta: (e.kind || "delta") + " · " + new Date((e.ts || 0) * 1000).toLocaleString(),
raw: e,
sort: (e.ts || 0) * 1000,
});
}
}
if (mode === "hottest" || mode === "all") {
for (const h of (d.context || {}).hottest || []) {
items.push({
kind: "power",
id: "hot-" + h.id,
deviceId: h.id,
severity: "info",
title: h.name || "node",
message: Math.round(h.watts || 0) + " W live sample",
meta: (h.subnet || "") + " · power ranking",
raw: h,
sort: (h.watts || 0) * 1e6,
});
}
}
items.sort((x, y) => (y.sort || 0) - (x.sort || 0));
return items;
}
function renderTriageList(opts = {}) {
const list = $("#triage-list");
if (!list) return;
let items = triageItems(triageMode);
const q = (triageFilter || "").trim().toLowerCase();
if (q) {
items = items.filter((it) =>
[it.title, it.message, it.meta, it.severity].join(" ").toLowerCase().includes(q)
);
}
const sub = $("#triage-sub");
if (sub) {
const systems = new Set(items.map((i) => i.title));
sub.textContent = items.length + " items · " + systems.size + " systems · inspect & create ATC ticket";
}
if (!items.length) {
list.innerHTML = '<p class="hint">No matching items in the current live snapshot.</p>';
$("#triage-detail").innerHTML = '<p class="hint">Nothing selected.</p>';
triageSelected = null;
return;
}
const groups = new Map();
for (const it of items) {
const g = it.title || "Unknown";
if (!groups.has(g)) groups.set(g, []);
groups.get(g).push(it);
}
let html = "";
for (const [sys, rows] of groups) {
html += '<div class="triage-group-label">' + escape(sys) + " · " + rows.length + "</div>";
for (const it of rows) {
const sev = String(it.severity || "").toLowerCase();
const cls = sev.includes("crit") ? "critical" : sev.includes("warn") ? "warning" : "info";
const active = triageSelected && triageSelected.id === it.id ? " active" : "";
html +=
'<button type="button" class="triage-item ' +
cls +
active +
'" data-triage-id="' +
escape(it.id) +
'"><div class="ti-top"><span>' +
escape(it.severity || it.kind) +
"</span><span>" +
escape((it.raw && it.raw.ip) || "") +
'</span></div><div class="ti-msg">' +
escape((it.message || "").slice(0, 160)) +
'</div><div class="ti-meta">' +
escape(it.meta) +
"</div></button>";
}
}
list.innerHTML = html;
let pick = items[0];
if (opts.alertId) pick = items.find((i) => String(i.alertId) === String(opts.alertId)) || pick;
else if (opts.deviceId) pick = items.find((i) => String(i.deviceId) === String(opts.deviceId)) || pick;
selectTriageItem(pick);
}
function selectTriageItem(it) {
triageSelected = it;
$("#triage-list")?.querySelectorAll(".triage-item").forEach((el) => {
el.classList.toggle("active", el.dataset.triageId === (it && it.id));
});
const detail = $("#triage-detail");
if (!detail || !it) return;
const node = deviceById(it.deviceId);
const a = it.kind === "alert" ? it.raw : null;
const connected = node && node.connected ? "CONNECTED" : "OFFLINE / unknown";
const watts = node && node.watts != null ? Math.round(node.watts) + " W" : "n/a";
detail.innerHTML =
"<h3>" +
escape(it.title) +
'</h3><div class="badge-row"><span class="badge">' +
escape(it.severity || it.kind) +
'</span><span class="badge ' +
(node && node.connected ? "on" : "off") +
'">' +
connected +
"</span>" +
(node && node.watts != null ? '<span class="badge power">' + watts + "</span>" : "") +
'</div><div class="kv">' +
'<div class="kv-row"><span class="k">System</span><span class="v">' +
escape(it.title) +
"</span></div>" +
'<div class="kv-row"><span class="k">IP</span><span class="v">' +
escape((node && node.ip) || (a && a.ip) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Model</span><span class="v">' +
escape((node && node.model) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Service tag</span><span class="v">' +
escape((node && node.service_tag) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Subnet</span><span class="v">' +
escape((node && node.subnet) || "—") +
"</span></div>" +
'<div class="kv-row"><span class="k">Message</span><span class="v">' +
escape(it.message || "—") +
"</span></div>" +
(a && a.action
? '<div class="kv-row"><span class="k">Recommended</span><span class="v">' + escape(a.action) + "</span></div>"
: "") +
'<div class="kv-row"><span class="k">Meta</span><span class="v">' +
escape(it.meta) +
"</span></div></div>" +
'<div class="triage-actions">' +
'<button type="button" class="btn primary" id="triage-ticket">Create ticket</button>' +
'<button type="button" class="btn" id="triage-focus">Focus on map</button>' +
'<button type="button" class="btn conn-link" id="triage-connect"' +
(node ? "" : " disabled") +
">Quick Connect</button>" +
'<button type="button" class="btn" id="triage-ops">Open Ops desk</button>' +
'<button type="button" class="btn" id="triage-ai">Ask Cockpit chat</button>' +
'<button type="button" class="btn ghost" id="triage-inspect">Inspector</button></div>';
$("#triage-ticket")?.addEventListener("click", () => createTicketFromTriage(it, node));
$("#triage-focus")?.addEventListener("click", () => {
if (it.deviceId != null) window.cockpit?.focusDeviceId?.(it.deviceId);
closeTriage();
});
$("#triage-connect")?.addEventListener("click", () => {
if (!node) return;
closeTriage();
window.cockpit?.openConnect?.(node);
});
$("#triage-ops")?.addEventListener("click", async () => {
closeTriage();
openDrawer("#ops-drawer");
await loadTickets();
});
$("#triage-ai")?.addEventListener("click", () => {
closeTriage();
openDrawer("#chat-drawer");
sendChat(
"Triage this " +
(it.severity || it.kind) +
" on " +
it.title +
" (" +
((node && node.ip) || (a && a.ip) || "no-ip") +
"): " +
it.message +
". Suggest next steps for ATC admins Jody and Laurens."
);
});
$("#triage-inspect")?.addEventListener("click", () => {
if (node) window.cockpit?.showInspector?.(node);
closeTriage();
});
}
async function createTicketFromTriage(it, node) {
const me = actor();
const other = me === "jody" ? "laurens" : "jody";
const sev = it.severity || it.kind || "alert";
const title = ("[" + sev + "] " + it.title).slice(0, 180);
const body = [
"Auto-triage from OpenManage Cockpit",
"Reporter: " + adminName(me),
"Assignee: " + adminName(other),
"System: " + it.title,
"IP: " + ((node && node.ip) || (it.raw && it.raw.ip) || "—"),
"Model: " + ((node && node.model) || "—"),
"Service tag: " + ((node && node.service_tag) || "—"),
"Subnet: " + ((node && node.subnet) || "—"),
"Severity: " + sev,
"Message: " + it.message,
it.meta ? "Meta: " + it.meta : "",
it.raw && it.raw.action ? "Recommended: " + it.raw.action : "",
]
.filter(Boolean)
.join("\n");
closeTriage();
openTicketModal({
title,
body,
assignee: other,
priority: String(sev).toLowerCase().includes("crit") ? "critical" : "high",
});
}
function wire() {
$("#ops-actor")?.addEventListener("change", (e) => {
localStorage.setItem("atc_actor", e.target.value);
});
const saved = localStorage.getItem("atc_actor");
if (saved && $("#ops-actor")) $("#ops-actor").value = saved;
if (window.cockpit) window.cockpit.openTriage = openTriage;
else window.cockpit = { openTriage };
$("#btn-triage-close")?.addEventListener("click", closeTriage);
$("#triage-tabs")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-triage]");
if (!btn) return;
openTriage(btn.dataset.triage);
});
$("#triage-search")?.addEventListener("input", (e) => {
triageFilter = e.target.value || "";
renderTriageList();
});
$("#triage-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-triage-id]");
if (!btn) return;
const it = triageItems(triageMode).find((x) => x.id === btn.dataset.triageId);
if (it) selectTriageItem(it);
});
$("#btn-chat")?.addEventListener("click", () => {
openDrawer("#chat-drawer");
loadChatModels();
if (!$("#chat-body")?.children.length) {
appendChat(
"assistant",
"Cockpit Copilot online. Pick a model above (same catalog as OpenManage AI), then ask about fleet health, alerts, power, or GPUs."
);
}
});
$("#chat-model")?.addEventListener("change", (e) => {
localStorage.setItem("cockpit_chat_model", e.target.value);
chatHistory.length = 0;
const body = $("#chat-body");
if (body) body.innerHTML = "";
appendChat("assistant", `Model switched to ${e.target.value}. Context cleared.`);
});
$("#btn-chat-close")?.addEventListener("click", closeDrawers);
$("#btn-ops")?.addEventListener("click", async () => {
openDrawer("#ops-drawer");
await loadTickets();
});
$("#btn-ops-close")?.addEventListener("click", closeDrawers);
$("#ops-filter")?.addEventListener("change", () => loadTickets());
$("#btn-ticket-modal-close")?.addEventListener("click", closeTicketModal);
$("#btn-ticket-cancel")?.addEventListener("click", closeTicketModal);
$("#ticket-form")?.addEventListener("submit", async (e) => {
e.preventDefault();
const title = $("#ticket-title")?.value?.trim();
const body = $("#ticket-body")?.value?.trim();
if (!title || !body) return;
try {
const created = await api.post("/api/tickets", {
title,
body,
created_by: actor(),
assignee: $("#ticket-assignee").value,
priority: $("#ticket-priority").value,
});
closeTicketModal();
openDrawer("#ops-drawer");
await loadTickets();
await openTicket(created.id);
} catch (err) {
alert("Ticket create failed: " + err.message);
}
});
$("#chat-quick")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-q]");
if (!btn) return;
sendChat(btn.dataset.q);
});
window.addEventListener("cockpit-snapshot", () => refreshChatContext());
$("#btn-new-ticket")?.addEventListener("click", () => newTicket().catch((e) => alert(e.message)));
$("#ticket-list")?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-ticket]");
if (!btn) return;
openTicket(Number(btn.dataset.ticket)).catch((err) => alert(err.message));
});
$("#chat-form")?.addEventListener("submit", (e) => {
e.preventDefault();
const input = $("#chat-input");
const msg = input?.value?.trim();
if (!msg) return;
input.value = "";
sendChat(msg);
});
// Extend scrim close
const scrim = $("#scrim");
scrim?.addEventListener("click", () => {
const mode = scrim.dataset.mode;
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer") closeDrawers();
});
initFeedDrag();
hookGpuFromFleet();
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire);
else wire();
})();
+287
View File
@@ -0,0 +1,287 @@
(() => {
const $ = (sel) => document.querySelector(sel);
let term = null;
let fitAddon = null;
let socket = null;
let connected = false;
let connectInFlight = false;
let currentHost = "";
function protoWs() {
return location.protocol === "https:" ? "wss" : "ws";
}
function rememberUser(user) {
if (user) localStorage.setItem("cockpit_ssh_user", user);
}
function rememberedUser() {
return localStorage.getItem("cockpit_ssh_user") || "";
}
function openSshModal(opts = {}) {
const modal = $("#ssh-modal");
const scrim = $("#scrim");
if (!modal) return;
currentHost = opts.host || "";
$("#ssh-host").value = currentHost;
$("#ssh-port").value = opts.port || 22;
$("#ssh-user").value = opts.username || rememberedUser();
$("#ssh-pass").value = "";
$("#ssh-device").textContent = opts.name || currentHost || "SSH session";
$("#ssh-sub").textContent = opts.model
? `${opts.model}${opts.ip ? " · " + opts.ip : ""}`
: opts.ip || currentHost || "—";
$("#ssh-status").textContent = "Enter username and password, then Connect.";
$("#ssh-status").className = "ssh-status";
$("#ssh-connect-btn").disabled = false;
$("#ssh-term-wrap").classList.add("hidden");
$("#ssh-login").classList.remove("hidden");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
scrim?.classList.add("open");
if (scrim) scrim.dataset.mode = "ssh";
setTimeout(() => {
const u = $("#ssh-user");
if (u && !u.value) u.focus();
else $("#ssh-pass")?.focus();
}, 50);
}
function closeSshModal() {
disconnectSsh();
const modal = $("#ssh-modal");
modal?.classList.add("hidden");
modal?.setAttribute("aria-hidden", "true");
const scrim = $("#scrim");
if (scrim?.dataset.mode === "ssh") {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
}
function setStatus(msg, kind) {
const el = $("#ssh-status");
if (!el) return;
el.textContent = msg;
el.className = "ssh-status" + (kind ? " " + kind : "");
}
function ensureTerminal() {
if (term) return term;
if (!window.Terminal) {
setStatus("Terminal library missing — hard refresh (Ctrl+Shift+R) or check /vendor/xterm/", "error");
$("#ssh-login")?.classList.remove("hidden");
return null;
}
term = new window.Terminal({
cursorBlink: true,
fontFamily: "IBM Plex Mono, ui-monospace, Menlo, monospace",
fontSize: 13,
lineHeight: 1.2,
theme: {
background: "#071018",
foreground: "#e8f4ff",
cursor: "#3dffe0",
selectionBackground: "rgba(0,168,232,0.35)",
},
allowProposedApi: true,
});
const FitCtor = window.FitAddon?.FitAddon || window.FitAddon;
fitAddon = FitCtor ? new FitCtor() : null;
if (fitAddon) term.loadAddon(fitAddon);
term.open($("#ssh-term"));
term.onData((data) => {
if (!socket || socket.readyState !== WebSocket.OPEN || !connected) return;
socket.send(data);
});
window.addEventListener("resize", () => {
try {
fitAddon?.fit();
sendResize();
} catch (_) {}
});
return term;
}
function sendResize() {
if (!socket || socket.readyState !== WebSocket.OPEN || !term) return;
socket.send(
JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
})
);
}
function disconnectSsh() {
connected = false;
try {
socket?.close();
} catch (_) {}
socket = null;
try {
term?.reset();
} catch (_) {}
}
async function connectSsh() {
if (connectInFlight) return;
const host = ($("#ssh-host")?.value || "").trim();
const username = ($("#ssh-user")?.value || "").trim();
const password = $("#ssh-pass")?.value || "";
const port = Number($("#ssh-port")?.value || 22);
if (!host || !username) {
setStatus("Host and username are required", "error");
return;
}
if (!password) {
setStatus("Password is required", "error");
$("#ssh-pass")?.focus();
return;
}
rememberUser(username);
connectInFlight = true;
disconnectSsh();
$("#ssh-login").classList.add("hidden");
$("#ssh-term-wrap").classList.remove("hidden");
const t = ensureTerminal();
if (!t) return;
t.reset();
t.focus();
try {
fitAddon?.fit();
} catch (_) {}
setStatus(`Connecting as ${username}@${host}:${port}`, "info");
$("#ssh-connect-btn").disabled = true;
const ws = new WebSocket(`${protoWs()}://${location.host}/ws/ssh`);
socket = ws;
ws.onopen = () => {
ws.send(
JSON.stringify({
type: "auth",
host,
port,
username,
password,
cols: t.cols || 120,
rows: t.rows || 36,
term: "xterm-256color",
})
);
// clear password from DOM after send
if ($("#ssh-pass")) $("#ssh-pass").value = "";
};
ws.onmessage = (ev) => {
const data = String(ev.data || "");
if (data.startsWith("{") && data.includes('"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "ready") {
connected = true;
connectInFlight = false;
setStatus(msg.message || "Connected", "ok");
$("#ssh-connect-btn").disabled = false;
$("#ssh-connect-btn").textContent = "Reconnect";
try {
fitAddon?.fit();
sendResize();
t.focus();
} catch (_) {}
return;
}
if (msg.type === "status") {
setStatus(msg.message || "", "info");
return;
}
if (msg.type === "error") {
connected = false;
connectInFlight = false;
setStatus(msg.message || "SSH error", "error");
$("#ssh-connect-btn").disabled = false;
$("#ssh-login").classList.remove("hidden");
return;
}
if (msg.type === "pong") return;
} catch (_) {
// fall through as terminal output
}
}
t.write(data);
};
ws.onerror = () => {
setStatus("WebSocket error", "error");
$("#ssh-connect-btn").disabled = false;
$("#ssh-login").classList.remove("hidden");
};
ws.onclose = () => {
connected = false;
connectInFlight = false;
setStatus("Disconnected", "info");
$("#ssh-connect-btn").disabled = false;
$("#ssh-connect-btn").textContent = "Connect";
};
}
function openSshForNode(node) {
if (!node?.ip) {
alert("No management IP for this device");
return;
}
openSshModal({
host: node.ip,
ip: node.ip,
name: node.name,
model: node.model,
username: rememberedUser(),
});
}
function bind() {
$("#btn-ssh-close")?.addEventListener("click", closeSshModal);
$("#btn-ssh-disconnect")?.addEventListener("click", () => {
disconnectSsh();
$("#ssh-login").classList.remove("hidden");
setStatus("Disconnected. Enter credentials to reconnect.", "info");
});
$("#ssh-form")?.addEventListener("submit", (e) => {
e.preventDefault();
connectSsh();
});
$("#ssh-connect-btn")?.addEventListener("click", (e) => {
e.preventDefault();
connectSsh();
});
// Scrim close for SSH
$("#scrim")?.addEventListener("click", () => {
if ($("#scrim")?.dataset.mode === "ssh") closeSshModal();
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("#ssh-modal")?.classList.contains("hidden")) {
closeSshModal();
}
});
}
bind();
window.cockpitSsh = {
open: openSshForNode,
openModal: openSshModal,
close: closeSshModal,
};
})();
+1707
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
+209
View File
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
+8
View File
File diff suppressed because one or more lines are too long