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:
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.bak*
|
||||||
|
ui/ome-cockpit-*
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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`
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
+13
@@ -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
@@ -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 & 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>
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
})();
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+8
@@ -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
|
||||||
Vendored
+209
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+8
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user