diff --git a/.env.example b/.env.example index 8936f71c..be7e651c 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,15 @@ PORT=5555 +# Listen address. Loopback by default (the dashboard exposes SSH + power controls); +# set to the host LAN IP or 0.0.0.0 to allow access from another machine. +BIND_HOST=127.0.0.1 LLM_PORT=8888 POLL_INTERVAL_GPU=2000 POLL_INTERVAL_CPU=2000 POLL_INTERVAL_NETWORK=2000 POLL_INTERVAL_STORAGE=5000 POLL_INTERVAL_LLM=2000 -POLL_INTERVAL_BANDWIDTH=1000 \ No newline at end of file +POLL_INTERVAL_BANDWIDTH=1000 +# Optional: ssh -i path inside the container when the key is not a default OpenSSH name +# SSH_IDENTITY_FILE=/root/.ssh/id_ed25519 +# Reuse authenticated SSH transports for remote polling. Set to 0 to disable. +SSH_CONTROL_PERSIST_SECONDS=60 diff --git a/.gitignore b/.gitignore index 638b8b63..4478cdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,11 @@ sparks.json config/sparks-secrets.json config/.secrets-key config/bench-history.json +config/bench-active.json +config/prefill-bench-history.json +config/prefill-bench-active.json config/showcase-history.json +config/llm-daily.json config/gpu-memory.json config/gpu-memory.json.* config/bench-exports/ @@ -19,5 +23,6 @@ PLAN.md tetris.html .zcode/ HANDOFF.md +HANDOFF-HERMES.md handoff.md test/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d12e5bc..a21f9a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,138 @@ Format: version sections are listed newest first. --- +## [Unreleased] + +### Added +- **Hide worker nodes** — Settings toggle. Worker-role Sparks drop off Overview cards and the tab bar (the open worker tab stays). Direct URLs and batch Wake / Shutdown / Hermes still include them. +- **On-demand Remote bench** — a **Remote** button next to decode/prefill opens a host + port (HTTPS) field. Paste a Tailscale URL such as `https://name.ts.net/v1/models`; nothing is probed until you run Decode or Prefill against it. +- **Decode / prefill benches on remote Sparks** — if the remote LLM is not reachable on its LAN IP (loopback-only bind), sparkDash opens an SSH local-forward to `127.0.0.1:` for the job. Bench buttons stay on the LLM card even when the live probe shows no model. + +### Fixed +- **Remote SSH session churn** — collectors reuse an authenticated SSH transport instead of creating a full SSH/PAM login for every metric poll. Failed transports invalidate readiness so subsequent callers share a recovery probe. `SSH_CONTROL_PERSIST_SECONDS=0` restores one connection per command if needed. +- **Prefill bench still dying at ~5 min** — Node undici aborts streams with no headers/body after 300s. Long prefills now use an Agent with those idle timeouts disabled; the per-size AbortSignal remains the bound. + +--- + +## [1.8.6] — 2026-09-01 + +### Added +- **Prefill benchmark** — sequential context-size sweep (1k–300k) measuring prefill tok/s (`prompt_tokens` ÷ TTFT) and TTFT. Unique prefix per size so prefix-cache does not inflate later runs. Button on the LLM card; persisted last run. Per-size timeout scales with context (90s floor, ~8 ms/token, 45 min cap). +- **DGX Spark CPU temperature** — remote Sparks collect CPU temp over SSH with the same hwmon allowlist as hosts (`acpitz` / `coretemp` / `k10temp` / `zenpower`; NVMe / CX7 filtered out). Overview shows a CPU bar and Spark pages show a CPU row on the GPU panel when the reading is above 0°C. + +### Fixed +- **Prefill bench 256k timeout** — per-size cap was 12 minutes (`~3 ms/token`); slow prefills aborted before first token. Now ~8 ms/token with a 45 minute cap, and the error names the limit. +- **Copy results tok/s** — clipboard text now uses one decimal like the decode-bench table (`31.5` not `31`), and TTFT uses the same mean as the table. ([#57](https://github.com/MiaAI-Lab/sparkDash/issues/57)) +- **SGLang log spam** — probe current `/server_info` and `/model_info` first; keep the deprecated `/get_*` aliases as fallback so old servers still work. ([#52](https://github.com/MiaAI-Lab/sparkDash/issues/52)) + +--- + +## [1.8.5] — 2026-08-28 + +### Fixed +- **Decode type picker defaults to Structured** — opening the sheet (or loading a previous run) no longer leaves Prose/Code/JSON selected. A still-running job still shows its type. +- **Code workload was prose-speed** — the LRU + "thorough comments" prompt is English with `def` sprinkled in, so DFlash2 accept matched Prose. Code is now `clamp_00`…`clamp_49` identical-shape Python helpers, no comments. + +--- + +## [1.8.4] — 2026-08-28 + +### Added +- **Decode benchmark type picker** — choose **Structured** (default, count 1→200), **Prose** (lab hash-map explanation), **Code** (fixed LRU-cache Python prompt), or **JSON** (GPU-metrics catalog) before Run. Labels are output types only — no `response_format`, grammars, or guided JSON. Same lab protocol for every type: temp 0, `top_p` 1, thinking off, 32-token warmup, default 400 tokens. The selected type is shown on results and in copied summaries. + +--- + +## [1.8.3] — 2026-08-28 + +### Changed +- **Decode benchmark uses the lab structured protocol** — count 1→200 (numbers only) instead of the Showcase JSON/YAML catalog + fill-to-max. Temperature **0**, `top_p` **1**, thinking **off**, 32-token warmup, default max tokens **400**. Concurrency 1 is the same prompt as glm-5.3-flash-sm120 `tests/bench_decode.py --structured`; concurrent streams get a unique suffix so they do not share a prefix-cache block. +- **Thinking flags default off** — GLM / Qwen / MiniMax think unless the request disables it. `applyThinkingFlags` now defaults to off and always sends `enable_thinking`, `thinking`, and `thinking_mode`. HTTP 400 retries keep an explicit off payload instead of stripping flags (stripping lets hybrid models think by default). Showcase treats a missing thinking flag as off. + +--- + +## [1.8.2] — 2026-08-23 + +### Added +- **EXL3 live tok/s** — detect ExLlamaV3 `tools/serve_openai.py` (`owned_by: exl3` or `/health` `{ok, busy}`) instead of mislabeling it as vLLM. Generation and prefill tok/s come from `/health` cumulative token counters (no Prometheus `/metrics`). +- **Tailnet monitoring** — opt-in per unit (`tailscaleMonitoring`, default **off**); `tailscale status --json` on the host and a Tailnet card under Resources. Flags a unit that is healthy on the LAN but off its tailnet. ([#43](https://github.com/MiaAI-Lab/sparkDash/pull/43)) +- **NV_ERR_NO_MEMORY on the GPU panel** — count of NVRM `NV_ERR_NO_MEMORY` kernel log lines since boot (shown when > 0). Journal is scanned at most once a minute, not on the 2s poll. Replaces the approach in [#40](https://github.com/MiaAI-Lab/sparkDash/pull/40). + +### Security +- **`BIND_HOST` now defaults to `127.0.0.1` (loopback) instead of `0.0.0.0`** — the dashboard is unauthenticated and can SSH into and power off Sparks, so it is no longer reachable on the LAN by default. Set `BIND_HOST` to the host's LAN IP (or `0.0.0.0`) to opt in to remote access. **Migration:** if you access sparkDash from another machine via bare-metal `npm start`, set `BIND_HOST` explicitly. Production and dev Compose both set `BIND_HOST=0.0.0.0` (`network_mode: host`). Startup now also warns when bound to a non-loopback address. ([#35](https://github.com/MiaAI-Lab/sparkDash/pull/35)) + +### Fixed +- Decode bench `POST /api/sparks/:id/llm/bench` rejects LLM ports that are not in the Spark's configured list (same allowlist as showcase). ([#45](https://github.com/MiaAI-Lab/sparkDash/pull/45)) +- **Host CPU temperature** — dedicated GPU hosts (`kind: host`) show CPU temp on the RAM panel and Overview (hidden at 0°C / no sensor). Remote hosts now read hwmon/thermal over SSH. DGX Sparks still do not display CPU temp (remote Sparks still skip the extra sensor SSH). ([#34](https://github.com/MiaAI-Lab/sparkDash/pull/34)) + +### Changed +- Docker Node base image pulls from `public.ecr.aws/docker/library/node` so Spark builds do not fail on Docker Hub IPv6 `auth.docker.io` / “network is unreachable”. `deploy.sh` prints that workaround if a build still fails. +- README architecture diagram top border aligned with the box. ([#39](https://github.com/MiaAI-Lab/sparkDash/pull/39)) + +--- + +## [1.8.1] — 2026-08-16 + +### Added +- **Daily LLM tok/s history** — busy-sample rollups (peak + mean) for decode and prefill, persisted in `config/llm-daily.json` (30 UTC days). 14-day peak chart on the LLM card; `GET /api/sparks/:id/llm/daily`. +- **Cached vs uncached prefill tok/s** — live rows when the backend splits kinds: ds4 labeled prefill counters, llama.cpp `/slots` `n_prompt_tokens_cache`, SGLang `sglang:cached_tokens_total` (L1 `cache_source="device"`). Combined Prefill stays computed/uncached. vLLM is unchanged (combined prefill + prefix-cache hit rate). + +### Changed +- **Docker SSH key auth** — compose comments + README: key auth runs inside the container (`/root/.ssh`), not the host user’s `~/.ssh`. Custom-named keys must be mounted as `id_ed25519` (or set `SSH_IDENTITY_FILE`). LAN IPs are from the sparkDash host. Add/Edit Spark hint when auth is Key. + +### Fixed +- SGLang `/metrics` no longer overwrites `/get_server_info` tok/s when both are present. +- llama.cpp `n_prompt_tokens_processed: 0` is not treated as missing (fully cached prompts). + +--- + +## [1.8.0] — 2026-08-15 + +### Added +- **Non-Spark unit support** (`kind: "host"`) — dedicated GPU hosts (any Linux box with an NVIDIA GPU, e.g. a workstation with an RTX card) are first-class units: added from the **+** button (choose **Dedicated GPU host**), monitored via SSH + `nvidia-smi` exactly like a Spark, but never labeled as a DGX Spark. +- **Detected host hardware** — for `kind: "host"`, the header shows real hardware detected once when online (GPU model, CUDA driver, CPU model/cores, system RAM) instead of fixed GB10 specs. +- **Separate system RAM vs discrete VRAM** — for host units, VRAM comes straight from `nvidia-smi` (`memory.used` / `memory.total`, free = total − used) while system RAM is read from `/proc/meminfo`. Spark behavior is unchanged (GB10 unified HBM pool). +- **RAM panel + Overview RAM bar** — host unit pages get a dedicated RAM panel, and Overview cards show a RAM bar under VRAM for hosts. +- **Host Resources layout** — host unit pages stack **RAM → Network → Storage** in the right column with **GPU** filling the left column (Sparks keep the original layout). CX7 IP is hidden for hosts (Spark-specific NIC). +- **Prefill tok/s** (moved from Unreleased) — live LLM panel sparkline, Overview cards as two columns (**tok/s** | **prefill**), decode-bench **Prefill** column (`prompt_tokens` ÷ TTFT). +- **Live prefill measurement** — vLLM uses engine-step `iteration_tokens_total` surplus over generation (so a short/cached prefill that lands in the same poll as the first decode tokens still counts); prompt/TTFT counters are the fallback because they often only move at first token. ds4 uses computed (not cached) prefill token diffs. Idle returns to 0. Opening a saved chat in the UI does not hit the GPU; prefill is the prompt/KV pass when you send or regenerate. + +### Changed +- **Decode benchmark matches Showcase structural** — same prompt catalog and fill-to-max shaping (`min_tokens` / `ignore_eos` / fill suffix); no 4k unique prefill prefix. Temperature **0**, thinking **off** (Showcase defaults are temp 0.7 and thinking off). Default max tokens 512. +- **Update Hermes button is now a permanent, neutral control** — no more toast notifications for Hermes updates. It turns warning-yellow and shows a commit-count badge **only when an update is actually available**; clicking it opens the update dialog (status / pending commits / release notes) as before. +- **Overview "Update Hermes" button** (formerly "Update All") follows the same rule — neutral by default, warning-yellow with a pending-count badge only when ≥1 monitored Spark has an update available. Pressing it now shows a **live progress bar** (x/y Sparks settled, driven by WS per-Spark update status) until every started update finishes. + +### Fixed +- **Decode benchmark "Benchmark not found" mid-run** — running jobs lived only in memory, so a `node --watch` / SIGTERM reload dropped them and the dialog poll hit 404. Active benches are now checkpointed to `config/bench-active.json`, finalized on shutdown, and recovered as interrupted on boot; the dialog also recovers via the list endpoint instead of showing a bare 404. + +### Removed +- **Toast system** (`useToasts.ts`, `useHermesAlerts.ts`, `components/ui/Toaster.tsx`) — Hermes notifications now live entirely on the header button instead of pop-up toasts. + +--- + +## [1.7.0] — 2026-08-08 + +### Added +- **Hermes Agent service per Spark** — opt-in `hermesMonitoring` toggle in Edit Spark; when on, sparkDash treats the Hermes Agent CLI (nousresearch/hermes-agent) as installed on that machine +- **Update notifications** — background `hermes update --check` poll (10 min) per monitored Spark; a toast alerts when an update is available +- **One-click update** — `Update Hermes` button in the Spark header and in the update alert toast; runs `hermes update` over SSH (non-interactive), with running/success/error state streamed over WS +- **Hermes status in snapshot** — installed / version / updateAvailable / behindCommits / checkedAt / job status per Spark (`snapshot.hermes`) +- **Toast system** — minimal built-in toast store + Toaster component (no new dependency), reused for Hermes alerts +- **Update confirmation dialog with real content** — clicking Update Hermes (header button or alert toast) opens a modal with **Update now** / **Cancel**. When the update is only commits on `main` (no newer tagged release than what is installed), it shows the **actual pending commits** from git (`HEAD..origin/main`) instead of the latest-release changelog — the full release changelog is shown only when a real version bump exists +- **`GET /api/sparks/:id/hermes/updates`** — update preview: latest release (cached) + installed version + **real pending commits** from git on the Spark + a resolved view; the old `/api/hermes/releases/latest` is superseded +- **Update All** — `POST /api/sparks/hermes/update-all` + Overview button runs `hermes update` on every Spark with Hermes Agent enabled (per-spark start/skip/fail summary; per-spark progress still streamed over WS) +- **`POST /api/sparks/:id/hermes/check`** (force check now) and **`POST /api/sparks/:id/hermes/update`** (background job, 202) + +### Changed +- `hermesMonitoring` normalized in Spark config; server boots HermesProbe only when enabled (all roles, local + remote) +- Toast stack renders above modals at `z-index: 10000` + +### Fixed +- **Local Spark Hermes runs as the wrong user (root), corrupting the install** — hermes + its git repo belong to the host user, but the local path executed hermes as the container root. That produced git "dubious ownership" failures and, once worked around, wrote root-owned files into the user's tree (tools/*.py, uv.lock, …) and ran `uv pip install` as root — which failed and left `venv/bin/hermes` missing, breaking the `hermes` CLI entirely. The local path now resolves the host user from the host passwd bind mount and drops to that user via `setpriv` (`nsenter` + host mount ns so host git is visible), with a self-healing root-owned-file repair step. Remote SSH already ran as the real user. +- **Broken launcher detection + auto-repair** — when the `hermes` launcher exists but cannot execute (e.g. missing venv entry point), sparkDash now reports "broken install" instead of a false "no update" and the one-click update automatically rebuilds the venv entry point (`uv pip install -e .`), then retries. +- **Stale git lock bricks later updates** — an interrupted `hermes update` can leave `.git/shallow.lock` (or any `*.lock`) behind, making every later fetch fail; leftover `*.lock` files are cleared before each check/update. + +--- + ## [1.6.0] — 2026-08-07 ### Added diff --git a/Dockerfile b/Dockerfile index c4da5af3..b7e64a7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,11 @@ # Dockerfile for arm64 (DGX Spark GB10 platform) # ============================================================ -FROM node:22-bookworm-slim AS builder +# library/node via public.ecr.aws — Docker Hub (docker.io) often resolves +# to IPv6; Sparks with no IPv6 route fail auth.docker.io with +# "network is unreachable". ECR public is the same official image, IPv4-first. +ARG NODE_IMAGE=public.ecr.aws/docker/library/node:22-bookworm-slim +FROM ${NODE_IMAGE} AS builder WORKDIR /app @@ -18,7 +22,11 @@ COPY package.json package-lock.json* ./ RUN npm ci --no-audit --no-fund \ || (echo "npm ci failed once — retrying…" && npm cache clean --force && npm ci --no-audit --no-fund) -# Copy source and build +# Copy source and build. VITE_HISTORY_HOURS sets the frontend metrics-history +# retention window (see src/hooks/metricsStore.ts); override via +# `docker compose build --build-arg VITE_HISTORY_HOURS=4` or the env in compose. +ARG VITE_HISTORY_HOURS=8 +ENV VITE_HISTORY_HOURS=${VITE_HISTORY_HOURS} COPY . . RUN npm run build @@ -31,7 +39,7 @@ RUN npm prune --omit=dev --no-audit --no-fund \ # ============================================================ # Production image — lean runtime # ============================================================ -FROM node:22-bookworm-slim +FROM ${NODE_IMAGE} # SSH client + sshpass for remote Sparks; util-linux provides nsenter for host GPU/net RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -46,6 +54,8 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json COPY --from=builder /app/package-lock.json ./package-lock.json COPY --from=builder /app/server ./server +COPY --from=builder /app/src/shared ./src/shared +COPY --from=builder /app/src/components/ShowcasePage/showcasePrompts.ts ./src/components/ShowcasePage/showcasePrompts.ts COPY --from=builder /app/config ./config # Volume for persistent sparks.json diff --git a/Dockerfile.dev b/Dockerfile.dev index 848f20a5..4142bb2f 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -2,7 +2,9 @@ # sparkDash — Development Dockerfile with live reload # ============================================================ -FROM node:22-bookworm-slim +# See Dockerfile: avoid docker.io IPv6 auth failures on Sparks. +ARG NODE_IMAGE=public.ecr.aws/docker/library/node:22-bookworm-slim +FROM ${NODE_IMAGE} WORKDIR /app diff --git a/README.md b/README.md index 53865cad..a2408cba 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ sparkDash is a real-time web dashboard for one or more **NVIDIA DGX Spark (GB10)** machines in a single browser window. It streams GPU, CPU, unified memory, storage, network, and local LLM metrics — and lets you add, edit, reorder, or remove Sparks from the UI without restarts or code changes. +It also supports **non-Spark units**: any Linux machine with an NVIDIA GPU (e.g. a workstation with a dedicated RTX/L-series card) can be added as a **dedicated GPU host** and monitored the same way via SSH and `nvidia-smi`. For these units the dashboard correctly separates **RAM** (system memory) from **VRAM** (discrete GPU memory). + sparkDash Overview page with multiple DGX Spark units, GPU metrics, and LLM status ### LLM Prompt Showcase @@ -30,6 +32,8 @@ sparkDash is a real-time web dashboard for one or more **NVIDIA DGX Spark (GB10) - [Latest version changelog](#latest-version-changelog) - [Features](#features) - [ComfyUI monitoring](#comfyui-monitoring) +- [Hermes Agent monitoring](#hermes-agent-monitoring) +- [Tailnet monitoring](#tailnet-monitoring) - [Full changelog](./CHANGELOG.md) - [Quick start](#quick-start) - [Architecture](#architecture) @@ -47,11 +51,9 @@ sparkDash is a real-time web dashboard for one or more **NVIDIA DGX Spark (GB10) ## Latest version changelog -### Version 1.6.0 — ComfyUI monitoring & compact default -- **ComfyUI** — opt-in per Spark (port 8188): live jobs, progress, last run, cancel, queue ETA, Open on LAN IP, model inventory -- **Overview** — Comfy status chip (`idle` / `run` / `Nq`) -- **Layout** — collapsible Resources / Services; LLM + Comfy side-by-side; multi-LLM row rules -- **Compact UI** — default density (comfortable still in Settings) +### Version 1.8.6 — prefill benchmark +- **Prefill benchmark** on the LLM card: sweep context sizes from 1k to 300k, report prefill tok/s (`prompt_tokens` ÷ TTFT) and TTFT. Unique prefix per size so prefix-cache does not inflate later runs. Timeouts scale with size (up to 45 min). +- **CPU temperature** on remote Sparks (Overview bar + GPU panel row when above 0°C). Full history: [CHANGELOG.md](./CHANGELOG.md) @@ -61,20 +63,24 @@ Full history: [CHANGELOG.md](./CHANGELOG.md) | Area | What you get | |------|----------------| -| **Multi-unit** | Any number of Sparks; each has a tabbed detail page plus a shared Overview | +| **Multi-unit** | Any number of units; each has a tabbed detail page plus a shared Overview | +| **Non-Spark GPU hosts** | Linux boxes with a dedicated NVIDIA GPU are first-class units: same `nvidia-smi` collectors over SSH, detected hardware summary, and separate **RAM** / **VRAM** panels. Detail page: GPU (left) + **RAM → Network → Storage** (right column); Overview cards show RAM and VRAM bars | | **Live streaming** | WebSocket metrics with configurable poll intervals; central history store for sparklines across tab switches | | **Local + remote** | Host metrics via sysfs/proc/`nvidia-smi`; remotes over SSH (key or password) | -| **LLM probe** | Auto-detects llama.cpp, vLLM, sglang, or ds4-server; live tok/s per server | +| **LLM probe** | Auto-detects llama.cpp, vLLM, sglang, ds4-server, or EXL3; live decode/prefill tok/s; cached vs uncached prefill on ds4, llama.cpp, and SGLang; **daily peak** history on the LLM card | | **ComfyUI** | Opt-in probe: queue/jobs, progress, cancel, Open link, inventory, overview chip | -| **Decode benchmark** | Multi-concurrency streaming decode tok/s (server + per-stream), persisted last run | +| **Hermes Agent** | Opt-in per unit: background update check (10 min), status badges, one-click or batch `hermes update` | +| **Tailnet** | Opt-in probe: flags a unit that is healthy on the LAN but off its tailnet | +| **Decode benchmark** | Multi-concurrency streaming decode tok/s; type picker (Structured / Prose / Code / JSON); lab protocol (temp 0, thinking off); persisted last run. Remote units: LAN HTTP, or SSH tunnel to loopback. **Remote** button for an on-demand HTTPS/host:port target | +| **Prefill benchmark** | Context-size sweep (1k–300k) of prefill tok/s and TTFT; unique prefix per size; persisted last run. Same remote targeting as decode | | **Prompt Showcase** | Full-page multi-terminal LLM streaming demo (up to 32 prompts) with live tok/s and copy-out | | **vLLM health** | KV cache %, run/wait queue, TTFT/E2E/ITL p95, preemptions, prefix cache, MTP accept from Prometheus `/metrics` | | **Multiple LLM ports** | Monitor several LLM servers on different ports simultaneously — each gets its own panel with independent backend detection and metrics | | **GPU processes** | See the top GPU processes by VRAM usage directly in the GPU panel, including process name and memory allocation | | **Spark uptime** | System uptime displayed inline on each Spark header for at-a-glance availability | | **Power controls** | Graceful shutdown (SSH host script) and Wake-on-LAN; batch actions on Overview | -| **Spark roles** | **Head** / **Worker** / **Standalone** — worker label + head link; standalone can disable LLM monitoring | -| **Unified memory** | GB10 128 GB LPDDR5X pool (~273 GB/s), GPU/CPU split, bandwidth via `nvidia-smi dmon` | +| **Spark roles** | **Head** / **Worker** / **Standalone** — worker label + head link; standalone can disable LLM monitoring; optional hide workers from Overview and tabs | +| **Unified memory** | GB10 128 GB LPDDR5X pool (~273 GB/s), GPU/CPU split, bandwidth via `nvidia-smi dmon`. Non-Spark hosts show discrete **VRAM** (nvidia-smi) and system **RAM** separately | | **Themes** | Dark, light, cool white, OLED — neutral palettes, persisted in `localStorage` | | **Secrets** | SSH passwords AES-256-GCM encrypted; never in `sparks.json` or API responses | | **Docker-first** | Single privileged container for host metrics; prod and dev Compose files | @@ -140,6 +146,93 @@ Env (optional): `COMFY_PORT` (default `8188`), `COMFY_PROBE_TIMEOUT_MS`, `POLL_I --- +## Hermes Agent monitoring + +sparkDash can **optionally** monitor [Hermes Agent](https://github.com/nousresearch/hermes-agent) (nousresearch/hermes-agent) on each unit and run one-click updates for you over SSH. + +### What is supported + +| Capability | Details | +|------------|---------| +| **Opt-in per Spark** | `hermesMonitoring` (default **off**) in **Edit Spark** | +| **Auto update check** | Background `hermes update --check` over SSH (default every 10 min) — returns update availability + pending commits | +| **Status badges** | In the Spark header: `Hermes` (installed version), `Hermes not found` if the binary is missing | +| **One-click update** | **Update Hermes** button opens a dialog with live status, real pending commits, and release notes; **Update now** runs `hermes update` via SSH (non-interactive go) | +| **Update state** | Running / success / error surfaced live (button turns into a “Hermes updating… / failed” state) | +| **Batch update** | **Update Hermes** on Overview runs `hermes update` on every monitored unit, with a live per-unit progress bar | + +### How to enable (per Spark) + +1. Open the Spark tab → **Edit** (pencil). +2. Enable **Hermes Agent**. +3. **Save** — background checks start immediately. + +The **Update Hermes** button appears in the Spark header/mobile action row; it turns warning-yellow with a commit-count badge only when an update is actually available. It also appears on Overview (batch) when at least one unit has Hermes enabled. + +**Connectivity check note:** local units run the check as the **host user** (via `setpriv`/`nsenter`, never as container root); remote units run it over SSH. Either way, the logged-in user needs permission to read the Hermes repo. + +### Side requirements + +- **Hermes Agent must be installed on the target machine** — sparkDash only checks & updates; it does not install it. The binary is looked up in `~/.local/bin` and `/usr/local/bin`. +- SSH user must be able to run `hermes update --check` / `hermes update` non-interactively (key auth recommended). +- An update can take a few minutes (repo pull + dependency reinstall); a stale `*.lock` file from a crashed run is cleared before each attempt. + +### Config fields (persisted on the Spark) + +| Field | Default | Description | +|-------|---------|-------------| +| `hermesMonitoring` | `false` | Check/update Hermes Agent on this machine | + +### Related API + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/api/sparks/hermes/update-all` | Batch `hermes update` on every monitored Spark (Overview button) | +| POST | `/api/sparks/:id/hermes/check` | Force `hermes update --check` now | +| POST | `/api/sparks/:id/hermes/update` | Run `hermes update` in the background (202) | +| GET | `/api/sparks/:id/hermes/updates` | Update preview: latest release + installed version + real pending commits + resolved view | + +Env (optional): `POLL_INTERVAL_HERMES` (default `600000` ms), `HERMES_UPDATE_TIMEOUT_MS` (default `600000` ms). + +--- + +## Tailnet monitoring + +Opt-in per unit (default **off**). Runs `tailscale status --json` on the host and shows a **Tailnet** card under Resources. + +This closes a blind spot every LAN-based check shares, including sparkDash's own SSH liveness. When `tailscaled` loses its session with the coordination server, SSH/GPU/LLM can all stay healthy while the box is unreachable from off-LAN. + +### What is supported + +| Capability | Details | +|------------|---------| +| **Opt-in per unit** | `tailscaleMonitoring` (default **off**) in **Edit Spark** | +| **Off-tailnet detection** | `Self.Online` — the node's *own* view of the coordination server | +| **Reason, not just state** | Tailscale `Health` messages, backend state, tailnet IP, DERP relay, version, expired-key warning | + +Asked of **each node about itself**. Peer state is never the verdict. The probe is read-only (`tailscale up` / `down` / `login` are never run). + +### How to enable + +1. Open **Edit Spark**. +2. Tick **Tailnet monitoring**. +3. Save. The Tailnet card appears under Resources. + +### Host requirements + +- `tailscale` CLI on the monitored host, and `tailscaled` running. +- Remote units: existing SSH. Local Docker: `nsenter` into the host mount namespace (same as `nvidia-smi`; `/host/proc` is already bind-mounted). + +### Config fields + +| Field | Default | Description | +|-------|---------|-------------| +| `tailscaleMonitoring` | `false` | Run `tailscale status --json` and show the Tailnet card | + +Env (optional): `POLL_INTERVAL_TAILSCALE` (default `30000`), `TAILSCALE_PROBE_TIMEOUT_MS` (default `8000`). + +--- + ## Quick start ```bash @@ -162,14 +255,22 @@ For development with Docker (source-mounted, HMR): docker compose -f docker-compose.dev.yml up --build ``` +**Remote units + SSH keys (Docker):** SSH is executed *inside* the container on the sparkDash host (typically the head DGX). Configured LAN IPs are from **that** host’s point of view, not your laptop. OpenSSH looks for keys under `/root/.ssh` in the container — the host user’s `~/.ssh` is not used unless you bind-mount it. Uncomment this volume in `docker-compose.yml` (and recreate the container): + +```yaml +- ${HOME}/.ssh/id_ed25519:/root/.ssh/id_ed25519:ro +``` + +If the key file has a non-default name (e.g. `id_ed25519_shared`), mount it **as** `id_ed25519`, or set `SSH_IDENTITY_FILE` to the path inside the container. Keep the file mode `600`. The unit that runs sparkDash itself should be added with **This host (local collectors — no SSH for metrics)**. + --- ## Architecture -Design principle: **one Spark model, N instances**. Every Spark is a record in `config/sparks.json`. The same `SparkMonitor`, `SystemCollector`, and `LlmProbe` code runs for all of them. Adding a unit is a config change, not a code change. +Design principle: **one Spark model, N instances**. Every unit is a record in `config/sparks.json` with a `kind` field (`spark` or `host`). The same `SparkMonitor`, `SystemCollector`, and `LlmProbe` code runs for all of them. Adding a unit is a config change, not a code change. ```txt -┌────────────────────── Docker container (sparkDash) ──────────────────────┐ +┌────────────────────── Docker container (sparkDash) ────────────────────────┐ │ Express (server/) │ │ ├─ config/sparks.json Spark registry (API read/write) │ │ ├─ SparkRegistry load/persist Sparks; change events │ @@ -253,6 +354,9 @@ sparkDash/ | POST | `/api/sparks/:id/llm-ports` | Add an LLM port (hot) | | DELETE | `/api/sparks/:id/llm-ports/:port` | Remove an LLM port (hot) | | PUT | `/api/sparks/:id/llm-port` | LLM port — backward-compat (hot) | +| GET | `/api/sparks/:id/llm/daily` | Daily busy decode/prefill tok/s (`port`, `days`) | +| POST | `/api/sparks/:id/llm/bench` | Start decode benchmark (202); poll/cancel/clear on the same path | +| POST | `/api/sparks/:id/llm/prefill-bench` | Start prefill + TTFT context sweep (202); poll/cancel/clear on the same path | | GET | `/api/settings` | Global settings | | PUT | `/api/settings` | Update global settings | | WS | `/ws` | Real-time metrics stream | @@ -272,6 +376,7 @@ Gear icon in the header, or `GET`/`PUT` `/api/settings`: | Poll interval | 2000 ms | WebSocket broadcast interval (minimum 1000 ms) | | Default LLM port | 8888 | Default for new Sparks | | Auto-hide offline | false | Hide offline Sparks on Overview | +| Hide worker nodes | false | Hide Worker-role Sparks from Overview and the tab bar | | Temperature unit | Celsius | Display GPU temperature in °C or °F | ### Environment variables @@ -280,7 +385,7 @@ Copy `.env.example` to `.env` if needed: | Variable | Default | Description | |----------|---------|-------------| -| `BIND_HOST` | `0.0.0.0` | HTTP and WebSocket listen address | +| `BIND_HOST` | `127.0.0.1` | HTTP and WebSocket listen address. Loopback by default — the dashboard exposes SSH + power controls, so set a LAN IP (or `0.0.0.0`) to allow remote access. | | `PORT` | `5555` | HTTP + WebSocket listen port | | `LLM_PORT` | `8888` | Default LLM probe port | | `COMFY_PORT` | `8188` | Default ComfyUI probe port | @@ -291,21 +396,34 @@ Copy `.env.example` to `.env` if needed: | `POLL_INTERVAL_STORAGE` | `5000` | Storage poll (ms) | | `POLL_INTERVAL_LLM` | `2000` | LLM probe poll (ms) | | `POLL_INTERVAL_BANDWIDTH` | `2000` | Memory bandwidth / dmon poll (ms) | +| `POLL_INTERVAL_HERMES` | `600000` | Hermes Agent update check poll (ms) | +| `POLL_INTERVAL_TAILSCALE` | `30000` | Tailnet probe poll (ms) | +| `TAILSCALE_PROBE_TIMEOUT_MS` | `8000` | Timeout for `tailscale status --json` (ms) | +| `POLL_INTERVAL_NVERR` | `60000` | Kernel journal scan for NVRM `NV_ERR_NO_MEMORY` (ms) | +| `HERMES_UPDATE_TIMEOUT_MS` | `600000` | Hard timeout for running `hermes update` over SSH (ms) | | `POLL_INTERVAL_LIVENESS` | `5000` | Online/SSH liveness check (ms) | | `SPARKDASH_SECRETS_KEY` | _(auto)_ | Passphrase or 64-char hex for secret encryption | | `HOST_PROC_PATH` | `/host/proc` | Host proc mount inside container | | `HOST_SYS_PATH` | `/host/sys` | Host sys mount | | `HOST_ROOT_PATH` | `/host/root` | Host root mount | +| `SSH_IDENTITY_FILE` | _(unset)_ | Path **inside the process** to a private key (`ssh -i`). Use when the bind-mount is not a default OpenSSH name. | +| `SSH_CONTROL_PERSIST_SECONDS` | `60` | Reuse authenticated SSH transports for remote collectors. Set to `0` to disable multiplexing. | -> When using Docker's default bridge network, keep `BIND_HOST=0.0.0.0`. -> With `network_mode: host`, use `BIND_HOST=127.0.0.1` to restrict access to the local host or a reverse proxy. +> The listener defaults to `127.0.0.1` (loopback) so the dashboard — which can SSH into and +> power off your Sparks — isn't reachable on the LAN by default. Set `BIND_HOST` to the host's +> LAN IP (or `0.0.0.0`) to reach it from another machine. The provided `docker-compose.yml` +> (`network_mode: host`) sets `BIND_HOST=0.0.0.0` explicitly (prod and `docker-compose.dev.yml`); restrict access at the network +> layer, or set `127.0.0.1` when running behind a reverse proxy. -### Adding a Spark +### Adding a unit 1. Open the **+** tab. -2. Set **Name**, **LAN IP** (required), optional **CX7 IP**, **SSH user**, and auth (key or password). Wake-on-LAN MAC is auto-read from **enP7s7** when online (optional override in Edit). -3. **Test Connection** for SSH + LLM reachability. -4. Save — a tab appears and metrics start streaming. +2. Choose **Unit type**: + - **NVIDIA DGX Spark** — the default; hardware summary shows DGX Spark specs and the CX7 IP field is available. + - **Dedicated GPU host** — any Linux machine with an NVIDIA GPU. It is monitored exactly like a Spark (SSH + `nvidia-smi`) but is **not** reported as a DGX Spark: the header shows a detected hardware summary (GPU model, CPU, RAM) instead of fixed GB10 specs, and the page shows separate **RAM** and **VRAM** panels (VRAM from `nvidia-smi`, RAM from system memory). On the unit page, RAM → Network → Storage stack in the right column with GPU filling the left column. +3. Set **Name**, **LAN IP** (required), optional **CX7 IP** (Sparks only), **SSH user**, and auth (key or password). LAN IP is probed from the sparkDash host. Key auth in Docker needs a key mounted into the container (see Quick start). Wake-on-LAN MAC is auto-read from **enP7s7** when online (optional override in Edit). +4. **Test Connection** for SSH + LLM reachability. +5. Save — a tab appears and metrics start streaming. ### Power controls (shutdown / Wake-on-LAN) @@ -338,7 +456,7 @@ Choice is stored in `localStorage`. - Encryption key: `config/.secrets-key` (auto-generated) or `SPARKDASH_SECRETS_KEY`. **Do not delete the key file** or encrypted secrets become unreadable. - **Target validation** rejects clearly unsafe IPv4 targets (link-local `169.254.0.0/16`, `0.0.0.0/8`, multicast/reserved ≥ 224). Private, loopback, and public addresses are allowed so LAN and remote Sparks work. - SSH and HTTP probes use short timeouts (about 5 s SSH connect, 3 s HTTP) so a hung host cannot stall the poll loop. -- Prefer **SSH keys** over passwords. +- Prefer **SSH keys** over passwords. In Docker, mount the private key into `/root/.ssh` (see Quick start); passwords are the only SSH secret the app stores itself. - Treat the dashboard as **LAN-trusted**: the API is intentionally unauthenticated for ease of use on a private network. That includes **power APIs** (shutdown / Wake-on-LAN): anyone who can reach the dashboard can request fleet power actions. @@ -367,7 +485,7 @@ Choice is stored in `localStorage`. ### Local vs remote Sparks -One `SystemCollector` path for both modes. When `spark.isLocal` is true, metrics come from host sysfs/proc and `nvidia-smi` (often via nsenter into the host namespace). Remote Sparks wrap the same commands in a shared `sshExec()` helper (key agent or `sshpass`). +One `SystemCollector` path for both modes. When `spark.isLocal` is true, metrics come from host sysfs/proc and `nvidia-smi` (often via nsenter into the host namespace). Remote Sparks wrap the same commands in a shared `sshExec()` helper (key agent or `sshpass`). The helper reuses an authenticated OpenSSH transport by default so frequent metric polls do not create a new SSH/PAM login lifecycle each time. Set `SSH_CONTROL_PERSIST_SECONDS=0` to disable reuse. For `kind: "host"` units, actual hardware (GPU model, driver version, CPU, RAM) is detected once and cached in place of the static DGX Spark specs, and GPU VRAM comes straight from `nvidia-smi` while system RAM is read from `/proc/meminfo`. ### Graceful degradation @@ -383,10 +501,12 @@ Each configured LLM port gets its own `LlmProbe` instance running in parallel. P - **llama.cpp** — `/slots` for live decode rates; model from `/props` - **ds4-server** (Entrpi/ds4-on-spark) — `/v1/models` (`owned_by: ds4.c`) + Prometheus `ds4_*` token counters for live tok/s -- **vLLM / sglang** — `/v1/models`; sglang via `/get_server_info` (`last_gen_throughput` when metrics off), vLLM via Prometheus `/metrics` counters (scientific notation supported) +- **vLLM / sglang** — `/v1/models`; sglang via `/server_info` (`last_gen_throughput` when metrics off; `/get_server_info` fallback), vLLM via Prometheus `/metrics` counters (scientific notation supported) Rates are derived from per-probe cumulative counter diffs (or SGLang sticky throughput while it moves). Multiple ports can be added or removed at runtime without restarting the monitor. +Live probes still use the LAN IP on remote units. **Decode and prefill benches** try that same HTTP target first; if it is closed they open an SSH local-forward onto the remote’s `127.0.0.1` so loopback-bound servers (ds4 `start.sh` default) can still be measured. The tunnel is torn down when the job finishes or is cancelled. + --- ## Contributing diff --git a/bench.png b/bench.png new file mode 100755 index 00000000..a7b53dd4 Binary files /dev/null and b/bench.png differ diff --git a/deploy.sh b/deploy.sh index 96e594e3..7833f232 100755 --- a/deploy.sh +++ b/deploy.sh @@ -6,8 +6,16 @@ set -e echo "Stopping and removing containers..." docker compose down -echo "Building images (without cache)..." -docker compose build +echo "Building images..." +if ! docker compose build; then + echo "" + echo "Build failed. If you saw auth.docker.io / 'network is unreachable' on an IPv6 address," + echo "Docker Hub is being reached over broken IPv6. This repo pulls Node from" + echo "public.ecr.aws (IPv4-first). Pull latest deploy.sh + Dockerfiles and retry." + echo "Host workaround: add this line to /etc/gai.conf then retry:" + echo " precedence ::ffff:0:0/96 100" + exit 1 +fi echo "Starting services in detached mode..." docker compose up -d diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e48d20b..7a1f5bc6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,14 +22,18 @@ services: - /usr/lib/aarch64-linux-gnu/libnvidia-ml.so.1:/usr/lib/aarch64-linux-gnu/libnvidia-ml.so.1:ro # Full config dir (sparks.json, secrets, gpu-memory) - ./config:/app/config + # Remote Sparks with SSH key auth — see docker-compose.yml. Uncomment: + # - ${HOME}/.ssh/id_ed25519:/root/.ssh/id_ed25519:ro privileged: true # Share host PID namespace so nvidia-smi compute-apps sees GPU processes # (otherwise local VRAM falls back to minute-old config/gpu-memory.json). pid: host environment: + - BIND_HOST=0.0.0.0 - PORT=5555 - LLM_PORT=8888 - NODE_ENV=development - HOST_PROC_PATH=/host/proc - HOST_SYS_PATH=/host/sys - HOST_ROOT_PATH=/host/root + - SSH_CONTROL_PERSIST_SECONDS=${SSH_CONTROL_PERSIST_SECONDS:-60} diff --git a/docker-compose.yml b/docker-compose.yml index bfda3a02..3c7b3de6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,9 @@ services: dockerfile: Dockerfile platforms: - linux/arm64 + args: + # Frontend metrics-history retention (hours). Rebuild after changing. + VITE_HISTORY_HOURS: ${VITE_HISTORY_HOURS:-8} container_name: sparkDash # always: come back after host reboot even if the container was stopped # before shutdown (unlike unless-stopped, which stays down after a manual stop). @@ -22,6 +25,8 @@ services: volumes: # Live server code — edits apply after --watch reloads the process - ./server:/app/server + # Prompt catalog imported by DecodeBench / Showcase (not under ./server) + - ./src/shared:/app/src/shared:ro # Mount host filesystem for local Spark metrics - /proc:/host/proc:ro - /sys:/host/sys:ro @@ -31,6 +36,12 @@ services: - /usr/lib/aarch64-linux-gnu/libnvidia-ml.so.1:/usr/lib/aarch64-linux-gnu/libnvidia-ml.so.1:ro # Config + encrypted secrets (passwords survive restarts) - ./config:/app/config + # Remote Sparks with SSH *key* auth: OpenSSH inside the container uses + # /root/.ssh (not the host user's ~/.ssh). Uncomment and point at a + # default-named key (id_ed25519 / id_rsa). A custom name like + # id_ed25519_shared must be mounted *as* id_ed25519, or set + # SSH_IDENTITY_FILE to the path inside the container. + # - ${HOME}/.ssh/id_ed25519:/root/.ssh/id_ed25519:ro environment: - BIND_HOST=0.0.0.0 - PORT=5555 @@ -39,3 +50,4 @@ services: - HOST_PROC_PATH=/host/proc - HOST_SYS_PATH=/host/sys - HOST_ROOT_PATH=/host/root + - SSH_CONTROL_PERSIST_SECONDS=${SSH_CONTROL_PERSIST_SECONDS:-60} diff --git a/package-lock.json b/package-lock.json index d45eff20..8e044345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "sparkdash", - "version": "1.6.0", + "version": "1.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparkdash", - "version": "1.6.0", + "version": "1.8.6", "license": "MIT", "dependencies": { "dotenv": "^17.4.2", "express": "^5.2.1", + "undici": "^6.24.1", "ws": "^8.21.0" }, "devDependencies": { @@ -3037,6 +3038,15 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/package.json b/package.json index cb29d7bb..1ffe41d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparkdash", - "version": "1.6.0", + "version": "1.8.6", "description": "sparkDash — Multi-DGX Spark Monitoring Dashboard", "type": "module", "scripts": { @@ -34,6 +34,7 @@ "dependencies": { "dotenv": "^17.4.2", "express": "^5.2.1", + "undici": "^6.24.1", "ws": "^8.21.0" }, "devDependencies": { diff --git a/server/collectors/DecodeBench.js b/server/collectors/DecodeBench.js index ab7548c9..2003d1fd 100644 --- a/server/collectors/DecodeBench.js +++ b/server/collectors/DecodeBench.js @@ -3,6 +3,14 @@ * * Measures real post-first-token decode tok/s against an OpenAI-compatible * chat completions endpoint. Concurrency levels run one after another. + * + * Output types (structured / prose / code / json) are prompt labels only — + * never response_format, grammars, or guided JSON. + * + * Structured protocol (matches glm-5.3-flash-sm120 tests/bench_decode.py): + * count 1→200, temperature 0, top_p 1, thinking off, warmup 32 tokens, + * decode tok/s = (completion_tokens − 1) / (last − first token). + * The same sampling protocol applies to every type. */ import { randomUUID } from "crypto"; @@ -17,60 +25,35 @@ import { round2, runStreamingRequest, sleep, + stripFillForceFields, } from "./LlmStreaming.js"; +import { + pickDecodeBenchPrompts, + decodeBenchPromptForType, + normalizeDecodeBenchType, + DECODE_BENCH_DEFAULT_TYPE, + DECODE_BENCH_TYPES, +} from "../../src/shared/llmPrompts.js"; +import { formatLlmBaseUrl } from "../../src/shared/llmTarget.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, "../.."); const HISTORY_PATH = process.env.BENCH_HISTORY_PATH || path.join(ROOT, "config", "bench-history.json"); +/** In-flight jobs checkpointed here so a --watch / SIGTERM restart does not 404 polls. */ +const ACTIVE_PATH = + process.env.BENCH_ACTIVE_PATH || path.join(ROOT, "config", "bench-active.json"); -/** - * Structured generation prompts (JSON / HTML). Models usually sustain higher - * decode tok/s on these than open-ended chat essays. Keep prompts short and - * distinct so concurrent streams don't share an identical prefix. - */ -const BENCH_PROMPTS = [ - "Write only valid JSON (no markdown). Generate a large array \"items\" of objects with fields id, name, category, price, inStock, tags (string array). Keep writing many items until you hit the length limit.", - "Write only valid JSON (no markdown). Generate a nested object for a fake e-commerce order: orderId, customer, shipping, lineItems[], payments[], timeline[]. Expand lineItems and timeline with many entries.", - "Write only valid JSON (no markdown). Generate { \"users\": [ ... ] } where each user has id, email, profile{firstName,lastName,bio}, roles[], lastLogin. Add as many users as possible.", - "Write only valid JSON (no markdown). Generate a metrics dump: { \"hosts\": [ { hostname, cpus[], disks[], gpus[], services[] } ] }. Invent many hosts with nested arrays fully populated.", - "Write only valid JSON (no markdown). Generate a GraphQL-like schema as JSON: types[], fields[], enums[]. Include many types each with many fields.", - "Write only valid JSON (no markdown). Generate { \"events\": [ ... ] } log lines with ts, level, service, message, attrs{}. Produce a long continuous event stream.", - "Write only valid JSON (no markdown). Generate a product catalog: categories[], products[] with sku, title, description, specs{}, variants[]. Make it large.", - "Write only valid JSON (no markdown). Generate OpenAPI-style paths as JSON: paths{}, components.schemas{}. Invent many endpoints and schemas.", - "Write only valid HTML5 (no markdown fences). Build a long multi-section documentation page with header, nav, main articles, tables, and footers. Keep adding sections.", - "Write only valid HTML5 (no markdown fences). Generate a large data table report ( with many rows) of invent server metrics: host, cpu, mem, disk, net, status. Dozens of rows.", - "Write only valid HTML5 (no markdown fences). Create a multi-page-looking dashboard layout with cards, lists, and nested
s. Keep expanding content blocks.", - "Write only valid HTML5 (no markdown fences). Write a long FAQ page with many

/

/

    Q&A pairs about networking and GPUs. Keep adding pairs.", - "Write only valid HTML5 (no markdown fences). Generate a blog index with many
    entries (title, date, tags, excerpt). Continue with lots of articles.", - "Write only valid HTML5 (no markdown fences). Produce a form-heavy admin UI: multiple
    s with inputs, selects, textareas, and labels. Expand with more field groups.", - "Write only valid JSON (no markdown). Generate { \"benchmarks\": [ { name, concurrency, ttftMs, tokPerSec, notes } ] } with many synthetic result rows.", - "Write only valid JSON (no markdown). Generate a filesystem tree as nested JSON: { name, type, children[] }. Make a deep and wide tree under /data.", - "Write only valid JSON (no markdown). Generate { \" sparql_like_rows\": [ ... ] } with columns subject, predicate, object, graph — hundreds of triples style rows.", - "Write only valid HTML5 (no markdown fences). Write a long changelog page with version headings and bullet lists of changes. Keep adding versions.", - "Write only valid JSON (no markdown). Generate a chat transcript: { \"messages\": [ { role, content, ts } ] } alternating user/assistant, many turns, substantial content.", - "Write only valid HTML5 (no markdown fences). Generate a recipe site section with many recipes: ingredients lists and step lists. Keep adding recipes.", - "Write only valid JSON (no markdown). Generate geo data: { \"features\": [ { type:\"Feature\", properties{}, geometry{type,coordinates} } ] } with many features.", - "Write only valid HTML5 (no markdown fences). Create a long comparison matrix page using nested tables for software features. Fill many rows and columns.", - "Write only valid JSON (no markdown). Generate CI job results: { \"jobs\": [ { id, name, status, steps[], durationSec, logs[] } ] }. Expand jobs and steps.", - "Write only valid HTML5 (no markdown fences). Write an API reference page: many endpoint sections with blocks and parameter tables. Keep going.", - "Write only valid JSON (no markdown). Generate a config blob: services{}, networks{}, volumes{}, env{} with many service definitions and ports.", - "Write only valid HTML5 (no markdown fences). Produce a news wire page: many
    items with headline, byline, and paragraphs. Continue writing items.", - "Write only valid JSON (no markdown). Generate token usage records: { \"requests\": [ { id, model, promptTokens, completionTokens, latencyMs, route } ] } — many requests.", - "Write only valid HTML5 (no markdown fences). Build a long glossary:
    with many
    /
    terms about ML systems. Keep adding terms.", - "Write only valid JSON (no markdown). Generate a dependency lock style file: { \"packages\": { \"name\": { version, deps{}, integrity } } } with many packages.", - "Write only valid HTML5 (no markdown fences). Write a product landing page with repeated feature blocks, pricing tables, and testimonials. Expand heavily.", - "Write only valid JSON (no markdown). Generate sensor readings: { \"series\": [ { sensorId, points:[{t,v}] } ] } with long point arrays.", - "Write only valid HTML5 (no markdown fences). Create a multi-chapter tutorial with

    , code samples in
    , and notes. Keep writing chapters.",
    -];
    +/** Lab protocol: temperature 0; thinking off; top_p 1. Structured default. */
     
     const ALLOWED_CONCURRENCIES = new Set([1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32]);
    -const DEFAULT_MAX_TOKENS = 500;
    +const DEFAULT_MAX_TOKENS = 400;
     const MIN_MAX_TOKENS = 64;
     const MAX_MAX_TOKENS = 2048;
    -const PER_REQUEST_TIMEOUT_MS = 180_000;
    -const WAVE_TIMEOUT_MS = 300_000;
    +const WARMUP_MAX_TOKENS = 32;
    +const PER_REQUEST_TIMEOUT_MS = 360_000;
    +const WAVE_TIMEOUT_MS = 360_000;
     const HISTORY_LIMIT = 10;
     /** Hardware sample cadence while a concurrency wave runs (debug timeline). */
     const HARDWARE_SAMPLE_MS = 1_000;
    @@ -115,31 +98,58 @@ async function pollHardwareSamples(sampleHardware, signal, intervalMs = HARDWARE
       return samples;
     }
     
    +/** Lab prompt for the selected type on every stream (C1 is the exact prompt). */
    +function pickBenchPrompts(count, type) {
    +  return pickDecodeBenchPrompts(count, type);
    +}
    +
    +function decodeRequestBody(modelId, prompt, maxTokens) {
    +  const body = {
    +    model: modelId || undefined,
    +    messages: [{ role: "user", content: prompt }],
    +    max_tokens: maxTokens,
    +    temperature: 0,
    +    top_p: 1,
    +    stream: true,
    +    stream_options: { include_usage: true },
    +  };
    +  applyThinkingFlags(body, modelId, false);
    +  return body;
    +}
    +
     /**
    - * Pick `count` distinct prompts (shuffled). Falls back to unique suffixes if
    - * we ever need more than the pool size.
    + * Short count stream so DFlash2 / Triton JIT is not billed on the first wave.
    + * Best-effort: a warmup failure does not fail the job.
      */
    -function pickDistinctPrompts(count) {
    -  const pool = [...BENCH_PROMPTS];
    -  // Fisher–Yates shuffle so concurrent sets vary across waves/runs
    -  for (let i = pool.length - 1; i > 0; i--) {
    -    const j = Math.floor(Math.random() * (i + 1));
    -    [pool[i], pool[j]] = [pool[j], pool[i]];
    +async function warmupDecode({ baseUrl, modelId, abortSignal, apiKey, debug = false, promptType = DECODE_BENCH_DEFAULT_TYPE }) {
    +  const url = `${baseUrl}/v1/chat/completions`;
    +  const warmupPrompt = decodeBenchPromptForType(promptType);
    +  const body = decodeRequestBody(modelId, warmupPrompt, WARMUP_MAX_TOKENS);
    +  const ctrl = new AbortController();
    +  const onParentAbort = () => ctrl.abort();
    +  if (abortSignal) {
    +    if (abortSignal.aborted) ctrl.abort();
    +    else abortSignal.addEventListener("abort", onParentAbort, { once: true });
       }
    -  const out = [];
    -  for (let i = 0; i < count; i++) {
    -    if (i < pool.length) {
    -      out.push(pool[i]);
    -    } else {
    -      // Should not hit for allowed concurrencies ≤ pool size
    -      out.push(`${pool[i % pool.length]}\n\n[stream variant ${i + 1}]`);
    -    }
    +  const timeout = setTimeout(() => ctrl.abort(), PER_REQUEST_TIMEOUT_MS);
    +  try {
    +    await runStreamingRequest(url, body, ctrl.signal, {
    +      debug,
    +      retryOnThinking400: true,
    +      thinking: false,
    +      apiKey,
    +    });
    +  } catch {
    +    /* ignore */
    +  } finally {
    +    clearTimeout(timeout);
    +    if (abortSignal) abortSignal.removeEventListener("abort", onParentAbort);
       }
    -  return out;
     }
     
     /**
    - * Run one concurrency wave: N simultaneous streams, each with a different prompt.
    + * Run one concurrency wave: N simultaneous streams.
    + * Prompts use the selected decode type (structured default).
      */
     function emptyWaveResult(concurrency, waveMs, results, modelId, error, prompts = [], debug = false) {
       return {
    @@ -154,6 +164,10 @@ function emptyWaveResult(concurrency, waveMs, results, modelId, error, prompts =
         medianTtftMs: 0,
         /** Client-side: total decode tokens / concurrent decode window */
         aggregateDecodeTps: 0,
    +    meanPrefillTps: 0,
    +    medianPrefillTps: 0,
    +    aggregatePrefillTps: 0,
    +    totalPrefillTokens: 0,
         /**
          * Server-side generation tok/s (same basis as live Generation tok/s panel).
          * Null when the backend does not expose counters.
    @@ -187,6 +201,8 @@ function streamPublicResult(r, index, prompt, reqMeta, debug = false) {
         decodeTps: r?.decodeTps ?? 0,
         decodeTokens: r?.decodeTokens ?? 0,
         completionTokens: r?.completionTokens ?? 0,
    +    prefillTps: r?.prefillTps ?? 0,
    +    prefillTokens: r?.prefillTokens ?? 0,
         totalMs: r?.totalMs ?? 0,
         error: r?.error ?? null,
       };
    @@ -216,6 +232,7 @@ function streamPublicResult(r, index, prompt, reqMeta, debug = false) {
       return out;
     }
     
    +
     async function runConcurrencyWave({
       baseUrl,
       modelId,
    @@ -225,9 +242,10 @@ async function runConcurrencyWave({
       sampleHardware = null,
       debug = false,
       apiKey = null,
    +  promptType = DECODE_BENCH_DEFAULT_TYPE,
     }) {
       const url = `${baseUrl}/v1/chat/completions`;
    -  const prompts = pickDistinctPrompts(concurrency);
    +  const prompts = pickBenchPrompts(concurrency, promptType);
       const reqMeta = { url, modelId, maxTokens };
     
       const wallStart = performance.now();
    @@ -258,27 +276,41 @@ async function runConcurrencyWave({
         }
     
         const body = {
    -      model: modelId || undefined,
    -      messages: [{ role: "user", content: prompts[streamIndex] }],
    -      max_tokens: maxTokens,
    -      temperature: 0,
    -      stream: true,
    -      stream_options: { include_usage: true },
    -      // Prefer full-length generations when the backend supports it
    +      ...decodeRequestBody(modelId, prompts[streamIndex], maxTokens),
    +      min_tokens: maxTokens,
           ignore_eos: true,
           stop: [],
         };
    -    applyThinkingFlags(body, modelId, true);
     
    -    const timeout = setTimeout(() => ctrl.abort(), PER_REQUEST_TIMEOUT_MS);
    -    return runStreamingRequest(url, body, ctrl.signal, {
    +    const streamOpts = {
           debug,
           retryOnThinking400: true,
    +      thinking: false,
           apiKey,
    -    }).finally(() => {
    -      clearTimeout(timeout);
    -      if (abortSignal) abortSignal.removeEventListener("abort", onParentAbort);
    -    });
    +    };
    +
    +    return (async () => {
    +      const timeout = setTimeout(() => ctrl.abort(), PER_REQUEST_TIMEOUT_MS);
    +      try {
    +        let result = await runStreamingRequest(url, body, ctrl.signal, streamOpts);
    +        if (
    +          result.error &&
    +          /^HTTP 400\b/.test(result.error) &&
    +          (body.min_tokens != null || body.ignore_eos != null)
    +        ) {
    +          result = await runStreamingRequest(
    +            url,
    +            stripFillForceFields(body),
    +            ctrl.signal,
    +            streamOpts
    +          );
    +        }
    +        return result;
    +      } finally {
    +        clearTimeout(timeout);
    +        if (abortSignal) abortSignal.removeEventListener("abort", onParentAbort);
    +      }
    +    })();
       });
     
       // Hard cap on the whole wave
    @@ -320,8 +352,10 @@ async function runConcurrencyWave({
       const failed = results.filter((r) => r.error || r.decodeTokens <= 0);
       const decodeTpsList = ok.map((r) => r.decodeTps);
       const ttftList = ok.map((r) => r.ttftMs);
    +  const prefillTpsList = ok.map((r) => r.prefillTps).filter((n) => n > 0);
       const totalDecodeTokens = ok.reduce((s, r) => s + r.decodeTokens, 0);
       const totalCompletionTokens = ok.reduce((s, r) => s + r.completionTokens, 0);
    +  const totalPrefillTokens = ok.reduce((s, r) => s + (r.prefillTokens || 0), 0);
     
       // Client aggregate over concurrent first→last content window (network-affected)
       let aggregateDecodeTps = 0;
    @@ -334,6 +368,18 @@ async function runConcurrencyWave({
         }
       }
     
    +  // Concurrent prefill window: earliest request start → latest first token
    +  let aggregatePrefillTps = 0;
    +  const t0s = ok
    +    .map((r) => (r.t0 != null ? r.t0 : r.tFirst != null ? r.tFirst - (r.ttftMs || 0) : null))
    +    .filter((t) => t != null);
    +  if (ok.length > 0 && t0s.length && firsts.length && totalPrefillTokens > 0) {
    +    const prefillWindowMs = Math.max(...firsts) - Math.min(...t0s);
    +    if (prefillWindowMs > 0) {
    +      aggregatePrefillTps = (totalPrefillTokens / prefillWindowMs) * 1000;
    +    }
    +  }
    +
       const model = results.find((r) => r.model)?.model || modelId || null;
     
       /** @type {Record} */
    @@ -348,6 +394,10 @@ async function runConcurrencyWave({
         meanTtftMs: round2(mean(ttftList)),
         medianTtftMs: round2(median(ttftList)),
         aggregateDecodeTps: round2(aggregateDecodeTps),
    +    meanPrefillTps: round2(mean(prefillTpsList)),
    +    medianPrefillTps: round2(median(prefillTpsList)),
    +    aggregatePrefillTps: round2(aggregatePrefillTps),
    +    totalPrefillTokens,
     
         totalDecodeTokens,
         totalCompletionTokens,
    @@ -369,9 +419,13 @@ async function runConcurrencyWave({
     /**
      * Job manager: one active job per spark, short history persisted to disk
      * so last results survive page refresh and process restart.
    + *
    + * Running jobs are also checkpointed to bench-active.json. On boot (or
    + * graceful shutdown), any leftover "running" entries are finalized as
    + * interrupted so GET /bench/:id keeps working instead of returning 404.
      */
     export class DecodeBenchManager {
    -  constructor(historyPath = HISTORY_PATH) {
    +  constructor(historyPath = HISTORY_PATH, activePath = ACTIVE_PATH) {
         /** @type {Map} */
         this.jobs = new Map();
         /** @type {Map} sparkId → active benchId */
    @@ -379,7 +433,9 @@ export class DecodeBenchManager {
         /** @type {Map} */
         this.historyBySpark = new Map();
         this.historyPath = historyPath;
    +    this.activePath = activePath;
         this._loadHistory();
    +    this._recoverInterruptedActive();
       }
     
       getJob(benchId) {
    @@ -485,6 +541,118 @@ export class DecodeBenchManager {
         }
       }
     
    +  /**
    +   * Promote leftover active checkpoints (process died mid-run) into history
    +   * so clients polling GET /:benchId still get a final job instead of 404.
    +   */
    +  _recoverInterruptedActive() {
    +    const leftovers = this._readActiveFile();
    +    if (!leftovers.length) return;
    +
    +    let changed = false;
    +    for (const snap of leftovers) {
    +      if (!snap?.benchId || !snap?.sparkId) continue;
    +      // Already in history from a prior clean finalize — skip
    +      const hist = this.getHistory(snap.sparkId);
    +      if (hist.some((j) => j.benchId === snap.benchId)) continue;
    +
    +      const interrupted = {
    +        ...snap,
    +        status: "failed",
    +        error:
    +          snap.error ||
    +          "Interrupted — server restarted while the benchmark was running",
    +        completedAt: snap.completedAt || Date.now(),
    +        progress: {
    +          ...(snap.progress || {}),
    +          message: "Interrupted",
    +          currentConcurrency: null,
    +        },
    +      };
    +      if (interrupted.completedAt && interrupted.startedAt) {
    +        interrupted.durationMs = interrupted.completedAt - interrupted.startedAt;
    +      }
    +      this.jobs.set(interrupted.benchId, interrupted);
    +      this._pushHistory(interrupted);
    +      changed = true;
    +      console.warn(
    +        `[DecodeBench] recovered interrupted job ${interrupted.benchId} on ${interrupted.sparkId}`
    +      );
    +    }
    +
    +    // Clear active file either way — nothing is actually running after boot
    +    this._writeActiveFile([]);
    +    if (changed) this._saveHistory();
    +  }
    +
    +  _readActiveFile() {
    +    try {
    +      if (!fs.existsSync(this.activePath)) return [];
    +      const raw = fs.readFileSync(this.activePath, "utf8");
    +      const data = JSON.parse(raw);
    +      if (Array.isArray(data)) return data;
    +      if (data && typeof data === "object" && Array.isArray(data.jobs)) {
    +        return data.jobs;
    +      }
    +      return [];
    +    } catch (err) {
    +      console.warn("[DecodeBench] failed to load active jobs:", err?.message || err);
    +      return [];
    +    }
    +  }
    +
    +  _writeActiveFile(jobs) {
    +    try {
    +      atomicWrite(
    +        this.activePath,
    +        JSON.stringify({ jobs }, null, 2),
    +        0o600
    +      );
    +    } catch (err) {
    +      console.warn("[DecodeBench] failed to save active jobs:", err?.message || err);
    +    }
    +  }
    +
    +  /** Persist public snapshots of all currently running jobs. */
    +  _checkpointActive() {
    +    /** @type {object[]} */
    +    const running = [];
    +    for (const job of this.jobs.values()) {
    +      if (job.status === "running") running.push(publicJob(job));
    +    }
    +    this._writeActiveFile(running);
    +  }
    +
    +  /**
    +   * Finalize every running job (used on SIGTERM / --watch reload).
    +   * Aborts in-flight streams and writes history so polls do not 404.
    +   * @param {string} [reason]
    +   */
    +  interruptAll(reason = "Interrupted — server shutting down") {
    +    for (const job of this.jobs.values()) {
    +      if (job.status !== "running") continue;
    +      try {
    +        job._abort?.abort();
    +      } catch {
    +        /* ignore */
    +      }
    +      try {
    +        job._closeTarget?.();
    +      } catch {
    +        /* ignore */
    +      }
    +      job._closeTarget = null;
    +      job.status = "failed";
    +      job.error = reason;
    +      job.progress.message = "Interrupted";
    +      job.progress.currentConcurrency = null;
    +      job.completedAt = Date.now();
    +      this.activeBySpark.delete(job.sparkId);
    +      this._pushHistory(job);
    +    }
    +    this._writeActiveFile([]);
    +  }
    +
       _saveHistory() {
         try {
           /** @type {Record} */
    @@ -506,9 +674,15 @@ export class DecodeBenchManager {
        *   modelId: string | null,
        *   concurrencies: number[],
        *   maxTokens?: number,
    +   *   promptType?: string,
        *   debug?: boolean,
        *   sampleHardware?: (() => Promise | object | null) | null,
        *   apiKey?: string | null,
    +   *   resolveTarget?: (ctx: { onStatus?: Function, signal?: AbortSignal }) => Promise<{
    +   *     host: string, port: number, tls?: boolean, via?: string, close: () => void
    +   *   }>,
    +   *   host?: string | null,
    +   *   tls?: boolean,
        * }} opts
        */
       start(opts) {
    @@ -519,9 +693,13 @@ export class DecodeBenchManager {
           modelId,
           concurrencies: rawConc,
           maxTokens: rawMax,
    +      promptType: rawType,
           debug = false,
           sampleHardware = null,
           apiKey = null,
    +      resolveTarget = null,
    +      host: rawHost = null,
    +      tls: rawTls = false,
         } = opts;
     
         if (this.activeBySpark.has(sparkId)) {
    @@ -553,6 +731,7 @@ export class DecodeBenchManager {
           throw err;
         }
     
    +    const promptType = normalizeDecodeBenchType(rawType);
         const debugOn = Boolean(debug);
         const benchId = randomUUID();
         const abort = new AbortController();
    @@ -567,7 +746,11 @@ export class DecodeBenchManager {
             modelId: modelId || null,
             concurrencies,
             maxTokens,
    +        promptType,
             ...(debugOn ? { debug: true } : {}),
    +        ...(rawHost
    +          ? { host: String(rawHost).trim(), tls: Boolean(rawTls) }
    +          : {}),
           },
           progress: {
             currentConcurrency: null,
    @@ -582,10 +765,13 @@ export class DecodeBenchManager {
           _apiKey: apiKey != null && String(apiKey).trim() ? String(apiKey).trim() : null,
           _sampleHardware:
             debugOn && typeof sampleHardware === "function" ? sampleHardware : null,
    +      _resolveTarget: typeof resolveTarget === "function" ? resolveTarget : null,
    +      _closeTarget: null,
         };
     
         this.jobs.set(benchId, job);
         this.activeBySpark.set(sparkId, benchId);
    +    this._checkpointActive();
     
         // Fire and forget — client polls GET
         this._runJob(job, lanIp).catch(() => {
    @@ -606,19 +792,57 @@ export class DecodeBenchManager {
       }
     
       async _runJob(job, lanIp) {
    -    const baseUrl = `http://${lanIp}:${job.config.port}`;
         const debug = Boolean(job._debug);
    +    let host = lanIp;
    +    let port = job.config.port;
    +    let tls = Boolean(job.config.tls);
         try {
    +      if (typeof job._resolveTarget === "function") {
    +        job.progress.message = "Connecting to LLM…";
    +        this._checkpointActive();
    +        const target = await job._resolveTarget({
    +          onStatus: (msg) => {
    +            if (typeof msg === "string" && msg) job.progress.message = msg;
    +            this._checkpointActive();
    +          },
    +          signal: job._abort.signal,
    +        });
    +        host = target?.host || host;
    +        port = Number.isInteger(target?.port) ? target.port : port;
    +        if (target?.tls != null) tls = Boolean(target.tls);
    +        job._closeTarget = typeof target?.close === "function" ? target.close : null;
    +        if (target?.via === "ssh-tunnel") {
    +          job.progress.message = "Warming up via SSH tunnel…";
    +        }
    +      }
    +      const baseUrl = formatLlmBaseUrl({ host, port, tls });
    +      if (!job._abort.signal.aborted) {
    +        if (!String(job.progress.message || "").startsWith("Warming up")) {
    +          job.progress.message = "Warming up…";
    +        }
    +        this._checkpointActive();
    +        await warmupDecode({
    +          baseUrl,
    +          modelId: job.config.modelId,
    +          abortSignal: job._abort.signal,
    +          apiKey: job._apiKey,
    +          debug,
    +          promptType: job.config.promptType,
    +        });
    +      }
           for (const c of job.config.concurrencies) {
             if (job._abort.signal.aborted) {
    -          job.status = "cancelled";
    -          job.error = "Cancelled by user";
    -          job.progress.message = "Cancelled";
    +          if (job.status === "running") {
    +            job.status = "cancelled";
    +            job.error = "Cancelled by user";
    +            job.progress.message = "Cancelled";
    +          }
               break;
             }
     
             job.progress.currentConcurrency = c;
             job.progress.message = `Running concurrency ${c}…`;
    +        this._checkpointActive();
     
             const wave = await runConcurrencyWave({
               baseUrl,
    @@ -629,6 +853,7 @@ export class DecodeBenchManager {
               sampleHardware: job._sampleHardware,
               debug,
               apiKey: job._apiKey,
    +          promptType: job.config.promptType,
             });
     
             if (job._abort.signal.aborted) {
    @@ -637,9 +862,11 @@ export class DecodeBenchManager {
                 job.results.push(wave);
                 job.progress.completedLevels += 1;
               }
    -          job.status = "cancelled";
    -          job.error = "Cancelled by user";
    -          job.progress.message = "Cancelled";
    +          if (job.status === "running") {
    +            job.status = "cancelled";
    +            job.error = "Cancelled by user";
    +            job.progress.message = "Cancelled";
    +          }
               break;
             }
     
    @@ -649,6 +876,7 @@ export class DecodeBenchManager {
     
             job.results.push(wave);
             job.progress.completedLevels += 1;
    +        this._checkpointActive();
           }
     
           if (job.status === "running") {
    @@ -657,25 +885,37 @@ export class DecodeBenchManager {
             job.progress.message = "Done";
           }
         } catch (err) {
    -      if (job._abort.signal.aborted) {
    -        job.status = "cancelled";
    -        job.error = "Cancelled by user";
    -        job.progress.message = "Cancelled";
    -      } else {
    -        job.status = "failed";
    -        job.error = err?.message || String(err);
    -        job.progress.message = "Failed";
    +      if (job.status === "running") {
    +        if (job._abort.signal.aborted) {
    +          job.status = "cancelled";
    +          job.error = "Cancelled by user";
    +          job.progress.message = "Cancelled";
    +        } else {
    +          job.status = "failed";
    +          job.error = err?.message || String(err);
    +          job.progress.message = "Failed";
    +        }
           }
         } finally {
    -      job.completedAt = Date.now();
    +      try {
    +        job._closeTarget?.();
    +      } catch {
    +        /* ignore */
    +      }
    +      job._closeTarget = null;
    +      if (job.completedAt == null) job.completedAt = Date.now();
           this.activeBySpark.delete(job.sparkId);
           this._pushHistory(job);
    +      this._checkpointActive();
         }
       }
     
       _pushHistory(job) {
         const list = this.historyBySpark.get(job.sparkId) || [];
    -    list.unshift(publicJob(job));
    +    const pub = publicJob(job);
    +    const existing = list.findIndex((j) => j.benchId === pub.benchId);
    +    if (existing >= 0) list.splice(existing, 1);
    +    list.unshift(pub);
         this.historyBySpark.set(job.sparkId, list.slice(0, HISTORY_LIMIT));
         this._saveHistory();
       }
    @@ -718,4 +958,6 @@ export const DECODE_BENCH_DEFAULTS = {
       defaultMaxTokens: DEFAULT_MAX_TOKENS,
       minMaxTokens: MIN_MAX_TOKENS,
       maxMaxTokens: MAX_MAX_TOKENS,
    +  promptTypes: [...DECODE_BENCH_TYPES],
    +  defaultPromptType: DECODE_BENCH_DEFAULT_TYPE,
     };
    diff --git a/server/collectors/HermesProbe.js b/server/collectors/HermesProbe.js
    new file mode 100644
    index 00000000..f6997bd7
    --- /dev/null
    +++ b/server/collectors/HermesProbe.js
    @@ -0,0 +1,476 @@
    +/**
    + * HermesProbe — checks & updates the Hermes Agent CLI (nousresearch/hermes-agent)
    + * on a Spark. Works for local hosts and remote hosts (SSH via sshExec).
    + *
    + * "Hermes Agent enabled" on a Spark means the user has installed hermes on that
    + * machine (they opt in via Edit Spark). We surface:
    + *   - installed / version,
    + *   - update availability via `hermes update --check` (the documented
    + *     script-friendly gate: git fetch origin, compare HEAD..origin/main),
    + *   - one-shot `hermes update` over a non-TTY session — stdin EOF means no
    + *     interactive prompts, which hermes handles as the desktop-app "Update" path
    + *     (updates.non_interactive_local_changes applies).
    + *
    + * All commands are built to exit 0 regardless of outcome and to carry
    + * machine-readable markers, because sshExec rejects on non-zero exit and
    + * `hermes update --check` exits 1 legitimately when an update IS available.
    + *
    + * LOCAL-HOST IDENTITY: the dashboard container runs as root, but hermes and its
    + * git repo belong to the host user (spark.ssh.user). Running hermes as root
    + * triggered git "dubious ownership" failures, and once patched around with
    + * safe.directory it wrote root-owned files into the user's tree (breaking the
    + * install). The local path therefore drops to the host user via setpriv
    + * (util-linux, present in the container image) using uid/gid/home resolved from
    + * the HOST passwd (/host/root/etc/passwd bind mount; plain /etc/passwd for
    + * bare-host dev), and runs inside the host mount namespace via nsenter so host
    + * git (not installed in the container) and the host filesystem are visible.
    + */
    +import fs from "fs";
    +import path from "path";
    +import { execFile } from "child_process";
    +import { HOST_PATHS, HERMES_UPDATE_TIMEOUT_MS } from "../config.js";
    +import { sshExec } from "./ssh.js";
    +import { parsePendingCommits } from "./HermesReleases.js";
    +
    +const HERMES_MISSING = "__HERMES_MISSING__";
    +const HERMES_LAUNCH_FAIL = "__HERMES_LAUNCH_FAIL__";
    +const CHECK_PHASE = "__CHECK_PHASE__";
    +const CHECK_EXIT = "__CHECK_EXIT__";
    +const CHECK_TIMEOUT_MS = 30000;
    +
    +/** Fallback only: allow git to trust the host-owned repo when we must run as root. */
    +const GIT_SAFE_ENV =
    +  "export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0='safe.directory' GIT_CONFIG_VALUE_0='*'; ";
    +
    +/**
    + * Resolve a username to { uid, gid, home } from a /etc/passwd-style block.
    + * Exported for tests; never throws.
    + * @param {string} passwdText
    + * @param {string|undefined} user
    + */
    +export function parseHostPasswd(passwdText, user) {
    +  if (typeof passwdText !== "string" || !user) return null;
    +  for (const line of passwdText.split("\n")) {
    +    const p = line.split(":");
    +    if (p.length >= 6 && p[0] === user) {
    +      const uid = Number(p[2]);
    +      const gid = Number(p[3]);
    +      if (Number.isInteger(uid) && Number.isInteger(gid) && uid >= 0 && gid >= 0) {
    +        return { uid, gid, home: p[5] || `/home/${user}` };
    +      }
    +    }
    +  }
    +  return null;
    +}
    +
    +/**
    + * Decide HOW a local hermes command should be executed.
    + * Pure + exported for tests. Returns the spawn invocation plus an optional
    + * ownership-repair descriptor (run first, as root).
    + *
    + * @param {object} opts
    + * @param {string|null} opts.mntNs  host mount-namespace path (null when running
    + *   directly on the host rather than inside the Docker container)
    + * @param {string} opts.passwdText  Host passwd file content
    + * @param {number} opts.currentUid  uid of the current process
    + * @param {string|undefined} opts.user  configured host user (spark.ssh.user)
    + * @param {string} opts.cmd  command script to run
    + */
    +export function chooseLocalInvocation({ mntNs, passwdText, currentUid, user, cmd }) {
    +  const hasNs = Boolean(mntNs);
    +  const isRoot = Number.isInteger(currentUid) && currentUid === 0;
    +  const ident = parseHostPasswd(passwdText, user);
    +
    +  if (ident && isRoot) {
    +    const fullCmd = `export HOME='${ident.home}'; ${cmd}`;
    +    const setprivArgs = [
    +      "setpriv",
    +      `--reuid=${ident.uid}`,
    +      `--regid=${ident.gid}`,
    +      "--init-groups",
    +      "--",
    +      "sh",
    +      "-c",
    +      fullCmd,
    +    ];
    +    return {
    +      file: hasNs ? "nsenter" : "setpriv",
    +      args: hasNs ? ["--mount=" + mntNs, "--", ...setprivArgs] : setprivArgs,
    +      repair: { home: ident.home, uid: ident.uid, gid: ident.gid, mntNs },
    +    };
    +  }
    +
    +  // Cannot drop privileges: run as the current (container) user. Inside the
    +  // host namespace when available (host git), with safe.directory so git
    +  // trusts the host-owned repo.
    +  const fallbackCmd = GIT_SAFE_ENV + cmd;
    +  if (hasNs) {
    +    return {
    +      file: "nsenter",
    +      args: ["--mount=" + mntNs, "--", "sh", "-c", fallbackCmd],
    +      repair: null,
    +    };
    +  }
    +  return { file: "sh", args: ["-c", fallbackCmd], repair: null };
    +}
    +
    +/**
    + * Build a single host command that locates the `hermes` binary and runs the
    + * given action. Runs entirely inside the target host (via SSH, or inside the
    + * host namespace for local Sparks). The installer symlinks `hermes` into the
    + * target user's `~/.local/bin` (or /usr/local/bin for root/FHS), but SSH
    + * non-interactive shells usually do NOT source ~/.bashrc, so the bare `hermes`
    + * name is unreliable — we bootstrap PATH with the configured user's home
    + * explicitly. Falls back to explicit paths, then reports __HERMES_MISSING__
    + * (exit 0) when nothing is found.
    + */
    +function buildHermesCmd(spark, actionCmd) {
    +  const user = spark.ssh?.user || "root";
    +  return [
    +    // A previous interrupted `hermes update` can leave a stale git lock
    +    // (e.g. .git/shallow.lock) that makes every later fetch fail with
    +    // "Unable to create ... File exists". Clear leftover locks in the hermes
    +    // repo before running — idempotent and safe (no git is running yet).
    +    `if [ -d /home/${user}/.hermes ]; then find /home/${user}/.hermes -type f -name '*.lock' -path '*/.git/*' -delete 2>/dev/null; fi`,
    +    // Explicit PATH bootstrap — never rely on remote shell rc files.
    +    `export PATH="/home/${user}/.local/bin:/home/${user}/bin:/usr/local/bin:/usr/bin:/bin:$PATH"`,
    +    `BIN=$(command -v hermes)`,
    +    `if [ -z "$BIN" ]; then BIN=$(ls -d /home/${user}/.local/bin/hermes /usr/local/bin/hermes 2>/dev/null | head -n 1); fi`,
    +    `if [ -z "$BIN" ]; then echo '${HERMES_MISSING}'; exit 0; fi`,
    +    `echo "HERMES_BIN=$BIN"`,
    +    // Surface a broken launcher (e.g. missing venv entry point) instead of
    +    // silently failing: print the error text + a marker when the binary exists
    +    // but cannot execute. POSIX-safe (no PIPESTATUS).
    +    `V=$("$BIN" --version 2>&1); RC=$?; echo "$V" | head -n 1; if [ "$RC" -ne 0 ]; then echo '${HERMES_LAUNCH_FAIL}'; fi`,
    +    `echo '${CHECK_PHASE}'`,
    +    actionCmd,
    +    `echo "${CHECK_EXIT}$?"`,
    +    "exit 0",
    +  ].join("; ");
    +}
    +
    +const CHECK_ACTION = `"$BIN" update --check 2>&1`;
    +// stdin from /dev/null guarantees EOF → non-interactive update.
    +const UPDATE_ACTION = `"$BIN" update 2>&1 < /dev/null`;
    +
    +/**
    + * Parse the `hermes update --check` section of the combined output.
    + *
    + * `hermes update --check` exits 0 when in sync and 1 both when an update IS
    + * available AND when the fetch fails ("✗ Failed to fetch"), so the exit code
    + * alone cannot distinguish the two. Report "update available" only when the
    + * output explicitly says so; a failed or ambiguous check is reported as an
    + * error (updateAvailable null) — never as a phantom update.
    + */
    +function parseCheck(output) {
    +  const exitMatch = new RegExp(`${CHECK_EXIT}(\\d+)`).exec(output);
    +  const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
    +  const section = output.split(CHECK_PHASE)[1] || output || "";
    +  const upToDate = /already up to date/i.test(section);
    +  const behindText =
    +    /update available|commits? behind|is behind|behind origin/i.test(section);
    +  const failedFetch = /failed to fetch|fatal:|unable to/i.test(section);
    +  const unsupported =
    +    /unrecognized arguments|unexpected argument|usage:|no such option|--check not/i.test(
    +      section
    +    );
    +  if (unsupported) return { updateAvailable: null, behindCommits: null, unsupported: true };
    +  // Explicit up-to-date beats anything else.
    +  if (upToDate) return { updateAvailable: false, behindCommits: null, failed: false };
    +  // Explicit behind report (with or without a commit count) is authoritative.
    +  if (behindText) {
    +    const m = /(\d+)\s+commits?\s+behind/i.exec(section);
    +    return {
    +      updateAvailable: true,
    +      behindCommits: m ? parseInt(m[1], 10) : null,
    +      failed: false,
    +    };
    +  }
    +  // Fetch failure — no commit comparison happened; report the raw message.
    +  if (failedFetch) {
    +    return {
    +      updateAvailable: null,
    +      behindCommits: null,
    +      failed: true,
    +      error: section.trim().slice(0, 300) || "hermes update --check could not fetch from origin",
    +    };
    +  }
    +  // Clean exit with no "up to date" text: treat as in sync (some versions
    +  // print a terse output). Anything else (non-zero, no message) is unknown.
    +  if (exitCode === 0) return { updateAvailable: false, behindCommits: null, failed: false };
    +  return {
    +    updateAvailable: null,
    +    behindCommits: null,
    +    failed: true,
    +    error:
    +      section.trim().slice(0, 300) ||
    +      `hermes update --check exited ${exitCode} with no usable output`,
    +  };
    +}
    +
    +function parseVersion(output) {
    +  const m = /\bv?(\d+\.\d+(?:\.\d+)*)/i.exec(output || "");
    +  return m ? m[1] : null;
    +}
    +
    +export class HermesProbe {
    +  /**
    +   * @param {object} spark
    +   */
    +  constructor(spark) {
    +    this.spark = spark;
    +  }
    +
    +  setTarget(spark) {
    +    this.spark = spark;
    +  }
    +
    +  /** Run a command on the Spark: SSH for remote, host drop + nsenter for local. */
    +  async _run(cmd, timeoutMs) {
    +    if (this.spark.isLocal) return this._execLocal(cmd, timeoutMs);
    +    return sshExec(this.spark, cmd, { timeoutMs });
    +  }
    +
    +  /**
    +   * Execute a hermes command on the local host as the configured host user.
    +   * See the file header note on identity — this is what keeps hermes's own
    +   * install intact instead of polluting it with root-owned files.
    +   */
    +  async _execLocal(cmd, timeoutMs) {
    +    const mntNs = fs.existsSync(path.join(HOST_PATHS.PROC, "1", "ns", "mnt"))
    +      ? path.join(HOST_PATHS.PROC, "1", "ns", "mnt")
    +      : null;
    +    let passwdText = "";
    +    try {
    +      const passwdPath = mntNs
    +        ? path.join(HOST_PATHS.ROOT, "etc", "passwd")
    +        : "/etc/passwd";
    +      passwdText = fs.readFileSync(passwdPath, "utf8");
    +    } catch {
    +      passwdText = "";
    +    }
    +    const inv = chooseLocalInvocation({
    +      mntNs,
    +      passwdText,
    +      currentUid: typeof process.getuid === "function" ? process.getuid() : -1,
    +      user: this.spark.ssh?.user,
    +      cmd,
    +    });
    +
    +    if (inv.repair) {
    +      const r = inv.repair;
    +      const script =
    +        `ROOTFILE=$(find '${r.home}/.hermes' -user root -print -quit 2>/dev/null); ` +
    +        `if [ -n "$ROOTFILE" ]; then chown -R ${r.uid}:${r.gid} '${r.home}/.hermes' 2>/dev/null || true; fi`;
    +      try {
    +        await this._spawn(
    +          r.mntNs ? "nsenter" : "sh",
    +          r.mntNs
    +            ? ["--mount=" + r.mntNs, "--", "sh", "-c", script]
    +            : ["-c", script],
    +          20000
    +        );
    +      } catch {
    +        /* ownership repair is best-effort — never block hermes on it */
    +      }
    +    }
    +
    +    return this._spawn(inv.file, inv.args, timeoutMs);
    +  }
    +
    +  _spawn(file, args, timeoutMs) {
    +    return new Promise((resolve, reject) => {
    +      execFile(
    +        file,
    +        args,
    +        { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
    +        (err, stdout) => {
    +          if (err) {
    +            return reject(new Error(String(stdout || "").trim() || err.message));
    +          }
    +          resolve(String(stdout).trim());
    +        }
    +      );
    +    });
    +  }
    +
    +  /**
    +   * Check whether an update is available. Read-only on the target.
    +   * @returns {Promise} installed / version / updateAvailable / behindCommits /
    +   *   checkedAt / error (never throws)
    +   */
    +  async check() {
    +    const checkedAt = Date.now();
    +    const notInstalled = {
    +      installed: false,
    +      version: null,
    +      updateAvailable: null,
    +      behindCommits: null,
    +      checkedAt,
    +      error: null,
    +    };
    +    try {
    +      const out = await this._run(buildHermesCmd(this.spark, CHECK_ACTION), CHECK_TIMEOUT_MS);
    +      if (out.includes(HERMES_MISSING)) return notInstalled;
    +      if (out.includes(HERMES_LAUNCH_FAIL)) {
    +        return {
    +          installed: true,
    +          version: null,
    +          updateAvailable: null,
    +          behindCommits: null,
    +          checkedAt,
    +          error:
    +            "hermes is installed but cannot launch (broken install — usually a missing venv entry point). " +
    +            "Run one-click Update Hermes to attempt an automatic repair.",
    +        };
    +      }
    +      const parsed = parseCheck(out);
    +      return {
    +        installed: true,
    +        version: parseVersion(out),
    +        updateAvailable: parsed.updateAvailable,
    +        behindCommits: parsed.behindCommits,
    +        checkedAt,
    +        error: parsed.unsupported
    +          ? "Installed hermes version does not support `hermes update --check` — update hermes manually once to enable monitoring."
    +          : parsed.error ?? null,
    +      };
    +    } catch (err) {
    +      const msg = err instanceof Error ? err.message : String(err);
    +      if (/command not found|no such file|no such file or directory/i.test(msg)) {
    +        return notInstalled;
    +      }
    +      return {
    +        installed: true,
    +        version: null,
    +        updateAvailable: null,
    +        behindCommits: null,
    +        checkedAt,
    +        error: msg,
    +      };
    +    }
    +  }
    +
    +  /**
    +   * Pending commits on the target's hermes checkout (HEAD..origin/main). Uses
    +   * git directly on the machine (host git via the namespace for local Sparks) so
    +   * the update dialog can show exactly what an update would bring instead of a
    +   * release changelog that may not correspond to any real version bump.
    +   * Never throws.
    +   * @returns {Promise} { count, headSha, commits: {sha,title}[] }
    +   */
    +  async pendingCommits() {
    +    const user = this.spark.ssh?.user || "root";
    +    const repo = `/home/${user}/.hermes/hermes-agent`;
    +    const script = [
    +      `REPO='${repo}'`,
    +      `git -C "$REPO" fetch origin --quiet 2>/dev/null || true`,
    +      `echo __COMMITS__`,
    +      `git -C "$REPO" log -30 --oneline --format=%H%x09%s HEAD..origin/main 2>/dev/null`,
    +      `echo __COUNT__`,
    +      `git -C "$REPO" rev-list --count HEAD..origin/main 2>/dev/null`,
    +      `echo __HEAD__`,
    +      `git -C "$REPO" rev-parse --short HEAD 2>/dev/null`,
    +      `exit 0`,
    +    ].join("; ");
    +    try {
    +      const out = await this._run(script, 45000);
    +      return parsePendingCommits(out);
    +    } catch {
    +      return null;
    +    }
    +  }
    +
    +  /**
    +   * Run `hermes update`. Exits 0 via the wrapper regardless of hermes's result;
    +   * success is judged from the output text.
    +   * @returns {Promise} ok / installed / version / error / output / startedAt / finishedAt
    +   */
    +  /**
    +   * Rebuild the venv entry point directly when the launcher is broken
    +   * (hermes exists but its venv script is gone). Mirrors what `hermes update`
    +   * would do, without needing hermes to launch. Best-effort: judged by a
    +   * subsequent run, never throws.
    +   * @returns {Promise} true when the launcher became usable again
    +   */
    +  async _repairVenv() {
    +    const user = this.spark.ssh?.user || "root";
    +    const venv = `/home/${user}/.hermes/hermes-agent/venv`;
    +    const script = [
    +      `UV='/home/${user}/.hermes/bin/uv'`,
    +      `if [ ! -x "$UV" ]; then UV=$(command -v uv 2>/dev/null); fi`,
    +      `if [ -z "$UV" ] || [ ! -x "$UV" ]; then echo '__UV_MISSING__'; exit 0; fi`,
    +      `cd '/home/${user}/.hermes/hermes-agent' && VIRTUAL_ENV='${venv}' "$UV" pip install -e . >/dev/null 2>&1`,
    +      `exit 0`,
    +    ].join("; ");
    +    try {
    +      await this._run(script, HERMES_UPDATE_TIMEOUT_MS);
    +    } catch {
    +      /* fall through — judged by the retry below */
    +    }
    +    try {
    +      const out = await this._run(buildHermesCmd(this.spark, "exit 0"), 20000);
    +      return !out.includes(HERMES_LAUNCH_FAIL);
    +    } catch {
    +      return false;
    +    }
    +  }
    +
    +  async update() {
    +    const startedAt = Date.now();
    +    const finishedAt = () => Date.now();
    +    try {
    +      let out = await this._run(
    +        buildHermesCmd(this.spark, UPDATE_ACTION),
    +        HERMES_UPDATE_TIMEOUT_MS
    +      );
    +      if (out.includes(HERMES_LAUNCH_FAIL)) {
    +        // hermes cannot even start (usually a lost venv entry point). Repair
    +        // the venv directly, then retry the update once.
    +        await this._repairVenv();
    +        out = await this._run(
    +          buildHermesCmd(this.spark, UPDATE_ACTION),
    +          HERMES_UPDATE_TIMEOUT_MS
    +        );
    +      }
    +      if (out.includes(HERMES_MISSING)) {
    +        return {
    +          ok: false,
    +          installed: false,
    +          version: null,
    +          error: "hermes binary not found on the machine",
    +          output: out.slice(-2000),
    +          startedAt,
    +          finishedAt: finishedAt(),
    +        };
    +      }
    +      const launchBroken = out.includes(HERMES_LAUNCH_FAIL);
    +      const looksFailed =
    +        launchBroken ||
    +        (/error|traceback|failed/i.test(out) && !/already up to date/i.test(out));
    +      return {
    +        ok: !looksFailed,
    +        installed: true,
    +        version: parseVersion(out),
    +        error: launchBroken
    +          ? "hermes is installed but cannot launch (broken install — the automatic repair could not fix it; reinstall hermes manually)."
    +          : looksFailed
    +            ? out.trim().slice(-500) || "hermes update reported an error"
    +            : null,
    +        output: out.slice(-2000),
    +        startedAt,
    +        finishedAt: finishedAt(),
    +      };
    +    } catch (err) {
    +      return {
    +        ok: false,
    +        installed: true,
    +        version: null,
    +        error: err instanceof Error ? err.message : String(err),
    +        output: null,
    +        startedAt,
    +        finishedAt: finishedAt(),
    +      };
    +    }
    +  }
    +}
    diff --git a/server/collectors/HermesReleases.js b/server/collectors/HermesReleases.js
    new file mode 100644
    index 00000000..545915f6
    --- /dev/null
    +++ b/server/collectors/HermesReleases.js
    @@ -0,0 +1,122 @@
    +/**
    + * HermesReleases — release + pending-commit helpers for the Hermes update
    + * confirmation dialog.
    + *
    + * Hermes updates from `origin/main` (git), not from tagged GitHub releases. A
    + * spark is often "N commits behind main" without any new tagged release, so the
    + * dialog must show what would actually change: the pending commits. The latest
    + * GitHub release is still fetched (cached) so we can detect a real version bump
    + * and show the release changelog only in that case.
    + */
    +const GITHUB_API_URL =
    +  "https://api.github.com/repos/NousResearch/hermes-agent/releases/latest";
    +const FETCH_TIMEOUT_MS = 8000;
    +const RELEASES_CACHE_MS = parseInt(
    +  process.env.HERMES_RELEASES_CACHE_MS || "1800000",
    +  10
    +);
    +
    +let cache = { at: 0, release: null };
    +
    +/**
    + * Pure mapper over the GitHub release payload shape (exported for tests).
    + * Accepts only strings — never throws on a malformed payload.
    + * @param {unknown} data
    + */
    +export function parseRelease(data) {
    +  if (!data || typeof data !== "object") return null;
    +  const tagName = typeof data.tag_name === "string" ? data.tag_name : "";
    +  const name = typeof data.name === "string" ? data.name : tagName;
    +  // Semantic version (e.g. "0.20.0") extracted from the release name/tag so the
    +  // dialog can tell "update to the same version's later commits" apart from a
    +  // real release bump (hermes versions are v0.x.y, tags are date-formatted).
    +  const m = /\bv?(\d+\.\d+(?:\.\d+)*)/.exec(name || tagName);
    +  return {
    +    tagName,
    +    name,
    +    version: tagName.replace(/^v/i, ""),
    +    semver: m ? m[1].replace(/^v/i, "") : null,
    +    publishedAt: typeof data.published_at === "string" ? data.published_at : null,
    +    htmlUrl:
    +      typeof data.html_url === "string"
    +        ? data.html_url
    +        : `https://github.com/NousResearch/hermes-agent/releases/tag/${tagName}`,
    +    body: typeof data.body === "string" ? data.body : "",
    +  };
    +}
    +
    +/**
    + * Compare two dotted-numeric versions ("1.2.3"). Returns <0 / 0 / >0.
    + * Unparseable segments are treated as 0. Never throws.
    + * @param {string} a
    + * @param {string} b
    + */
    +export function compareSemver(a, b) {
    +  const pa = String(a || "").split(".");
    +  const pb = String(b || "").split(".");
    +  const len = Math.max(pa.length, pb.length);
    +  for (let i = 0; i < len; i++) {
    +    const va = parseInt(pa[i], 10);
    +    const vb = parseInt(pb[i], 10);
    +    const na = Number.isInteger(va) ? va : 0;
    +    const nb = Number.isInteger(vb) ? vb : 0;
    +    if (na !== nb) return na < nb ? -1 : 1;
    +  }
    +  return 0;
    +}
    +
    +/**
    + * Parse the output of the pending-commits git probe into a structured result.
    + * Lines between the __COMMITS__/__COUNT__/__HEAD__ markers are `shasubject`.
    + * @param {string} output
    + */
    +export function parsePendingCommits(output) {
    +  if (typeof output !== "string" || !output) return null;
    +  const section = (marker) => {
    +    const parts = output.split(marker);
    +    if (parts.length < 2) return "";
    +    // Skip the marker's own trailing newline before taking the first line.
    +    const first = parts[1].replace(/^\n/, "").split(/\n/)[0] || "";
    +    return first.trim();
    +  };
    +  const commitBlock = output.split("__COMMITS__")[1]?.split("__COUNT__")[0] || "";
    +  const commits = [];
    +  for (const line of commitBlock.split("\n")) {
    +    const tab = line.indexOf("\t");
    +    if (tab > 0) {
    +      const sha = line.slice(0, tab).trim();
    +      const title = line.slice(tab + 1).trim();
    +      if (sha && title) commits.push({ sha, title });
    +    }
    +  }
    +  const countRaw = section("__COUNT__");
    +  const count = Number.isInteger(parseInt(countRaw, 10)) ? parseInt(countRaw, 10) : null;
    +  return {
    +    count: count !== null ? count : commits.length,
    +    headSha: section("__HEAD__") || null,
    +    commits: commits.slice(0, 30),
    +  };
    +}
    +
    +/**
    + * Latest release (cached). Throws with a descriptive message on network /
    + * API failures so the route can surface them (the dialog still allows updating
    + * without the changelog).
    + * @returns {Promise}
    + */
    +export async function getLatestRelease() {
    +  if (cache.release && Date.now() - cache.at < RELEASES_CACHE_MS) {
    +    return cache.release;
    +  }
    +  const res = await fetch(GITHUB_API_URL, {
    +    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
    +    headers: {
    +      Accept: "application/vnd.github+json",
    +      "User-Agent": "sparkdash",
    +    },
    +  });
    +  if (!res.ok) throw new Error(`GitHub API returned HTTP ${res.status}`);
    +  const release = parseRelease(await res.json());
    +  cache = { at: Date.now(), release };
    +  return release;
    +}
    diff --git a/server/collectors/LlmDaily.js b/server/collectors/LlmDaily.js
    new file mode 100644
    index 00000000..d23a6994
    --- /dev/null
    +++ b/server/collectors/LlmDaily.js
    @@ -0,0 +1,194 @@
    +/**
    + * Daily LLM tok/s rollups (decode + prefill, plus cached/uncached when the backend splits).
    + *
    + * Busy samples only (rate > 0). Persists to config/llm-daily.json, last 30 UTC days.
    + */
    +import fs from "fs";
    +import { LLM_DAILY_JSON_PATH } from "../config.js";
    +import { atomicWrite } from "../util/atomicWrite.js";
    +
    +const MAX_DAYS = 30;
    +const FLUSH_MS = 30_000;
    +const BUSY_EPS = 0.05;
    +
    +function round2(n) {
    +  return Math.round(n * 100) / 100;
    +}
    +
    +function utcDateKey(d = new Date()) {
    +  return d.toISOString().slice(0, 10);
    +}
    +
    +function seriesKey(sparkId, port) {
    +  return `${sparkId}:${port}`;
    +}
    +
    +function emptyDay() {
    +  return {
    +    decodeMax: 0,
    +    decodeSum: 0,
    +    decodeN: 0,
    +    prefillMax: 0,
    +    prefillSum: 0,
    +    prefillN: 0,
    +    cachedPrefillMax: 0,
    +    cachedPrefillSum: 0,
    +    cachedPrefillN: 0,
    +    uncachedPrefillMax: 0,
    +    uncachedPrefillSum: 0,
    +    uncachedPrefillN: 0,
    +    hasSplit: false,
    +  };
    +}
    +
    +function ingest(day, field, value) {
    +  if (value == null || !Number.isFinite(value) || value <= BUSY_EPS) return false;
    +  const v = round2(value);
    +  day[`${field}Max`] = Math.max(day[`${field}Max`] || 0, v);
    +  day[`${field}Sum`] = round2((day[`${field}Sum`] || 0) + v);
    +  day[`${field}N`] = (day[`${field}N`] || 0) + 1;
    +  return true;
    +}
    +
    +function avg(sum, n) {
    +  if (!n) return null;
    +  return round2(sum / n);
    +}
    +
    +function publicDay(date, day) {
    +  if (!day) {
    +    return {
    +      date,
    +      decodeMax: 0,
    +      decodeAvg: null,
    +      prefillMax: 0,
    +      prefillAvg: null,
    +      cachedPrefillMax: null,
    +      cachedPrefillAvg: null,
    +      uncachedPrefillMax: null,
    +      uncachedPrefillAvg: null,
    +    };
    +  }
    +  const split = Boolean(day.hasSplit);
    +  return {
    +    date,
    +    decodeMax: round2(day.decodeMax || 0),
    +    decodeAvg: avg(day.decodeSum, day.decodeN),
    +    prefillMax: round2(day.prefillMax || 0),
    +    prefillAvg: avg(day.prefillSum, day.prefillN),
    +    cachedPrefillMax: split ? round2(day.cachedPrefillMax || 0) : null,
    +    cachedPrefillAvg: split ? avg(day.cachedPrefillSum, day.cachedPrefillN) : null,
    +    uncachedPrefillMax: split ? round2(day.uncachedPrefillMax || 0) : null,
    +    uncachedPrefillAvg: split ? avg(day.uncachedPrefillSum, day.uncachedPrefillN) : null,
    +  };
    +}
    +
    +function pruneSeries(daysByDate) {
    +  const keys = Object.keys(daysByDate).sort();
    +  if (keys.length <= MAX_DAYS) return daysByDate;
    +  const keep = new Set(keys.slice(-MAX_DAYS));
    +  const next = {};
    +  for (const k of keys) {
    +    if (keep.has(k)) next[k] = daysByDate[k];
    +  }
    +  return next;
    +}
    +
    +export class LlmDailyStore {
    +  /**
    +   * @param {string} [filePath]
    +   */
    +  constructor(filePath = LLM_DAILY_JSON_PATH) {
    +    this.filePath = filePath;
    +    /** @type {Record>>} */
    +    this._data = {};
    +    this._dirty = false;
    +    this._flushTimer = null;
    +    this._load();
    +  }
    +
    +  _load() {
    +    try {
    +      if (!fs.existsSync(this.filePath)) return;
    +      const raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
    +      if (raw && typeof raw === "object") this._data = raw;
    +    } catch {
    +      this._data = {};
    +    }
    +  }
    +
    +  _scheduleFlush() {
    +    if (this._flushTimer) return;
    +    this._flushTimer = setTimeout(() => {
    +      this._flushTimer = null;
    +      this.flush();
    +    }, FLUSH_MS);
    +    this._flushTimer.unref?.();
    +  }
    +
    +  flush() {
    +    if (!this._dirty) return;
    +    try {
    +      atomicWrite(this.filePath, JSON.stringify(this._data));
    +      this._dirty = false;
    +    } catch (err) {
    +      console.error("[LlmDaily] write failed:", err.message);
    +    }
    +  }
    +
    +  /**
    +   * @param {string} sparkId
    +   * @param {number} port
    +   * @param {{ available?: boolean, generationTps?: number, prefillTps?: number, cachedPrefillTps?: number|null, uncachedPrefillTps?: number|null }} metrics
    +   * @param {Date} [now]
    +   */
    +  record(sparkId, port, metrics, now = new Date()) {
    +    if (!sparkId || !Number.isInteger(port)) return;
    +    if (!metrics || metrics.available === false) return;
    +
    +    const key = seriesKey(sparkId, port);
    +    const date = utcDateKey(now);
    +    if (!this._data[key]) this._data[key] = {};
    +    if (!this._data[key][date]) this._data[key][date] = emptyDay();
    +    const day = this._data[key][date];
    +
    +    let changed = false;
    +    changed = ingest(day, "decode", metrics.generationTps) || changed;
    +    changed = ingest(day, "prefill", metrics.prefillTps) || changed;
    +    if (metrics.cachedPrefillTps != null || metrics.uncachedPrefillTps != null) {
    +      if (!day.hasSplit) {
    +        day.hasSplit = true;
    +        changed = true;
    +      }
    +      changed = ingest(day, "cachedPrefill", metrics.cachedPrefillTps) || changed;
    +      changed = ingest(day, "uncachedPrefill", metrics.uncachedPrefillTps) || changed;
    +    }
    +
    +    if (!changed) return;
    +    this._data[key] = pruneSeries(this._data[key]);
    +    this._dirty = true;
    +    this._scheduleFlush();
    +  }
    +
    +  /**
    +   * Calendar-aligned last `days` UTC dates (zeros for missing).
    +   * @param {string} sparkId
    +   * @param {number} port
    +   * @param {{ days?: number, now?: Date }} [opts]
    +   */
    +  getSeries(sparkId, port, opts = {}) {
    +    const n = Math.min(MAX_DAYS, Math.max(1, Number(opts.days) || 14));
    +    const now = opts.now instanceof Date ? opts.now : new Date();
    +    const key = seriesKey(sparkId, port);
    +    const stored = this._data[key] || {};
    +    const out = [];
    +    for (let i = n - 1; i >= 0; i--) {
    +      const d = new Date(now.getTime() - i * 86400000);
    +      const date = utcDateKey(d);
    +      out.push(publicDay(date, stored[date]));
    +    }
    +    return { sparkId, port, days: out };
    +  }
    +}
    +
    +export const llmDaily = new LlmDailyStore();
    diff --git a/server/collectors/LlmProbe.js b/server/collectors/LlmProbe.js
    index 53e45adc..2d29858f 100644
    --- a/server/collectors/LlmProbe.js
    +++ b/server/collectors/LlmProbe.js
    @@ -10,6 +10,9 @@ import { llmProbeHost } from "./llmHost.js";
     
     const FAIL_RESET_THRESHOLD = 3;
     const REDETECT_INTERVAL_MS = 60_000;
    +/** Current SGLang names first. Deprecated aliases still work but log a warning per hit. */
    +const SGLANG_SERVER_INFO_PATHS = ["/server_info", "/get_server_info"];
    +const SGLANG_MODEL_INFO_PATHS = ["/model_info", "/get_model_info"];
     /**
      * SGLang's last_gen_throughput is a sticky gauge (holds last decode rate when
      * idle). Only treat it as live after we observe a change between polls, and
    @@ -63,7 +66,7 @@ export class LlmProbe {
         this.baseUrl = `http://${llmProbeHost(spark)}:${port}`;
     
         // State
    -    this.backendType = null; // 'vllm' | 'llama.cpp' | 'sglang' | 'ds4' | null
    +    this.backendType = null; // 'vllm' | 'llama.cpp' | 'sglang' | 'ds4' | 'exl3' | null
         this.serverIsOpenAI = null; // true = OpenAI-compatible
         /** Whether /v1/models (or /slots) answered without credentials. null = unknown. */
         this.authOpen = null;
    @@ -76,11 +79,23 @@ export class LlmProbe {
         this.slotsTotal = 0;
         this.generationTps = 0;
         this.prefillTps = 0;
    +    /** Live cached-prefill tok/s when the backend splits kinds (ds4 / llama.cpp / sglang). null otherwise. */
    +    this.cachedPrefillTps = null;
    +    /** Live uncached/computed prefill tok/s when split is available. null otherwise. */
    +    this.uncachedPrefillTps = null;
         this.error = null;
     
         // Per-slot rate tracking (for llama.cpp native path)
         this.slotState = new Map();
         this.lastTokenCounts = { input: 0, output: 0 };
    +    /** Previous prefill counters by kind; null until first labeled sample. */
    +    this.lastPrefillKinds = null;
    +    /** Previous vLLM TTFT histogram `_sum` (seconds). null until first sample. */
    +    this.lastTtftSum = null;
    +    /** Previous vLLM TTFT histogram `_count` (requests). null until first sample. */
    +    this.lastTtftCount = null;
    +    /** Previous `vllm:iteration_tokens_total_sum` (engine-step tokens). */
    +    this.lastIterSum = null;
         this.lastProbeTime = 0;
     
         // Cumulative total output tokens (generation) as reported by the LLM server
    @@ -92,6 +107,8 @@ export class LlmProbe {
         this.requestsRunning = null;
         this.requestsWaiting = null;
         this.ttftP95Seconds = null;
    +    /** Live recent-window mean TTFT (seconds) from histogram sum/count deltas. null when unavailable. */
    +    this.ttftSeconds = null;
         this.preemptionsTotal = null; // cumulative counter
         /** Prefix cache hit rate 0–1 (hits/queries). */
         this.prefixCacheHitRate = null;
    @@ -108,6 +125,50 @@ export class LlmProbe {
         this._sglangStickyTps = null;
       }
     
    +  /**
    +   * Prefill is a short burst; decode then runs with Δprompt=0.
    +   * Keep the last real prefill rate while the engine is still in-flight, then 0.
    +   * @param {number} rate
    +   * @param {boolean} inflight
    +   */
    +  _setPrefillTps(rate, inflight) {
    +    const rounded = Math.max(0, Math.round(rate * 100) / 100);
    +    if (rounded > 0) this.prefillTps = rounded;
    +    else if (!inflight) this.prefillTps = 0;
    +  }
    +
    +  /**
    +   * Live cached vs computed prefill tok/s from cumulative counters.
    +   * First sample seeds the baseline (0 tok/s). Missing either side clears the split.
    +   * @param {number|null|undefined} cachedCount
    +   * @param {number|null|undefined} computedCount
    +   * @param {number} dtSec
    +   */
    +  _setPrefillSplitRates(cachedCount, computedCount, dtSec) {
    +    if (cachedCount == null || computedCount == null || !Number.isFinite(cachedCount) || !Number.isFinite(computedCount)) {
    +      this.cachedPrefillTps = null;
    +      this.uncachedPrefillTps = null;
    +      this.lastPrefillKinds = null;
    +      return;
    +    }
    +    const total = cachedCount + computedCount;
    +    this.prefixCacheHitRate =
    +      total > 0 ? Math.round((cachedCount / total) * 10000) / 10000 : null;
    +    if (this.lastPrefillKinds == null) {
    +      this.lastPrefillKinds = { cached: cachedCount, computed: computedCount };
    +      this.cachedPrefillTps = 0;
    +      this.uncachedPrefillTps = 0;
    +      return;
    +    }
    +    if (dtSec > 0 && dtSec < 10) {
    +      const dCached = cachedCount - this.lastPrefillKinds.cached;
    +      const dComputed = computedCount - this.lastPrefillKinds.computed;
    +      this.cachedPrefillTps = Math.max(0, Math.round((dCached / dtSec) * 100) / 100);
    +      this.uncachedPrefillTps = Math.max(0, Math.round((dComputed / dtSec) * 100) / 100);
    +    }
    +    this.lastPrefillKinds = { cached: cachedCount, computed: computedCount };
    +  }
    +
       /** Update probe port (and host from spark). Resets detection when the target changes. */
       setPort(port) {
         const next = Number(port);
    @@ -174,6 +235,8 @@ export class LlmProbe {
         this.modelPath = null;
         this.generationTps = 0;
         this.prefillTps = 0;
    +    this.cachedPrefillTps = null;
    +    this.uncachedPrefillTps = null;
         this.contextLength = null;
         this.gpuMemoryUtilization = null;
         this.slotsActive = 0;
    @@ -183,6 +246,7 @@ export class LlmProbe {
         this.requestsRunning = null;
         this.requestsWaiting = null;
         this.ttftP95Seconds = null;
    +    this.ttftSeconds = null;
         this.preemptionsTotal = null;
         this.prefixCacheHitRate = null;
         this.e2eP95Seconds = null;
    @@ -190,6 +254,10 @@ export class LlmProbe {
         this.mtpAcceptanceRate = null;
         this.slotState.clear();
         this.lastTokenCounts = { input: 0, output: 0 };
    +    this.lastPrefillKinds = null;
    +    this.lastTtftSum = null;
    +    this.lastTtftCount = null;
    +    this.lastIterSum = null;
         this._sglangStickyTps = null;
       }
     
    @@ -216,7 +284,8 @@ export class LlmProbe {
         if (
           this.backendType !== "vllm" &&
           this.backendType !== "sglang" &&
    -      this.backendType !== "ds4"
    +      this.backendType !== "ds4" &&
    +      this.backendType !== "exl3"
         ) {
           const slotUrl = `${this.baseUrl}/slots`;
           try {
    @@ -263,33 +332,67 @@ export class LlmProbe {
       }
     
       /**
    -   * Classify an OpenAI-compatible server: ds4-server, SGLang, or vLLM (default).
    +   * Classify an OpenAI-compatible server: ds4, SGLang, EXL3, or vLLM (default).
        * @param {unknown} ownedBy
    -   * @returns {Promise<"ds4" | "sglang" | "vllm">}
    +   * @returns {Promise<"ds4" | "sglang" | "exl3" | "vllm">}
        */
       async _classifyOpenAIBackend(ownedBy) {
         if (typeof ownedBy === "string") {
           if (/ds4/i.test(ownedBy)) return "ds4";
           if (/sglang/i.test(ownedBy)) return "sglang";
    +      if (/exl3/i.test(ownedBy)) return "exl3";
         }
         if (await this._probeIsDs4()) return "ds4";
         if (await this._probeIsSglang()) return "sglang";
    +    if (await this._probeIsExl3()) return "exl3";
         return "vllm";
       }
     
    -  /** True when SGLang native server-info endpoints respond. */
    -  async _probeIsSglang() {
    -    for (const path of ["/get_server_info", "/server_info"]) {
    +  /**
    +   * True when EXL3 `tools/serve_openai.py` /health exposes backend + token totals.
    +   * Distinguishes from vLLM's /health (no `busy` + `completion_tokens_total`).
    +   */
    +  async _probeIsExl3() {
    +    try {
    +      const res = await this._fetch(`${this.baseUrl}/health`);
    +      if (!res.ok) return false;
    +      const data = await res.json().catch(() => null);
    +      return LlmProbe._healthLooksLikeExl3(data);
    +    } catch {
    +      return false;
    +    }
    +  }
    +
    +  /** @param {unknown} data */
    +  static _healthLooksLikeExl3(data) {
    +    if (!data || typeof data !== "object" || Array.isArray(data)) return false;
    +    if (data.backend === "exl3") return true;
    +    // tools/serve_openai.py: { ok, busy } even before token counters existed.
    +    return data.ok === true && typeof data.busy === "boolean";
    +  }
    +
    +  /**
    +   * First successful SGLang JSON from `paths` (current name, then deprecated alias).
    +   * @param {string[]} paths
    +   * @returns {Promise}
    +   */
    +  async _fetchSglangJson(paths) {
    +    for (const path of paths) {
           try {
             const res = await this._fetch(`${this.baseUrl}${path}`);
             if (!res.ok) continue;
             const data = await res.json().catch(() => null);
    -        if (data && typeof data === "object" && !Array.isArray(data)) return true;
    +        if (data && typeof data === "object" && !Array.isArray(data)) return data;
           } catch {
             /* try next */
           }
         }
    -    return false;
    +    return null;
    +  }
    +
    +  /** True when SGLang native server-info endpoints respond. */
    +  async _probeIsSglang() {
    +    return (await this._fetchSglangJson(SGLANG_SERVER_INFO_PATHS)) != null;
       }
     
       /** True when Prometheus /metrics exposes ds4-server series (ds4-on-spark). */
    @@ -347,32 +450,58 @@ export class LlmProbe {
           if (/ds4/i.test(owned)) this.backendType = "ds4";
           else if (/sglang/i.test(owned) && this.backendType !== "ds4") {
             this.backendType = "sglang";
    +      } else if (/exl3/i.test(owned) && this.backendType !== "ds4") {
    +        this.backendType = "exl3";
           }
         }
     
    -    // SGLang: native info endpoints. Skip on known vLLM/ds4 to avoid 404 spam.
    -    if (this.backendType === "sglang" || this.backendType == null) {
    +    // EXL3 serve_openai.py: live tok/s from /health cumulative counters (no Prometheus).
    +    if (this.backendType === "exl3" || this.backendType == null) {
           try {
    -        const sgRes = await this._fetch(`${this.baseUrl}/get_server_info`);
    -        if (sgRes.ok) {
    -          this.backendType = "sglang";
    -          const sgData = await sgRes.json();
    -          this._applySglangServerInfo(sgData, dtSec);
    +        const healthRes = await this._fetch(`${this.baseUrl}/health`);
    +        if (healthRes.ok) {
    +          const health = await healthRes.json().catch(() => null);
    +          if (LlmProbe._healthLooksLikeExl3(health)) {
    +            this.backendType = "exl3";
    +            this._applyExl3Health(health, dtSec);
    +            return this._getSnapshot();
    +          }
             }
    -      } catch {}
    +      } catch {
    +        /* health optional unless already classified exl3 */
    +      }
    +      if (this.backendType === "exl3") return this._getSnapshot();
    +    }
    +
    +    // SGLang: native info endpoints. Skip on known vLLM/ds4 to avoid 404 spam.
    +    // Prefer /server_info — /get_server_info is a deprecated alias that logs every poll.
    +    if (this.backendType === "sglang" || this.backendType == null) {
    +      const sgData = await this._fetchSglangJson(SGLANG_SERVER_INFO_PATHS);
    +      if (sgData) {
    +        this.backendType = "sglang";
    +        // Load before last_gen_throughput so inflight can keep a steady rate live.
    +        await this._probeSglangLoad();
    +        this._applySglangServerInfo(sgData, dtSec);
    +        // Engine tile: Active vs Sleeping. SGLang has no live sleep gauge
    +        // (sleep_on_idle is a launch flag, not current state). A reachable
    +        // server with weights resident is Active / ready.
    +        if (this.gpuMemoryUtilization == null) this.gpuMemoryUtilization = 1;
    +      }
         }
     
         if (this.backendType === "sglang") {
    -      // Optional Prometheus path when launched with --enable-metrics
    -      if (this.generationTps === 0 && this.prefillTps === 0) {
    -        try {
    -          const metricsRes = await this._fetch(`${this.baseUrl}/metrics`);
    -          if (metricsRes.ok) {
    -            this._applySglangMetrics(await metricsRes.text(), dtSec);
    -          }
    -        } catch {
    -          /* metrics optional */
    +      // Prometheus is optional (--enable-metrics). Do not mix those counters
    +      // into lastTokenCounts when server-info already produced live rates.
    +      try {
    +        const metricsRes = await this._fetch(`${this.baseUrl}/metrics`);
    +        if (metricsRes.ok) {
    +          const txt = await metricsRes.text();
    +          const idle = this.generationTps === 0 && this.prefillTps === 0;
    +          if (idle) this._applySglangMetrics(txt, dtSec);
    +          else this._applySglangPrefillSplit(txt, dtSec);
             }
    +      } catch {
    +        /* metrics optional */
           }
           await this._enrichSglangModelInfo();
           return this._getSnapshot();
    @@ -393,11 +522,11 @@ export class LlmProbe {
               this.backendType = "vllm";
               this._applyVllmMetrics(txt, dtSec);
             }
    -      } else if (this.backendType !== "ds4") {
    +      } else if (this.backendType !== "ds4" && this.backendType !== "exl3") {
             this.backendType = "vllm";
           }
         } catch {
    -      if (this.backendType !== "ds4") this.backendType = "vllm";
    +      if (this.backendType !== "ds4" && this.backendType !== "exl3") this.backendType = "vllm";
         }
     
         return this._getSnapshot();
    @@ -413,7 +542,16 @@ export class LlmProbe {
        */
       _applyDs4Metrics(txt, dtSec) {
         const decoded = this._getPromMetric(txt, "ds4_tokens_decoded_total");
    -    const prefilled = this._getPromMetric(txt, "ds4_tokens_prefilled_total");
    +    const computedPrefill = this._getPromMetricLabeled(
    +      txt,
    +      "ds4_tokens_prefilled_total",
    +      "kind",
    +      "computed"
    +    );
    +    const prefilled =
    +      computedPrefill ?? this._getPromMetric(txt, "ds4_tokens_prefilled_total");
    +    const inflightHint = this._getPromMetric(txt, "ds4_requests_inflight");
    +    const inflight = inflightHint != null && inflightHint > 0;
     
         if (decoded != null) {
           if (prefilled != null && dtSec > 0 && dtSec < 10) {
    @@ -424,16 +562,16 @@ export class LlmProbe {
           } else if (dtSec > 0 && dtSec < 10) {
             const deltaOut = decoded - this.lastTokenCounts.output;
             this.generationTps = Math.max(0, Math.round((deltaOut / dtSec) * 100) / 100);
    +        if (!inflight && deltaOut <= 0) this.prefillTps = 0;
           }
           if (prefilled != null) this.lastTokenCounts.input = prefilled;
           this.lastTokenCounts.output = decoded;
           this.totalOutputTokens = decoded;
         } else {
           // No counters — fall back to window gauges only while something is in flight
    -      const inflightHint = this._getPromMetric(txt, "ds4_requests_inflight");
           const gaugeGen = this._getPromMetric(txt, "ds4_decode_tok_s");
           const gaugePrefill = this._getPromMetric(txt, "ds4_prefill_tok_s");
    -      if (inflightHint != null && inflightHint > 0) {
    +      if (inflight) {
             if (gaugeGen != null) {
               this.generationTps = Math.max(0, Math.round(gaugeGen * 100) / 100);
             }
    @@ -446,9 +584,9 @@ export class LlmProbe {
           }
         }
     
    -    const inflight = this._getPromMetric(txt, "ds4_requests_inflight");
    -    this.requestsRunning = inflight;
    -    if (inflight != null) this.slotsActive = Math.round(inflight);
    +    const inflightCount = this._getPromMetric(txt, "ds4_requests_inflight");
    +    this.requestsRunning = inflightCount;
    +    if (inflightCount != null) this.slotsActive = Math.round(inflightCount);
     
         const banksTotal = this._getPromMetric(txt, "ds4_banks_total");
         if (banksTotal != null) this.slotsTotal = Math.round(banksTotal);
    @@ -457,7 +595,7 @@ export class LlmProbe {
         this.mtpAcceptanceRate =
           specAccept != null ? Math.round(specAccept * 10000) / 10000 : null;
     
    -    // Prefix-cache hit rate from prefill kind labels (cached / (computed+cached))
    +    // Prefix-cache hit rate + live tok/s from prefill kind labels
         const cached = this._getPromMetricLabeled(
           txt,
           "ds4_tokens_prefilled_total",
    @@ -470,23 +608,71 @@ export class LlmProbe {
           "kind",
           "computed"
         );
    -    if (cached != null && computed != null) {
    -      const total = cached + computed;
    -      this.prefixCacheHitRate =
    -        total > 0 ? Math.round((cached / total) * 10000) / 10000 : null;
    -    } else {
    -      this.prefixCacheHitRate = null;
    -    }
    +    this._setPrefillSplitRates(cached, computed, dtSec);
     
         // Clear tiles that are vLLM-histogram-specific (no ds4 equivalent yet)
         this.kvCacheUsage = null;
         this.requestsWaiting = null;
         this.ttftP95Seconds = null;
    +    this.ttftSeconds = null;
         this.preemptionsTotal = null;
         this.e2eP95Seconds = null;
         this.itlP95Seconds = null;
       }
     
    +  /**
    +   * Apply EXL3 tools/serve_openai.py GET /health.
    +   * Live tok/s from cumulative counter diffs so idle → 0.
    +   * @param {Record} data
    +   * @param {number} dtSec
    +   */
    +  _applyExl3Health(data, dtSec) {
    +    const prompt = Number(data?.prompt_tokens_total);
    +    const completion = Number(data?.completion_tokens_total);
    +    const busy = data?.busy === true;
    +    const ctx = Number(data?.context_length);
    +    if (Number.isFinite(ctx) && ctx > 0) this.contextLength = Math.round(ctx);
    +
    +    this.requestsRunning = busy ? 1 : 0;
    +    this.slotsActive = busy ? 1 : 0;
    +    this.slotsTotal = 1;
    +    this.kvCacheUsage = null;
    +    this.requestsWaiting = null;
    +    this.ttftP95Seconds = null;
    +    this.ttftSeconds = null;
    +    this.preemptionsTotal = null;
    +    this.prefixCacheHitRate = null;
    +    this.e2eP95Seconds = null;
    +    this.itlP95Seconds = null;
    +    this.mtpAcceptanceRate = null;
    +    this.cachedPrefillTps = null;
    +    this.uncachedPrefillTps = null;
    +
    +    if (!Number.isFinite(completion)) {
    +      this.generationTps = busy ? this.generationTps : 0;
    +      if (!busy) this.prefillTps = 0;
    +      return;
    +    }
    +
    +    if (dtSec > 0 && dtSec < 10) {
    +      const deltaOut = completion - this.lastTokenCounts.output;
    +      this.generationTps = Math.max(0, Math.round((deltaOut / dtSec) * 100) / 100);
    +      if (Number.isFinite(prompt)) {
    +        const deltaIn = prompt - this.lastTokenCounts.input;
    +        this.prefillTps = Math.max(0, Math.round((deltaIn / dtSec) * 100) / 100);
    +      } else if (!busy && this.generationTps <= 0) {
    +        this.prefillTps = 0;
    +      }
    +    } else if (!busy) {
    +      this.generationTps = 0;
    +      this.prefillTps = 0;
    +    }
    +
    +    if (Number.isFinite(prompt)) this.lastTokenCounts.input = prompt;
    +    this.lastTokenCounts.output = completion;
    +    this.totalOutputTokens = completion;
    +  }
    +
       /**
        * Apply stock vLLM Prometheus /metrics (tok/s + inference tiles).
        * @param {string} txt
    @@ -495,19 +681,59 @@ export class LlmProbe {
       _applyVllmMetrics(txt, dtSec) {
         const promptTokens = this._getVllmMetric(txt, "prompt_tokens_total");
         const genTokens = this._getVllmMetric(txt, "generation_tokens_total");
    +    const running = this._getVllmMetric(txt, "num_requests_running");
    +    const iterSum = this._getVllmMetric(txt, "iteration_tokens_total_sum");
         if (promptTokens != null && genTokens != null) {
           const deltaIn = promptTokens - this.lastTokenCounts.input;
           const deltaOut = genTokens - this.lastTokenCounts.output;
           this.lastTokenCounts.input = promptTokens;
           this.lastTokenCounts.output = genTokens;
           this.totalOutputTokens = genTokens;
    +      const ttftSum = this._getVllmMetric(txt, "time_to_first_token_seconds_sum");
    +      const deltaIter =
    +        iterSum != null && this.lastIterSum != null ? iterSum - this.lastIterSum : 0;
           if (dtSec > 0 && dtSec < 10) {
             this.generationTps = Math.max(0, Math.round((deltaOut / dtSec) * 100) / 100);
    -        this.prefillTps = Math.max(0, Math.round((deltaIn / dtSec) * 100) / 100);
    +        const deltaTtft =
    +          ttftSum != null && this.lastTtftSum != null ? ttftSum - this.lastTtftSum : 0;
    +        // Engine-step tokens include prefill+decode. Surplus over generation is
    +        // prefill, including the common case where a short/cached prefill lands
    +        // in the same poll as the first decode tokens.
    +        const prefillIter = Math.max(0, deltaIter - Math.max(0, deltaOut));
    +        const specNoise = deltaOut > 0 && prefillIter > 0 && prefillIter < deltaOut * 0.5;
    +        const livePrefill =
    +          prefillIter > 0 && !specNoise ? prefillIter / dtSec : 0;
    +        const finishedPrefill =
    +          deltaIn > 0 && deltaTtft > 0
    +            ? deltaIn / deltaTtft
    +            : deltaIn > 0 && livePrefill <= 0
    +              ? deltaIn / dtSec
    +              : 0;
    +        this.prefillTps = Math.max(
    +          0,
    +          Math.round((livePrefill > 0 ? livePrefill : finishedPrefill) * 100) / 100
    +        );
    +      }
    +      // Live mean TTFT over the last poll window from histogram sum/count deltas.
    +      // Computed BEFORE lastTtftSum is advanced so the delta is real, not 0.
    +      const ttftCount = this._getVllmMetric(txt, "time_to_first_token_seconds_count");
    +      if (ttftCount != null) {
    +        const deltaSum =
    +          ttftSum != null && this.lastTtftSum != null ? ttftSum - this.lastTtftSum : null;
    +        const deltaCount =
    +          this.lastTtftCount != null ? ttftCount - this.lastTtftCount : null;
    +        this.ttftSeconds =
    +          deltaSum != null && deltaCount != null && deltaCount > 0 && deltaSum >= 0
    +            ? Math.round((deltaSum / deltaCount) * 1000) / 1000
    +            : null;
    +        this.lastTtftCount = ttftCount;
    +      } else {
    +        this.ttftSeconds = null;
           }
    +      if (ttftSum != null) this.lastTtftSum = ttftSum;
         }
    +    if (iterSum != null) this.lastIterSum = iterSum;
     
    -    const running = this._getVllmMetric(txt, "num_requests_running");
         this.requestsRunning = running;
         if (running != null) this.slotsActive = Math.round(running);
     
    @@ -548,7 +774,7 @@ export class LlmProbe {
       }
     
       /**
    -   * Apply SGLang /get_server_info.
    +   * Apply SGLang /server_info (or deprecated /get_server_info).
        * Older builds expose total_input_tokens / total_output_tokens.
        * Current builds (metrics often off) expose sticky last_gen_throughput under
        * internal_states[i]. Only treat it as live after the value changes between
    @@ -594,25 +820,109 @@ export class LlmProbe {
             this.totalOutputTokens = output;
             if (dtSec > 0 && dtSec < 10) {
               this.generationTps = Math.max(0, Math.round((deltaOut / dtSec) * 100) / 100);
    -          this.prefillTps = Math.max(0, Math.round((deltaIn / dtSec) * 100) / 100);
    +          this._setPrefillTps(deltaIn / dtSec, deltaOut > 0);
             }
             return;
           }
         }
     
    -    // No cumulative counters — sticky last_gen_throughput only while it moves
    +    // No cumulative counters — sticky last_gen_throughput only while it moves,
    +    // unless /v1/loads (or /get_load) says requests are in flight.
         const lastGen = LlmProbe._sglangLastGenThroughput(sgData);
    -    this.generationTps = this._sglangStickyThroughput(lastGen);
    +    this.generationTps = this._sglangStickyThroughput(lastGen, this._sglangInflight());
    +  }
    +
    +  /** True when SGLang load probe reported running or waiting requests. */
    +  _sglangInflight() {
    +    return (
    +      this.slotsActive > 0 ||
    +      (this.requestsRunning != null && this.requestsRunning > 0) ||
    +      (this.requestsWaiting != null && this.requestsWaiting > 0)
    +    );
    +  }
    +
    +  /**
    +   * Prefer /v1/loads (num_running_reqs). Fall back to /get_load, where
    +   * num_reqs is running + waiting.
    +   */
    +  async _probeSglangLoad() {
    +    for (const path of ["/v1/loads", "/get_load"]) {
    +      try {
    +        const res = await this._fetch(`${this.baseUrl}${path}`);
    +        if (!res.ok) continue;
    +        const data = await res.json().catch(() => null);
    +        if (this._applySglangLoad(data)) return;
    +      } catch {
    +        /* try next */
    +      }
    +    }
    +  }
    +
    +  /**
    +   * Apply SGLang /v1/loads or /get_load. Returns true when a row was applied.
    +   * @param {unknown} payload
    +   * @returns {boolean}
    +   */
    +  _applySglangLoad(payload) {
    +    const rows = LlmProbe._sglangLoadRows(payload);
    +    if (!rows.length) return false;
    +    let running = 0;
    +    let waiting = 0;
    +    let saw = false;
    +    for (const row of rows) {
    +      const wait = Number(row.num_waiting_reqs);
    +      const runDirect = Number(row.num_running_reqs);
    +      const total = Number(row.num_reqs);
    +      if (Number.isFinite(wait) && wait >= 0) {
    +        waiting += wait;
    +        saw = true;
    +      }
    +      if (Number.isFinite(runDirect) && runDirect >= 0) {
    +        running += runDirect;
    +        saw = true;
    +      } else if (Number.isFinite(total) && total >= 0) {
    +        const waitPart = Number.isFinite(wait) && wait >= 0 ? wait : 0;
    +        running += Math.max(0, total - waitPart);
    +        saw = true;
    +      }
    +    }
    +    if (!saw) return false;
    +    this.requestsRunning = running;
    +    this.requestsWaiting = waiting;
    +    this.slotsActive = Math.round(running);
    +    return true;
    +  }
    +
    +  /**
    +   * @param {unknown} payload
    +   * @returns {Array>}
    +   */
    +  static _sglangLoadRows(payload) {
    +    if (payload == null) return [];
    +    if (Array.isArray(payload)) {
    +      return payload.filter((row) => row && typeof row === "object");
    +    }
    +    if (typeof payload !== "object") return [];
    +    if (Array.isArray(payload.loads)) {
    +      return payload.loads.filter((row) => row && typeof row === "object");
    +    }
    +    if (payload.num_reqs != null || payload.num_running_reqs != null) {
    +      return [payload];
    +    }
    +    return [];
       }
     
       /**
        * Map SGLang's sticky last_gen_throughput gauge to a live panel rate.
        * Returns 0 until the value changes between polls (avoids showing a stale
        * leftover after idle); stays live for a short window after each change.
    +   * When `inflight` is true (independent load signal), keep a positive rate
    +   * even if the gauge is not moving — a busy decode can report a constant value.
        * @param {number | null} raw
    +   * @param {boolean} [inflight]
        * @returns {number}
        */
    -  _sglangStickyThroughput(raw) {
    +  _sglangStickyThroughput(raw, inflight = false) {
         if (raw == null || !Number.isFinite(raw) || raw < 0) {
           this._sglangStickyTps = null;
           return 0;
    @@ -622,9 +932,12 @@ export class LlmProbe {
         const prev = this._sglangStickyTps;
     
         if (!prev) {
    -      // First sample after reset/start — seed only; do not display stale gauge
    -      this._sglangStickyTps = { value: rounded, liveUntil: 0 };
    -      return 0;
    +      // First sample after reset/start — seed only unless load says we are busy
    +      this._sglangStickyTps = {
    +        value: rounded,
    +        liveUntil: inflight && rounded > 0 ? now + SGLANG_STICKY_TPS_LIVE_MS : 0,
    +      };
    +      return inflight && rounded > 0 ? rounded : 0;
         }
     
         if (rounded !== prev.value) {
    @@ -638,6 +951,9 @@ export class LlmProbe {
         if (prev.liveUntil > now) {
           return rounded;
         }
    +    if (inflight && rounded > 0) {
    +      return rounded;
    +    }
         return 0;
       }
     
    @@ -700,8 +1016,10 @@ export class LlmProbe {
           this.generationTps = Math.max(0, Math.round((deltaOut / dtSec) * 100) / 100);
           if (prompt != null) {
             const deltaIn = prompt - this.lastTokenCounts.input;
    -        this.prefillTps = Math.max(0, Math.round((deltaIn / dtSec) * 100) / 100);
    +        this._setPrefillTps(deltaIn / dtSec, deltaOut > 0);
             this.lastTokenCounts.input = prompt;
    +      } else if (deltaOut <= 0) {
    +        this.prefillTps = 0;
           }
         }
         this.lastTokenCounts.output = gen;
    @@ -714,11 +1032,39 @@ export class LlmProbe {
           this.requestsRunning = running;
           this.slotsActive = Math.round(running);
         }
    +
    +    const cached = this._sglangCachedTokens(txt);
    +    if (cached != null && prompt != null) {
    +      this._setPrefillSplitRates(cached, prompt, dtSec);
    +    }
    +  }
    +
    +  /**
    +   * Cache split only — does not touch generation/prefill lastTokenCounts.
    +   * Prefers cache_source="device" so HiCache L1/L2/L3 labels are not summed.
    +   */
    +  _applySglangPrefillSplit(txt, dtSec) {
    +    const prompt =
    +      this._getPromMetric(txt, "sglang:prompt_tokens_total") ??
    +      this._getPromMetric(txt, "sglang_prompt_tokens_total");
    +    const cached = this._sglangCachedTokens(txt);
    +    if (cached != null && prompt != null) {
    +      this._setPrefillSplitRates(cached, prompt, dtSec);
    +    }
    +  }
    +
    +  _sglangCachedTokens(txt) {
    +    return (
    +      this._getPromMetricLabeled(txt, "sglang:cached_tokens_total", "cache_source", "device") ??
    +      this._getPromMetricLabeled(txt, "sglang_cached_tokens_total", "cache_source", "device") ??
    +      this._getPromMetricMax(txt, "sglang:cached_tokens_total") ??
    +      this._getPromMetricMax(txt, "sglang_cached_tokens_total")
    +    );
       }
     
    -  /** Prefer SGLang /get_model_info (or /model_info) over raw HF cache paths. */
    +  /** Prefer SGLang /model_info (or deprecated /get_model_info) over raw HF cache paths. */
       async _enrichSglangModelInfo() {
    -    for (const path of ["/get_model_info", "/model_info"]) {
    +    for (const path of SGLANG_MODEL_INFO_PATHS) {
           try {
             const res = await this._fetch(`${this.baseUrl}${path}`);
             if (!res.ok) continue;
    @@ -758,12 +1104,21 @@ export class LlmProbe {
               let totalGen = 0;
               let totalPrefill = 0;
               let totalDecoded = 0;
    +          let promptedSum = 0;
    +          let cachedSum = 0;
    +          let sawCache = false;
     
               for (const slot of slots) {
                 const slotId = slot.id ?? "default";
                 const decoded = this._getSlotDecoded(slot);
                 const prompted = this._getSlotPrefilled(slot);
    +            const cached = this._getSlotCached(slot);
                 totalDecoded += decoded;
    +            promptedSum += prompted;
    +            if (cached != null) {
    +              sawCache = true;
    +              cachedSum += cached;
    +            }
                 const lastState = this.slotState.get(slotId) || { decoded: 0, prompted: 0 };
                 const dDecoded = decoded - lastState.decoded;
                 const dPrompted = prompted - lastState.prompted;
    @@ -776,7 +1131,8 @@ export class LlmProbe {
     
               this.totalOutputTokens = totalDecoded;
               this.generationTps = Math.max(0, Math.round(totalGen * 100) / 100);
    -          this.prefillTps = Math.max(0, Math.round(totalPrefill * 100) / 100);
    +          this._setPrefillTps(totalPrefill, totalGen > 0);
    +          if (sawCache) this._setPrefillSplitRates(cachedSum, promptedSum, dtSec);
             }
           }
         } catch {}
    @@ -828,6 +1184,24 @@ export class LlmProbe {
         return found ? sum : null;
       }
     
    +  /**
    +   * Max of Prometheus series matching `name` (avoids summing HiCache layers).
    +   * @param {string} body
    +   * @param {string} name
    +   * @returns {number | null}
    +   */
    +  _getPromMetricMax(body, name) {
    +    const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    +    const re = new RegExp(`^${esc}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)\\s*$`, "gm");
    +    let best = null;
    +    let m;
    +    while ((m = re.exec(body)) !== null) {
    +      const v = parseFloat(m[1]);
    +      if (Number.isFinite(v)) best = best == null ? v : Math.max(best, v);
    +    }
    +    return best;
    +  }
    +
       /**
        * Sum series of `name` whose label `labelKey` equals `labelValue`.
        * @param {string} body
    @@ -930,7 +1304,18 @@ export class LlmProbe {
       }
     
       _getSlotPrefilled(slot) {
    -    return slot.n_prompt_tokens_processed || slot.n_prompt_tokens || 0;
    +    if (slot?.n_prompt_tokens_processed != null) {
    +      const n = Number(slot.n_prompt_tokens_processed);
    +      if (Number.isFinite(n)) return n;
    +    }
    +    return slot?.n_prompt_tokens || 0;
    +  }
    +
    +  /** Cached prompt tokens on a llama.cpp slot, or null when the field is absent. */
    +  _getSlotCached(slot) {
    +    if (slot == null || slot.n_prompt_tokens_cache == null) return null;
    +    const n = Number(slot.n_prompt_tokens_cache);
    +    return Number.isFinite(n) ? n : null;
       }
     
       /**
    @@ -1006,11 +1391,14 @@ export class LlmProbe {
           slotsTotal: this.slotsTotal,
           generationTps: this.generationTps,
           prefillTps: this.prefillTps,
    +      cachedPrefillTps: this.cachedPrefillTps,
    +      uncachedPrefillTps: this.uncachedPrefillTps,
           totalOutputTokens: this.totalOutputTokens,
           kvCacheUsage: this.kvCacheUsage,
           requestsRunning: this.requestsRunning,
           requestsWaiting: this.requestsWaiting,
           ttftP95Seconds: this.ttftP95Seconds,
    +      ttftSeconds: this.ttftSeconds,
           preemptionsTotal: this.preemptionsTotal,
           prefixCacheHitRate: this.prefixCacheHitRate,
           e2eP95Seconds: this.e2eP95Seconds,
    @@ -1033,11 +1421,14 @@ export class LlmProbe {
           slotsTotal: 0,
           generationTps: 0,
           prefillTps: 0,
    +      cachedPrefillTps: null,
    +      uncachedPrefillTps: null,
           totalOutputTokens: 0,
           kvCacheUsage: null,
           requestsRunning: null,
           requestsWaiting: null,
           ttftP95Seconds: null,
    +      ttftSeconds: null,
           preemptionsTotal: null,
           prefixCacheHitRate: null,
           e2eP95Seconds: null,
    diff --git a/server/collectors/LlmStreaming.js b/server/collectors/LlmStreaming.js
    index 7ce64ba1..c7ea7cd8 100644
    --- a/server/collectors/LlmStreaming.js
    +++ b/server/collectors/LlmStreaming.js
    @@ -1,10 +1,12 @@
     /**
    - * Shared OpenAI-compatible SSE streaming helpers used by DecodeBench and Showcase.
    + * Shared OpenAI-compatible SSE streaming helpers used by DecodeBench, PrefillBench, and Showcase.
      *
      * Decode tok/s uses the first visible token → last visible token window
      * (not stream EOF), so trailing usage/[DONE] latency does not drag the rate down.
      */
     
    +import { Agent } from "undici";
    +
     /** Response headers worth keeping for request correlation / debugging. */
     const DEBUG_HEADER_RE =
       /^(x-request-id|x-stainless-|server|date|content-type|openai-|x-envoy-|cf-ray|request-id)$/i;
    @@ -12,6 +14,35 @@ const DEBUG_HEADER_RE =
     /** Truncate streamed content previews stored for debugging. */
     export const CONTENT_PREVIEW_CHARS = 160;
     
    +/**
    + * Undici's default headersTimeout/bodyTimeout is 300s. A 256k prefill that has
    + * not produced a first token (or even response headers) by then is aborted
    + * even when PrefillBench's own timer is 30–45 minutes. 0 disables those idle
    + * cuts; the caller AbortSignal still bounds the request.
    + */
    +export const LLM_STREAM_AGENT = new Agent({
    +  headersTimeout: 0,
    +  bodyTimeout: 0,
    +});
    +
    +/** Map fetch/undici failures to a short UI string. */
    +export function describeStreamFetchError(err) {
    +  if (!err) return "Request failed";
    +  const code = err.code || err.cause?.code;
    +  if (
    +    code === "UND_ERR_HEADERS_TIMEOUT" ||
    +    code === "UND_ERR_BODY_TIMEOUT" ||
    +    err.name === "HeadersTimeoutError" ||
    +    err.name === "BodyTimeoutError"
    +  ) {
    +    return `HTTP idle timeout (${code || err.name}): no data from the LLM for 5 minutes`;
    +  }
    +  if (err.name === "AbortError" || err.name === "TimeoutError") {
    +    return "Request aborted or timed out";
    +  }
    +  return err.message || String(err);
    +}
    +
     export function round2(n) {
       return Math.round(n * 100) / 100;
     }
    @@ -99,21 +130,39 @@ export async function readServerGenerationTokens(baseUrl, opts = {}) {
         /* try next */
       }
     
    -  // SGLang
    +  // EXL3 serve_openai.py — cumulative completion tokens on /health
       try {
    -    const res = await fetch(`${baseUrl}/get_server_info`, {
    +    const res = await fetch(`${baseUrl}/health`, {
           signal: AbortSignal.timeout(5_000),
           headers,
         });
         if (res.ok) {
           const data = await res.json();
    -      if (data?.total_output_tokens != null) {
    -        const v = Number(data.total_output_tokens);
    -        if (Number.isFinite(v)) return v;
    -      }
    +      const v = Number(data?.completion_tokens_total);
    +      if (Number.isFinite(v) && data?.backend === "exl3") return v;
    +      if (Number.isFinite(v) && typeof data?.busy === "boolean") return v;
         }
       } catch {
    -    /* ignore */
    +    /* try next */
    +  }
    +
    +  // SGLang — current /server_info first; /get_server_info is a deprecated alias.
    +  for (const path of ["/server_info", "/get_server_info"]) {
    +    try {
    +      const res = await fetch(`${baseUrl}${path}`, {
    +        signal: AbortSignal.timeout(5_000),
    +        headers,
    +      });
    +      if (res.ok) {
    +        const data = await res.json();
    +        if (data?.total_output_tokens != null) {
    +          const v = Number(data.total_output_tokens);
    +          if (Number.isFinite(v)) return v;
    +        }
    +      }
    +    } catch {
    +      /* try next */
    +    }
       }
     
       return null;
    @@ -144,6 +193,27 @@ export function estimateTokenCount(text) {
       return Math.max(1, Math.round(text.length / 4));
     }
     
    +/** Concatenate string message contents from an OpenAI chat body (prefill fallback). */
    +/** vLLM-oriented fields that some OpenAI-compat servers reject with HTTP 400. */
    +export function stripFillForceFields(body) {
    +  if (!body || typeof body !== "object") return body;
    +  const next = { ...body };
    +  delete next.min_tokens;
    +  delete next.ignore_eos;
    +  delete next.stop;
    +  return next;
    +}
    +
    +export function promptTextFromBody(body) {
    +  const msgs = body?.messages;
    +  if (!Array.isArray(msgs)) return "";
    +  const parts = [];
    +  for (const m of msgs) {
    +    if (typeof m?.content === "string" && m.content) parts.push(m.content);
    +  }
    +  return parts.join("\n");
    +}
    +
     /**
      * Extract visible text pieces from an OpenAI-compatible delta (or choice.text).
      * Counts content + reasoning / reasoning_content so thinking models stay "alive".
    @@ -183,34 +253,52 @@ function extractDeltaPieces(choice) {
       return { answer, reasoning, tokenChunks };
     }
     
    +/** Coerce a request/session thinking flag. Default is off (matches Showcase UI). */
    +export function coerceThinkingFlag(raw) {
    +  return raw === true || raw === "true" || raw === 1 || raw === "1";
    +}
    +
    +function hasThinkingRequestFields(body) {
    +  if (!body || typeof body !== "object") return false;
    +  if (body.chat_template_kwargs && typeof body.chat_template_kwargs === "object") return true;
    +  if ("enable_thinking" in body || "thinking" in body || "thinking_mode" in body) return true;
    +  if ("reasoning_effort" in body || "reasoning_budget" in body) return true;
    +  return false;
    +}
    +
     /**
      * Apply per-model thinking flags so reasoning models don't 400.
    - * MiniMax-M3 needs `thinking_mode`; most others use `enable_thinking`.
    + * Hybrid models (Qwen3, GLM, MiniMax, …) default thinking ON unless we send
    + * disable flags — `enable_thinking` alone is ignored by MiniMax (`thinking_mode`).
      *
      * @param {Record} body
      * @param {string | null | undefined} modelId
    - * @param {boolean} [think=true]
    + * @param {boolean} [think=false]
      */
    -export function applyThinkingFlags(body, modelId, think = true) {
    +export function applyThinkingFlags(body, modelId, think = false) {
       if (!body || typeof body !== "object") return body;
    -  const id = String(modelId || "").toLowerCase();
    +  const on = Boolean(think);
    +  void modelId;
       /** @type {Record} */
       const ctk = {
         ...(body.chat_template_kwargs && typeof body.chat_template_kwargs === "object"
           ? body.chat_template_kwargs
           : {}),
    -    enable_thinking: think,
    +    enable_thinking: on,
    +    // SGLang / some Qwen templates read `thinking` rather than `enable_thinking`.
    +    thinking: on,
    +    // MiniMax (M2 / M2.5 / M3) ignores enable_thinking; always send thinking_mode
    +    // so a missing model id still disables reasoning.
    +    thinking_mode: on ? "enabled" : "disabled",
       };
    -  // MiniMax-M3 (and similarly named) use thinking_mode
    -  if (id.includes("minimax") || /(^|[^a-z])m3([^a-z]|$)/.test(id)) {
    -    ctk.thinking_mode = think ? "enabled" : "disabled";
    -  }
       body.chat_template_kwargs = ctk;
       return body;
     }
     
     /**
    - * Strip thinking-related request fields (for 400 retry).
    + * Strip thinking-related request fields (for 400 retry when *enabling* thinking
    + * on a model that does not accept those fields). Do not use this as a fallback
    + * when the user asked for thinking off — stripping lets the model default ON.
      * @param {Record} body
      */
     export function stripThinkingFlags(body) {
    @@ -219,12 +307,31 @@ export function stripThinkingFlags(body) {
         const ctk = { ...body.chat_template_kwargs };
         delete ctk.enable_thinking;
         delete ctk.thinking_mode;
    +    delete ctk.thinking;
         if (Object.keys(ctk).length) body.chat_template_kwargs = ctk;
         else delete body.chat_template_kwargs;
       }
    +  delete body.enable_thinking;
    +  delete body.thinking;
    +  delete body.thinking_mode;
    +  delete body.reasoning_effort;
    +  delete body.reasoning_budget;
       return body;
     }
     
    +/**
    + * Alternate disable-only payload after HTTP 400 on the full thinking-flag set.
    + * Keeps an explicit off switch instead of stripping (which re-enables default thinking).
    + * @param {Record} body
    + */
    +export function thinkingOffFallbackBody(body) {
    +  const next = stripThinkingFlags({ ...body });
    +  next.chat_template_kwargs = { enable_thinking: false, thinking: false, thinking_mode: "disabled" };
    +  next.thinking = { type: "disabled" };
    +  next.enable_thinking = false;
    +  return next;
    +}
    +
     /**
      * Poll server generation counters the same way live LlmProbe does (Δtokens / Δt).
      * Returns the median of positive samples while generation is active.
    @@ -304,7 +411,10 @@ export async function pollServerGenerationRates(
      * - debug: capture compact HTTP/SSE debug trace
      * - collectContent: accumulate full visible text (showcase)
      * - onDelta: live callback `{ text?, answer?, reasoning?, tokenCount, tFirst, tLast, … }`
    - * - retryOnThinking400: if HTTP 400 and body had thinking flags, retry once stripped
    + * - retryOnThinking400: if HTTP 400 and thinking was *enabled*, retry once stripped
    + *   (non-reasoning models). When thinking is off, retry with a smaller disable
    + *   payload instead of stripping — stripping lets hybrid models think by default.
    + * - thinking: whether the request intended reasoning on (default false)
      * - apiKey: optional Bearer token for OpenAI-compatible gateways
      */
     export async function runStreamingRequest(
    @@ -316,6 +426,7 @@ export async function runStreamingRequest(
         collectContent = false,
         onDelta = null,
         retryOnThinking400 = false,
    +    thinking = false,
         apiKey = null,
       } = {}
     ) {
    @@ -330,11 +441,17 @@ export async function runStreamingRequest(
         retryOnThinking400 &&
         result.error &&
         /^HTTP 400\b/.test(result.error) &&
    -    body &&
    -    typeof body === "object" &&
    -    body.chat_template_kwargs
    +    hasThinkingRequestFields(body)
       ) {
    -    const retryBody = stripThinkingFlags({ ...body, chat_template_kwargs: { ...body.chat_template_kwargs } });
    +    const retryBody = coerceThinkingFlag(thinking)
    +      ? stripThinkingFlags({
    +          ...body,
    +          chat_template_kwargs:
    +            body.chat_template_kwargs && typeof body.chat_template_kwargs === "object"
    +              ? { ...body.chat_template_kwargs }
    +              : {},
    +        })
    +      : thinkingOffFallbackBody(body);
         return runStreamingRequestOnce(url, retryBody, signal, {
           debug,
           collectContent,
    @@ -369,6 +486,7 @@ async function runStreamingRequestOnce(
       let reasoningChunkCount = 0;
       let chunkTokenCount = 0;
       let usageCompletionTokens = null;
    +  let usagePromptTokens = null;
       /** @type {Record | null} */
       let usage = null;
       let model = null;
    @@ -403,6 +521,7 @@ async function runStreamingRequestOnce(
           headers,
           body: JSON.stringify(body),
           signal,
    +      dispatcher: LLM_STREAM_AGENT,
         });
     
         httpStatus = response.status;
    @@ -454,16 +573,17 @@ async function runStreamingRequestOnce(
               if (debug && json.id && !completionId) completionId = String(json.id);
               if (json.model) model = json.model;
               if (json.usage && typeof json.usage === "object") {
    -            if (debug) {
    -              usage = {
    -                promptTokens: Number(json.usage.prompt_tokens) || 0,
    -                completionTokens: Number(json.usage.completion_tokens) || 0,
    -                totalTokens: Number(json.usage.total_tokens) || 0,
    -              };
    -            }
    +            usage = {
    +              promptTokens: Number(json.usage.prompt_tokens) || 0,
    +              completionTokens: Number(json.usage.completion_tokens) || 0,
    +              totalTokens: Number(json.usage.total_tokens) || 0,
    +            };
                 if (json.usage.completion_tokens != null) {
                   usageCompletionTokens = Number(json.usage.completion_tokens);
                 }
    +            if (json.usage.prompt_tokens != null) {
    +              usagePromptTokens = Number(json.usage.prompt_tokens);
    +            }
               }
     
               const choice = json.choices?.[0];
    @@ -506,11 +626,7 @@ async function runStreamingRequestOnce(
           }
         }
       } catch (err) {
    -    if (err?.name === "AbortError") {
    -      error = "Request aborted or timed out";
    -    } else {
    -      error = err?.message || String(err);
    -    }
    +    error = describeStreamFetchError(err);
       }
     
       const tEnd = performance.now();
    @@ -532,6 +648,14 @@ async function runStreamingRequestOnce(
       const decodeTps =
         decodeMs > 0 && decodeTokens > 0 ? (decodeTokens / decodeMs) * 1000 : 0;
     
    +  const promptEstimate = estimateTokenCount(promptTextFromBody(body));
    +  const prefillTokens =
    +    usagePromptTokens != null && usagePromptTokens > 0
    +      ? usagePromptTokens
    +      : promptEstimate;
    +  const prefillTps =
    +    ttftMs > 0 && prefillTokens > 0 ? (prefillTokens / ttftMs) * 1000 : 0;
    +
       /** @type {Record} */
       const out = {
         ttftMs: round2(ttftMs),
    @@ -542,8 +666,11 @@ async function runStreamingRequestOnce(
         completionTokens,
         decodeTokens,
         decodeTps: round2(decodeTps),
    +    prefillTokens,
    +    prefillTps: round2(prefillTps),
         totalMs: round2(totalMs),
         /** Absolute performance.now() marks for wave-level aggregation */
    +    t0,
         tFirst,
         tLast,
         model,
    diff --git a/server/collectors/PrefillBench.js b/server/collectors/PrefillBench.js
    new file mode 100644
    index 00000000..dc147d44
    --- /dev/null
    +++ b/server/collectors/PrefillBench.js
    @@ -0,0 +1,642 @@
    +/**
    + * PrefillBench — sequential context-size prefill throughput + TTFT.
    + *
    + * Sends a unique-prefix padded prompt at each selected size (up to 300k),
    + * generates a handful of tokens, and records prompt_tokens / TTFT.
    + * One request per size (concurrency 1) so prefix-cache from a prior size
    + * cannot inflate the next: each request starts with a fresh salt.
    + */
    +
    +import { randomUUID } from "crypto";
    +import fs from "fs";
    +import path from "path";
    +import { fileURLToPath } from "url";
    +import { atomicWrite } from "../util/atomicWrite.js";
    +import {
    +  applyThinkingFlags,
    +  estimateTokenCount,
    +  round2,
    +  runStreamingRequest,
    +} from "./LlmStreaming.js";
    +import { decodeBenchManager } from "./DecodeBench.js";
    +import {
    +  PREFILL_CONTEXT_SIZES,
    +  PREFILL_DEFAULT_CONTEXT_SIZES,
    +  formatContextSize,
    +} from "../../src/shared/prefillBench.js";
    +import { formatLlmBaseUrl } from "../../src/shared/llmTarget.js";
    +
    +export { formatContextSize };
    +
    +const __filename = fileURLToPath(import.meta.url);
    +const __dirname = path.dirname(__filename);
    +const ROOT = path.resolve(__dirname, "../..");
    +const HISTORY_PATH =
    +  process.env.PREFILL_BENCH_HISTORY_PATH ||
    +  path.join(ROOT, "config", "prefill-bench-history.json");
    +const ACTIVE_PATH =
    +  process.env.PREFILL_BENCH_ACTIVE_PATH ||
    +  path.join(ROOT, "config", "prefill-bench-active.json");
    +
    +/** Canonical sizes (tokens). 300k is the top of the sweep. */
    +export const ALLOWED_CONTEXT_SIZES = PREFILL_CONTEXT_SIZES;
    +export const DEFAULT_CONTEXT_SIZES = PREFILL_DEFAULT_CONTEXT_SIZES;
    +const ALLOWED_SET = new Set(ALLOWED_CONTEXT_SIZES);
    +
    +const WARMUP_TARGET_TOKENS = 512;
    +const GEN_MAX_TOKENS = 8;
    +const HISTORY_LIMIT = 10;
    +
    +/** `" the"` is typically one BPE token (~4 chars). */
    +const FILLER_UNIT = " the";
    +
    +/**
    + * Unique-prefix prompt aimed at `targetTokens` (estimateTokenCount / 4 chars).
    + * Salt at the start so a previous size is not a prefix-cache hit.
    + * @param {number} targetTokens
    + * @param {string} salt
    + */
    +export function buildPrefillPrompt(targetTokens, salt) {
    +  const n = Math.max(8, Math.round(Number(targetTokens) || 0));
    +  const header = `[prefill-bench ${salt}]\nIgnore the filler below. Reply with the single word OK.\n`;
    +  const footer = "\nReply OK.";
    +  const reserved = estimateTokenCount(header + footer);
    +  const fillTokens = Math.max(1, n - reserved);
    +  return header + FILLER_UNIT.repeat(fillTokens) + footer;
    +}
    +
    +/**
    + * Per-size request timeout: 90s floor, ~8 ms/token (~125 tok/s), 45 min cap.
    + * 256k at a slow ~200 tok/s is ~22 min — the old 12 min cap aborted those runs.
    + * @param {number} tokens
    + */
    +export function timeoutMsForSize(tokens) {
    +  const n = Math.max(0, Number(tokens) || 0);
    +  return Math.min(2_700_000, Math.max(90_000, 60_000 + n * 8));
    +}
    +
    +export function normalizeContextSizes(raw) {
    +  if (!Array.isArray(raw)) return [];
    +  const out = [];
    +  for (const v of raw) {
    +    const n = typeof v === "string" ? parseInt(v, 10) : Number(v);
    +    if (!Number.isInteger(n) || !ALLOWED_SET.has(n)) continue;
    +    if (!out.includes(n)) out.push(n);
    +  }
    +  out.sort((a, b) => a - b);
    +  return out;
    +}
    +
    +function prefillRequestBody(modelId, prompt) {
    +  const body = {
    +    model: modelId || undefined,
    +    messages: [{ role: "user", content: prompt }],
    +    max_tokens: GEN_MAX_TOKENS,
    +    temperature: 0,
    +    top_p: 1,
    +    stream: true,
    +    stream_options: { include_usage: true },
    +  };
    +  applyThinkingFlags(body, modelId, false);
    +  return body;
    +}
    +
    +/**
    + * @param {{
    + *   baseUrl: string,
    + *   modelId: string | null,
    + *   targetTokens: number,
    + *   abortSignal: AbortSignal,
    + *   apiKey?: string | null,
    + * }} opts
    + */
    +async function runPrefillSize({
    +  baseUrl,
    +  modelId,
    +  targetTokens,
    +  abortSignal,
    +  apiKey = null,
    +}) {
    +  const url = `${baseUrl}/v1/chat/completions`;
    +  const salt = randomUUID();
    +  const prompt = buildPrefillPrompt(targetTokens, salt);
    +  const promptChars = prompt.length;
    +  const timeoutMs = timeoutMsForSize(targetTokens);
    +
    +  const ctrl = new AbortController();
    +  const onParentAbort = () => ctrl.abort();
    +  if (abortSignal) {
    +    if (abortSignal.aborted) ctrl.abort();
    +    else abortSignal.addEventListener("abort", onParentAbort, { once: true });
    +  }
    +  let timedOut = false;
    +  const timeout = setTimeout(() => {
    +    timedOut = true;
    +    ctrl.abort();
    +  }, timeoutMs);
    +  const wallStart = performance.now();
    +
    +  try {
    +    const result = await runStreamingRequest(
    +      url,
    +      prefillRequestBody(modelId, prompt),
    +      ctrl.signal,
    +      { retryOnThinking400: true, thinking: false, apiKey }
    +    );
    +    const durationMs = round2(performance.now() - wallStart);
    +    const timeoutErr = timedOut
    +      ? `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for first token at ${formatContextSize(targetTokens)}`
    +      : null;
    +    const ok =
    +      !timedOut &&
    +      !result.error &&
    +      (result.prefillTokens > 0 || result.ttftMs > 0);
    +    return {
    +      targetTokens,
    +      promptTokens: result.prefillTokens || 0,
    +      promptChars,
    +      prefillTps: result.prefillTps || 0,
    +      ttftMs: result.ttftMs || 0,
    +      ttftContentMs: result.ttftContentMs ?? null,
    +      completionTokens: result.completionTokens || 0,
    +      durationMs,
    +      model: result.model || modelId || null,
    +      error: ok ? null : timeoutErr || result.error || "No first token",
    +    };
    +  } finally {
    +    clearTimeout(timeout);
    +    if (abortSignal) abortSignal.removeEventListener("abort", onParentAbort);
    +  }
    +}
    +
    +async function warmupPrefill({ baseUrl, modelId, abortSignal, apiKey }) {
    +  try {
    +    await runPrefillSize({
    +      baseUrl,
    +      modelId,
    +      targetTokens: WARMUP_TARGET_TOKENS,
    +      abortSignal,
    +      apiKey,
    +    });
    +  } catch {
    +    /* best-effort */
    +  }
    +}
    +
    +function publicJob(job) {
    +  return {
    +    benchId: job.benchId,
    +    sparkId: job.sparkId,
    +    status: job.status,
    +    startedAt: job.startedAt,
    +    completedAt: job.completedAt,
    +    config: { ...job.config },
    +    progress: { ...job.progress },
    +    results: job.results,
    +    error: job.error,
    +    durationMs:
    +      job.completedAt != null
    +        ? job.completedAt - job.startedAt
    +        : Date.now() - job.startedAt,
    +  };
    +}
    +
    +/**
    + * Job manager: one active prefill job per Spark, history on disk.
    + */
    +export class PrefillBenchManager {
    +  constructor(historyPath = HISTORY_PATH, activePath = ACTIVE_PATH) {
    +    /** @type {Map} */
    +    this.jobs = new Map();
    +    /** @type {Map} */
    +    this.activeBySpark = new Map();
    +    /** @type {Map} */
    +    this.historyBySpark = new Map();
    +    this.historyPath = historyPath;
    +    this.activePath = activePath;
    +    this._loadHistory();
    +    this._recoverInterruptedActive();
    +  }
    +
    +  getJob(benchId) {
    +    const job = this.jobs.get(benchId);
    +    if (job) return publicJob(job);
    +    for (const list of this.historyBySpark.values()) {
    +      const found = list.find((j) => j.benchId === benchId);
    +      if (found) return found;
    +    }
    +    return null;
    +  }
    +
    +  getActive(sparkId) {
    +    const id = this.activeBySpark.get(sparkId);
    +    if (!id) return null;
    +    const job = this.jobs.get(id);
    +    return job ? publicJob(job) : null;
    +  }
    +
    +  getHistory(sparkId) {
    +    return this.historyBySpark.get(sparkId) || [];
    +  }
    +
    +  getLast(sparkId, port = null) {
    +    const hist = this.getHistory(sparkId);
    +    if (!hist.length) return null;
    +    if (port != null) {
    +      const p = Number(port);
    +      const match = hist.find(
    +        (j) => j.config?.port === p && Array.isArray(j.results) && j.results.length > 0
    +      );
    +      if (match) return match;
    +      const anyPort = hist.find((j) => j.config?.port === p);
    +      if (anyPort) return anyPort;
    +    }
    +    return hist[0] || null;
    +  }
    +
    +  clearHistory(sparkId, port = null) {
    +    const p = port != null ? Number(port) : null;
    +
    +    if (p != null && Number.isInteger(p)) {
    +      const list = this.getHistory(sparkId).filter((j) => j.config?.port !== p);
    +      if (list.length) this.historyBySpark.set(sparkId, list);
    +      else this.historyBySpark.delete(sparkId);
    +      for (const [benchId, job] of this.jobs.entries()) {
    +        if (job.sparkId === sparkId && job.config?.port === p && job.status !== "running") {
    +          this.jobs.delete(benchId);
    +        }
    +      }
    +    } else {
    +      this.historyBySpark.delete(sparkId);
    +      for (const [benchId, job] of this.jobs.entries()) {
    +        if (job.sparkId === sparkId && job.status !== "running") {
    +          this.jobs.delete(benchId);
    +        }
    +      }
    +    }
    +
    +    this._saveHistory();
    +    return { ok: true };
    +  }
    +
    +  _loadHistory() {
    +    try {
    +      if (!fs.existsSync(this.historyPath)) return;
    +      const raw = fs.readFileSync(this.historyPath, "utf8");
    +      const data = JSON.parse(raw);
    +      if (!data || typeof data !== "object") return;
    +      for (const [sparkId, list] of Object.entries(data)) {
    +        if (!Array.isArray(list)) continue;
    +        const cleaned = list
    +          .filter((j) => j && typeof j === "object" && j.benchId && j.sparkId)
    +          .slice(0, HISTORY_LIMIT)
    +          .map((j) => ({
    +            ...j,
    +            status: j.status === "running" ? "cancelled" : j.status || "completed",
    +          }));
    +        if (cleaned.length) this.historyBySpark.set(sparkId, cleaned);
    +      }
    +    } catch (err) {
    +      console.warn("[PrefillBench] failed to load history:", err?.message || err);
    +    }
    +  }
    +
    +  _recoverInterruptedActive() {
    +    const leftovers = this._readActiveFile();
    +    if (!leftovers.length) return;
    +
    +    let changed = false;
    +    for (const snap of leftovers) {
    +      if (!snap?.benchId || !snap?.sparkId) continue;
    +      const hist = this.getHistory(snap.sparkId);
    +      if (hist.some((j) => j.benchId === snap.benchId)) continue;
    +
    +      const interrupted = {
    +        ...snap,
    +        status: "failed",
    +        error:
    +          snap.error ||
    +          "Interrupted — server restarted while the benchmark was running",
    +        completedAt: snap.completedAt || Date.now(),
    +        progress: {
    +          ...(snap.progress || {}),
    +          message: "Interrupted",
    +          currentContext: null,
    +        },
    +      };
    +      if (interrupted.completedAt && interrupted.startedAt) {
    +        interrupted.durationMs = interrupted.completedAt - interrupted.startedAt;
    +      }
    +      this.jobs.set(interrupted.benchId, interrupted);
    +      this._pushHistory(interrupted);
    +      changed = true;
    +      console.warn(
    +        `[PrefillBench] recovered interrupted job ${interrupted.benchId} on ${interrupted.sparkId}`
    +      );
    +    }
    +
    +    this._writeActiveFile([]);
    +    if (changed) this._saveHistory();
    +  }
    +
    +  _readActiveFile() {
    +    try {
    +      if (!fs.existsSync(this.activePath)) return [];
    +      const raw = fs.readFileSync(this.activePath, "utf8");
    +      const data = JSON.parse(raw);
    +      if (Array.isArray(data)) return data;
    +      if (data && typeof data === "object" && Array.isArray(data.jobs)) {
    +        return data.jobs;
    +      }
    +      return [];
    +    } catch (err) {
    +      console.warn("[PrefillBench] failed to load active jobs:", err?.message || err);
    +      return [];
    +    }
    +  }
    +
    +  _writeActiveFile(jobs) {
    +    try {
    +      atomicWrite(this.activePath, JSON.stringify({ jobs }, null, 2), 0o600);
    +    } catch (err) {
    +      console.warn("[PrefillBench] failed to save active jobs:", err?.message || err);
    +    }
    +  }
    +
    +  _checkpointActive() {
    +    /** @type {object[]} */
    +    const running = [];
    +    for (const job of this.jobs.values()) {
    +      if (job.status === "running") running.push(publicJob(job));
    +    }
    +    this._writeActiveFile(running);
    +  }
    +
    +  interruptAll(reason = "Interrupted — server shutting down") {
    +    for (const job of this.jobs.values()) {
    +      if (job.status !== "running") continue;
    +      try {
    +        job._abort?.abort();
    +      } catch {
    +        /* ignore */
    +      }
    +      try {
    +        job._closeTarget?.();
    +      } catch {
    +        /* ignore */
    +      }
    +      job._closeTarget = null;
    +      job.status = "failed";
    +      job.error = reason;
    +      job.progress.message = "Interrupted";
    +      job.progress.currentContext = null;
    +      job.completedAt = Date.now();
    +      this.activeBySpark.delete(job.sparkId);
    +      this._pushHistory(job);
    +    }
    +    this._writeActiveFile([]);
    +  }
    +
    +  _saveHistory() {
    +    try {
    +      /** @type {Record} */
    +      const out = {};
    +      for (const [sparkId, list] of this.historyBySpark.entries()) {
    +        out[sparkId] = list;
    +      }
    +      atomicWrite(this.historyPath, JSON.stringify(out, null, 2), 0o600);
    +    } catch (err) {
    +      console.warn("[PrefillBench] failed to save history:", err?.message || err);
    +    }
    +  }
    +
    +  /**
    +   * @param {{
    +   *   sparkId: string,
    +   *   lanIp: string,
    +   *   port: number,
    +   *   modelId: string | null,
    +   *   contextSizes: number[],
    +   *   apiKey?: string | null,
    +   *   resolveTarget?: (ctx: { onStatus?: Function, signal?: AbortSignal }) => Promise<{
    +   *     host: string, port: number, tls?: boolean, via?: string, close: () => void
    +   *   }>,
    +   *   host?: string | null,
    +   *   tls?: boolean,
    +   * }} opts
    +   */
    +  start(opts) {
    +    const {
    +      sparkId,
    +      lanIp,
    +      port,
    +      modelId,
    +      contextSizes: rawSizes,
    +      apiKey = null,
    +      resolveTarget = null,
    +      host: rawHost = null,
    +      tls: rawTls = false,
    +    } = opts;
    +
    +    if (this.activeBySpark.has(sparkId)) {
    +      const err = new Error("A prefill benchmark is already running for this Spark");
    +      err.status = 409;
    +      throw err;
    +    }
    +    if (decodeBenchManager.getActive(sparkId)) {
    +      const err = new Error("A decode benchmark is already running for this Spark");
    +      err.status = 409;
    +      throw err;
    +    }
    +
    +    const contextSizes = normalizeContextSizes(rawSizes);
    +    if (!contextSizes.length) {
    +      const err = new Error(
    +        "Select at least one context size (1k–300k)"
    +      );
    +      err.status = 400;
    +      throw err;
    +    }
    +
    +    const p = Number(port);
    +    if (!Number.isInteger(p) || p < 1 || p > 65535) {
    +      const err = new Error("Invalid LLM port");
    +      err.status = 400;
    +      throw err;
    +    }
    +
    +    const benchId = randomUUID();
    +    const abort = new AbortController();
    +    const job = {
    +      benchId,
    +      sparkId,
    +      status: "running",
    +      startedAt: Date.now(),
    +      completedAt: null,
    +      config: {
    +        port: p,
    +        modelId: modelId || null,
    +        contextSizes,
    +        ...(rawHost
    +          ? { host: String(rawHost).trim(), tls: Boolean(rawTls) }
    +          : {}),
    +      },
    +      progress: {
    +        currentContext: null,
    +        completedLevels: 0,
    +        totalLevels: contextSizes.length,
    +        message: "Starting…",
    +      },
    +      results: [],
    +      error: null,
    +      _abort: abort,
    +      _apiKey: apiKey != null && String(apiKey).trim() ? String(apiKey).trim() : null,
    +      _resolveTarget: typeof resolveTarget === "function" ? resolveTarget : null,
    +      _closeTarget: null,
    +    };
    +
    +    this.jobs.set(benchId, job);
    +    this.activeBySpark.set(sparkId, benchId);
    +    this._checkpointActive();
    +
    +    this._runJob(job, lanIp).catch(() => {
    +      /* errors recorded on job */
    +    });
    +
    +    return publicJob(job);
    +  }
    +
    +  cancel(sparkId, benchId) {
    +    const job = this.jobs.get(benchId);
    +    if (!job || job.sparkId !== sparkId) return null;
    +    if (job.status !== "running") return publicJob(job);
    +    job._abort.abort();
    +    job.progress.message = "Cancelling…";
    +    return publicJob(job);
    +  }
    +
    +  async _runJob(job, lanIp) {
    +    let host = lanIp;
    +    let port = job.config.port;
    +    let tls = Boolean(job.config.tls);
    +    try {
    +      if (typeof job._resolveTarget === "function") {
    +        job.progress.message = "Connecting to LLM…";
    +        this._checkpointActive();
    +        const target = await job._resolveTarget({
    +          onStatus: (msg) => {
    +            if (typeof msg === "string" && msg) job.progress.message = msg;
    +            this._checkpointActive();
    +          },
    +          signal: job._abort.signal,
    +        });
    +        host = target?.host || host;
    +        port = Number.isInteger(target?.port) ? target.port : port;
    +        if (target?.tls != null) tls = Boolean(target.tls);
    +        job._closeTarget = typeof target?.close === "function" ? target.close : null;
    +        if (target?.via === "ssh-tunnel") {
    +          job.progress.message = "Warming up via SSH tunnel…";
    +        }
    +      }
    +      const baseUrl = formatLlmBaseUrl({ host, port, tls });
    +      if (!job._abort.signal.aborted) {
    +        if (!String(job.progress.message || "").startsWith("Warming up")) {
    +          job.progress.message = "Warming up…";
    +        }
    +        this._checkpointActive();
    +        await warmupPrefill({
    +          baseUrl,
    +          modelId: job.config.modelId,
    +          abortSignal: job._abort.signal,
    +          apiKey: job._apiKey,
    +        });
    +      }
    +
    +      for (const size of job.config.contextSizes) {
    +        if (job._abort.signal.aborted) {
    +          if (job.status === "running") {
    +            job.status = "cancelled";
    +            job.error = "Cancelled by user";
    +            job.progress.message = "Cancelled";
    +          }
    +          break;
    +        }
    +
    +        job.progress.currentContext = size;
    +        job.progress.message = `Prefilling ${formatContextSize(size)}…`;
    +        this._checkpointActive();
    +
    +        const row = await runPrefillSize({
    +          baseUrl,
    +          modelId: job.config.modelId,
    +          targetTokens: size,
    +          abortSignal: job._abort.signal,
    +          apiKey: job._apiKey,
    +        });
    +
    +        if (job._abort.signal.aborted) {
    +          if (job.status === "running") {
    +            job.status = "cancelled";
    +            job.error = "Cancelled by user";
    +            job.progress.message = "Cancelled";
    +          }
    +          break;
    +        }
    +
    +        if (row.model && !job.config.modelId) {
    +          job.config.modelId = row.model;
    +        }
    +
    +        job.results.push(row);
    +        job.progress.completedLevels += 1;
    +        this._checkpointActive();
    +      }
    +
    +      if (job.status === "running") {
    +        job.status = "completed";
    +        job.progress.currentContext = null;
    +        job.progress.message = "Done";
    +      }
    +    } catch (err) {
    +      if (job.status === "running") {
    +        if (job._abort.signal.aborted) {
    +          job.status = "cancelled";
    +          job.error = "Cancelled by user";
    +          job.progress.message = "Cancelled";
    +        } else {
    +          job.status = "failed";
    +          job.error = err?.message || String(err);
    +          job.progress.message = "Failed";
    +        }
    +      }
    +    } finally {
    +      try {
    +        job._closeTarget?.();
    +      } catch {
    +        /* ignore */
    +      }
    +      job._closeTarget = null;
    +      if (job.completedAt == null) job.completedAt = Date.now();
    +      this.activeBySpark.delete(job.sparkId);
    +      this._pushHistory(job);
    +      this._checkpointActive();
    +    }
    +  }
    +
    +  _pushHistory(job) {
    +    const list = this.historyBySpark.get(job.sparkId) || [];
    +    const pub = publicJob(job);
    +    const existing = list.findIndex((j) => j.benchId === pub.benchId);
    +    if (existing >= 0) list.splice(existing, 1);
    +    list.unshift(pub);
    +    this.historyBySpark.set(job.sparkId, list.slice(0, HISTORY_LIMIT));
    +    this._saveHistory();
    +  }
    +}
    +
    +export const prefillBenchManager = new PrefillBenchManager();
    +
    +export const PREFILL_BENCH_DEFAULTS = {
    +  allowedContextSizes: [...ALLOWED_CONTEXT_SIZES],
    +  defaultContextSizes: [...DEFAULT_CONTEXT_SIZES],
    +};
    diff --git a/server/collectors/ShowcaseManager.js b/server/collectors/ShowcaseManager.js
    index 9a85616f..4acc61e0 100644
    --- a/server/collectors/ShowcaseManager.js
    +++ b/server/collectors/ShowcaseManager.js
    @@ -12,12 +12,16 @@ import path from "path";
     import { fileURLToPath } from "url";
     import { atomicWrite } from "../util/atomicWrite.js";
     import { decodeBenchManager } from "./DecodeBench.js";
    +import { prefillBenchManager } from "./PrefillBench.js";
     import {
       applyThinkingFlags,
    +  coerceThinkingFlag,
       pollServerGenerationRates,
       round2,
       runStreamingRequest,
    +  stripFillForceFields,
     } from "./LlmStreaming.js";
    +import { withFillToMaxInstruction } from "../../src/shared/llmPrompts.js";
     
     const __filename = fileURLToPath(import.meta.url);
     const __dirname = path.dirname(__filename);
    @@ -40,7 +44,7 @@ const MIN_PROMPT_LEN = 1;
     const MAX_PROMPT_LEN = 4000;
     const HEARTBEAT_TIMEOUT_MS = 5_000;
     const HEARTBEAT_CHECK_MS = 1_000;
    -/** Full max_tokens fills at low tok/s need a longer per-stream budget than decode bench. */
    +/** Full max_tokens fills at low tok/s need a long per-stream budget. */
     const PER_REQUEST_TIMEOUT_MS = 360_000;
     const LABEL_CHARS = 40;
     
    @@ -55,38 +59,8 @@ function contentCap(maxTokens) {
     
     const PROMPT_TYPES = new Set(["structural", "text", "mixed"]);
     
    -/** Suffix appended server-side; keep under MAX_PROMPT_LEN headroom in UI catalogs. */
    -const FILL_TO_MAX_SUFFIX =
    -  " Continue generating until you hit the maximum output length; do not stop early—keep expanding with more content.";
    -
    -/**
    - * Encourage full-length completions when the prompt doesn't already ask for it.
    - * Only skip when the prompt already states the hard length/EOS rule — phrases like
    - * "keep expanding" alone are not enough (models still stop at natural EOS).
    - * @param {string} prompt
    - */
    -export function withFillToMaxInstruction(prompt) {
    -  const p = String(prompt || "").trim();
    -  if (!p) return p;
    -  if (
    -    /maximum output length|do not stop early|until you hit the (maximum|output)/i.test(
    -      p
    -    )
    -  ) {
    -    return p;
    -  }
    -  return `${p}${FILL_TO_MAX_SUFFIX}`;
    -}
    -
    -/** vLLM-oriented fields that some OpenAI-compat servers reject with HTTP 400. */
    -export function stripFillForceFields(body) {
    -  if (!body || typeof body !== "object") return body;
    -  const next = { ...body };
    -  delete next.min_tokens;
    -  delete next.ignore_eos;
    -  delete next.stop;
    -  return next;
    -}
    +export { withFillToMaxInstruction } from "../../src/shared/llmPrompts.js";
    +export { stripFillForceFields } from "./LlmStreaming.js";
     
     function labelFromPrompt(prompt) {
       const s = String(prompt || "").replace(/\s+/g, " ").trim();
    @@ -142,7 +116,7 @@ function publicSessionRecord(session, opts = {}) {
         modelId: session.modelId ?? null,
         maxTokens: session.maxTokens ?? null,
         temperature: session.temperature ?? DEFAULT_TEMPERATURE,
    -    thinking: session.thinking !== false,
    +    thinking: coerceThinkingFlag(session.thinking),
         promptType: session.promptType ?? null,
         startedAt: session.startedAt ?? null,
         completedAt: session.completedAt ?? null,
    @@ -169,7 +143,7 @@ function historySummary(record) {
         modelId: record.modelId ?? null,
         maxTokens: record.maxTokens ?? null,
         temperature: record.temperature ?? DEFAULT_TEMPERATURE,
    -    thinking: record.thinking !== false,
    +    thinking: coerceThinkingFlag(record.thinking),
         promptType: record.promptType ?? null,
         startedAt: record.startedAt ?? null,
         completedAt: record.completedAt ?? null,
    @@ -351,6 +325,11 @@ export class ShowcaseManager {
           err.status = 409;
           throw err;
         }
    +    if (prefillBenchManager.getActive(sparkId)) {
    +      const err = new Error("A prefill benchmark is already running for this Spark");
    +      err.status = 409;
    +      throw err;
    +    }
     
         const prompts = normalizePrompts(rawPrompts);
         if (!prompts) {
    @@ -391,7 +370,7 @@ export class ShowcaseManager {
           throw err;
         }
     
    -    const thinking = rawThinking !== false;
    +    const thinking = coerceThinkingFlag(rawThinking);
         const promptType =
           typeof rawPromptType === "string" && PROMPT_TYPES.has(rawPromptType)
             ? rawPromptType
    @@ -550,7 +529,7 @@ export class ShowcaseManager {
           modelId: session.modelId,
           maxTokens: session.maxTokens,
           temperature: session.temperature,
    -      thinking: session.thinking !== false,
    +      thinking: coerceThinkingFlag(session.thinking),
           startedAt: session.startedAt,
           completedAt: session.completedAt,
           serverGenerationTps: session.serverGenerationTps,
    @@ -720,7 +699,7 @@ export class ShowcaseManager {
             stream: true,
             stream_options: { include_usage: true },
           };
    -      applyThinkingFlags(body, session.modelId, session.thinking !== false);
    +      applyThinkingFlags(body, session.modelId, session.thinking);
     
           stream.status = "streaming";
           stream._t0 = performance.now();
    @@ -731,6 +710,7 @@ export class ShowcaseManager {
           return runStreamingRequest(url, body, ctrl.signal, {
             collectContent: true,
             retryOnThinking400: true,
    +        thinking: session.thinking,
             apiKey: session._apiKey,
             onDelta: (info) => {
               if (session.status !== "running") return;
    @@ -757,6 +737,7 @@ export class ShowcaseManager {
                   {
                     collectContent: true,
                     retryOnThinking400: true,
    +                thinking: session.thinking,
                     apiKey: session._apiKey,
                     onDelta: (info) => {
                       if (session.status !== "running") return;
    diff --git a/server/collectors/SystemCollector.js b/server/collectors/SystemCollector.js
    index 15edcbec..2c2c9d05 100644
    --- a/server/collectors/SystemCollector.js
    +++ b/server/collectors/SystemCollector.js
    @@ -1,9 +1,24 @@
     import fs from "fs";
     import path from "path";
    -import { HOST_PATHS, GPU_MEMORY_JSON_PATH, DGX_SPARK, HARDWARE_DEFAULTS } from "../config.js";
    +import { HOST_PATHS, GPU_MEMORY_JSON_PATH, DGX_SPARK, HARDWARE_DEFAULTS, POLL_INTERVAL_NVERR } from "../config.js";
     import { normalizeMac, WOL_INTERFACE } from "../wol.js";
     import { sshExec } from "./ssh.js";
     
    +const NVERR_JOURNAL_CMD =
    +  'journalctl -k --no-pager -q --grep=NV_ERR_NO_MEMORY 2>/dev/null | grep -c NV_ERR_NO_MEMORY || true';
    +
    +/**
    + * Parse `grep -c` stdout into a non-negative integer. Exported for tests.
    + * @param {unknown} raw
    + * @returns {number}
    + */
    +export function parseNvErrNoMemoryCount(raw) {
    +  const line = String(raw ?? "").trim().split("\n").pop() ?? "";
    +  const n = Number.parseInt(line, 10);
    +  if (!Number.isFinite(n) || n < 0) return 0;
    +  return n;
    +}
    +
     /**
      * SystemCollector — collects hardware metrics for a Spark.
      * In Phase 2, this is the LOCAL path only (no SSH).
    @@ -32,6 +47,8 @@ export class SystemCollector {
     
         // Cached hardware info
         this._hardwareInfo = null;
    +    /** Cached NVRM NV_ERR_NO_MEMORY count (slow journal scan). */
    +    this._nvErrCache = { count: 0, at: 0 };
       }
     
       /** Collect GPU metrics (temperature, usage, power, VRAM). */
    @@ -157,6 +174,7 @@ export class SystemCollector {
           vram,
           processes,
           throttle: gpu.throttle,
    +      nvErrNoMemory: await this._nvErrNoMemory(),
         };
       }
     
    @@ -253,16 +271,24 @@ export class SystemCollector {
         const { totalMB: memTotalMB, availMB } = await this._readMeminfoMB();
         availableMB = availMB;
     
    -    // Prefer the OS-visible pool (MemTotal) as the total; fall back to the nvidia-smi
    -    // value, then the hardware spec (HBM) only if nothing else is known.
    -    if (memTotalMB > 0) {
    -      total = memTotalMB;
    -    } else if (total == null || total === 0) {
    -      total = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    +    const usedMB = Math.round(used || 0);
    +    let totalMB = Math.round(total || 0);
    +
    +    if (this.spark.kind === "host") {
    +      // Discrete GPU VRAM: trust nvidia-smi's memory.total (e.g. 24 GB L4), and
    +      // only fall back to the OS pool / Spark spec when nvidia-smi says N/A.
    +      // Free VRAM = total − used (unlike the shared pool, GPU memory is dedicated).
    +      if (totalMB <= 0 && memTotalMB > 0) totalMB = memTotalMB;
    +      else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    +      if (totalMB > 0 && usedMB > 0) availableMB = Math.max(0, totalMB - usedMB);
    +    } else {
    +      // GB10 shared HBM pool: prefer the OS-visible pool (MemTotal) as the total,
    +      // fall back to nvidia-smi, then the hardware spec (HBM) only if nothing known.
    +      if (memTotalMB > 0) totalMB = memTotalMB;
    +      else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    +      availableMB = availMB;
         }
     
    -    const usedMB = Math.round(used || 0);
    -    const totalMB = Math.round(total || 0);
         const percentage = totalMB > 0 ? Math.round((usedMB / totalMB) * 100) : 0;
     
         return { used: usedMB, total: totalMB, percentage, available: availableMB };
    @@ -953,16 +979,21 @@ export class SystemCollector {
           const totalMatch = meminfoOut.match(/MemTotal:\s+(\d+)\s+kB/);
           const availMatch = meminfoOut.match(/MemAvailable:\s+(\d+)\s+kB/);
           const memTotalMB = totalMatch ? Math.round(parseInt(totalMatch[1]) / 1024) : 0;
    -      const availableMB = availMatch ? Math.round(parseInt(availMatch[1]) / 1024) : 0;
    -
    -      if (memTotalMB > 0) {
    -        total = memTotalMB;
    -      } else if (total == null || total === 0) {
    -        total = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    -      }
    +      let availableMB = availMatch ? Math.round(parseInt(availMatch[1]) / 1024) : 0;
     
           const usedMB = Math.round(used || 0);
    -      const totalMB = Math.round(total || 0);
    +      let totalMB = Math.round(total || 0);
    +      if (this.spark.kind === "host") {
    +        // Discrete GPU VRAM: trust nvidia-smi's memory.total; free VRAM = total − used.
    +        if (totalMB <= 0 && memTotalMB > 0) totalMB = memTotalMB;
    +        else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    +        if (totalMB > 0 && usedMB > 0) availableMB = Math.max(0, totalMB - usedMB);
    +      } else {
    +        // GB10 shared HBM pool: prefer the OS-visible pool (MemTotal) as the total,
    +        // fall back to nvidia-smi, then the hardware spec (HBM) only if nothing known.
    +        if (memTotalMB > 0) totalMB = memTotalMB;
    +        else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB
    +      }
           const percentage = totalMB > 0 ? Math.round((usedMB / totalMB) * 100) : 0;
     
           // Rough system power estimate: GPU draw + 20W CX7/peripherals
    @@ -981,6 +1012,7 @@ export class SystemCollector {
             vram: { used: usedMB, total: totalMB, percentage, available: availableMB },
             processes,
             throttle: gpu.throttle,
    +        nvErrNoMemory: await this._nvErrNoMemory(),
           };
         } catch (err) {
           console.error(`[SystemCollector] Remote GPU error for ${this.spark.id}:`, err.message);
    @@ -988,18 +1020,33 @@ export class SystemCollector {
         }
       }
     
    -  async _getRemoteCpu() {
    +  /**
    +   * One SSH round trip: /proc/stat, CPU arch, then the same hwmon-then-thermal
    +   * sensor dump local `_getCPUTemperature()` uses. `|| true` on the thermal
    +   * glob keeps a missing zone from failing the whole CPU poll (sshExec treats
    +   * any non-zero exit as a hard error).
    +   */
    +  _buildRemoteCpuCommand() {
    +    return [
    +      "cat /proc/stat | head -1",
    +      "echo '---'",
    +      "cat /proc/cpuinfo | grep -E 'CPU architecture|aarch64' | head -1",
    +      "echo '---'",
    +      // GB10 also exposes nvme/mlx5 sensors; the name allowlist keeps those out.
    +      'for h in /sys/class/hwmon/*; do n=$(cat "$h/name" 2>/dev/null); case "$n" in coretemp|k10temp|zenpower|acpitz) for t in "$h"/temp*_input; do cat "$t" 2>/dev/null; break; done;; esac; done',
    +      "cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null || true",
    +    ].join("; ");
    +  }
    +
    +  async _getRemoteCpu(sshExecutor = sshExec) {
         try {
    -      const cmd = [
    -        "cat /proc/stat | head -1",
    -        "echo '---'",
    -        "cat /proc/cpuinfo | grep -E 'CPU architecture|aarch64' | head -1",
    -      ].join("; ");
    +      const cmd = this._buildRemoteCpuCommand();
     
    -      const output = await sshExec(this.spark, cmd);
    +      const output = await sshExecutor(this.spark, cmd);
           const sections = output.split("---");
           const statOut = sections[0]?.trim() || "";
           const cpuinfoOut = sections[1]?.trim() || "";
    +      const tempOut = sections[2] || "";
     
           const cpuStat = this._parseCPUUsage(statOut);
           const totalDiff = cpuStat.total - (this.lastCpuStat?.total || cpuStat.total);
    @@ -1013,13 +1060,36 @@ export class SystemCollector {
           const idleWatts = tdp * 0.08;
           const draw = idleWatts + (tdp - idleWatts) * Math.min(usage / 100, 1);
     
    -      return { usage, temperature: 0, draw: Math.round(draw * 10) / 10, tdp: Math.round(tdp) };
    +      return {
    +        usage,
    +        temperature: this._parseSensorTemp(tempOut),
    +        draw: Math.round(draw * 10) / 10,
    +        tdp: Math.round(tdp),
    +      };
         } catch (err) {
           console.error(`[SystemCollector] Remote CPU error for ${this.spark.id}:`, err.message);
           return this._defaultCpu();
         }
       }
     
    +  /**
    +   * First plausible temperature from a remote sensor dump (raw millidegrees,
    +   * one per line, highest priority first). Same accept range as local
    +   * `_getCPUTemperature()`; returns 0 when nothing is readable.
    +   *
    +   * @param {string} raw
    +   * @returns {number} degrees Celsius, or 0
    +   */
    +  _parseSensorTemp(raw) {
    +    for (const line of String(raw).split("\n")) {
    +      const millidegrees = parseInt(line.trim(), 10);
    +      if (Number.isFinite(millidegrees) && millidegrees > 0 && millidegrees < 200000) {
    +        return Math.round((millidegrees / 1000) * 10) / 10;
    +      }
    +    }
    +    return 0;
    +  }
    +
       async _getRemoteRam() {
         try {
           const cmd = "grep -E 'MemTotal|MemAvailable' /proc/meminfo 2>/dev/null";
    @@ -1303,6 +1373,77 @@ export class SystemCollector {
         return this._exec(cmd);
       }
     
    +  /**
    +   * One-shot real-hardware detection (GPU chip + driver, CPU model/cores, RAM).
    +   * Used for kind === "host" units (dedicated GPU Linux boxes) so the header
    +   * doesn't claim DGX Spark specs. Returns null on any failure → caller keeps
    +   * its static fallback summary.
    +   * @returns {Promise}
    +   */
    +  async detectHardware() {
    +    try {
    +      let smiOut = "";
    +      let cpuinfo = "";
    +      let meminfo = "";
    +      let coresParsed = null;
    +      if (this.spark.isLocal) {
    +        const results = await Promise.all([
    +          this._nvidiaSmi(
    +            "--query-gpu=name,driver_version --format=csv,noheader,nounits 2>/dev/null"
    +          ).catch(() => ""),
    +          this._readHostFile("/proc/cpuinfo").catch(() => ""),
    +          this._readHostFile("/proc/meminfo").catch(() => ""),
    +        ]);
    +        smiOut = results[0];
    +        cpuinfo = results[1];
    +        meminfo = results[2];
    +        coresParsed = (cpuinfo.match(/processor\s*:/g) || []).length;
    +      } else {
    +        const out = await sshExec(this.spark, [
    +          "nvidia-smi --query-gpu=name,driver_version --format=csv,noheader,nounits 2>/dev/null",
    +          "echo '---'",
    +          "grep -E '^model name' /proc/cpuinfo | head -1",
    +          "echo '---'",
    +          "grep -E 'processor\\s*:' /proc/cpuinfo | wc -l",
    +          "echo '---'",
    +          "grep -E 'MemTotal' /proc/meminfo",
    +        ].join("; "));
    +        const parts = out.split("---");
    +        smiOut = parts[0]?.trim() || "";
    +        cpuinfo = parts[1]?.trim() || "";
    +        meminfo = parts[3]?.trim() || "";
    +        const n = parseInt(parts[2]?.trim() || "", 10);
    +        coresParsed = Number.isInteger(n) && n > 0 ? n : null;
    +      }
    +
    +      const smiLine = smiOut.split("\n").find(Boolean) || "";
    +      const smiParts = smiLine.split(",").map((s) => s.trim());
    +      const gpuChip = smiParts[0] || null;
    +      const cudaDriver = smiParts[1] || null;
    +
    +      const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/i);
    +      const cpuModel = modelMatch ? modelMatch[1].trim() : null;
    +      const cpuCores = coresParsed !== null && coresParsed > 0 ? coresParsed : null;
    +
    +      const memMatch = meminfo.match(/MemTotal:\s+(\d+)\s+kB/);
    +      const totalMemoryGB = memMatch
    +        ? Math.max(1, Math.round(parseInt(memMatch[1], 10) / 1024 / 1024))
    +        : null;
    +
    +      return {
    +        device: "Linux GPU host",
    +        cpuModel,
    +        cpuCores,
    +        totalMemoryGB,
    +        gpuChip,
    +        cudaDriver,
    +        storageModel: null,
    +      };
    +    } catch {
    +      return null;
    +    }
    +  }
    +
       /**
        * Read host network files via host netns — /proc/net is netns-local even under
        * a bind-mounted /host/proc (self/net symlink semantics).
    @@ -1326,7 +1467,7 @@ export class SystemCollector {
             });
           }
         }
    -    return this._readHostFile(`/proc/net/${relPath}`);
    +    return fs.readFileSync(`/proc/net/${relPath}`, "utf-8");
       }
     
       /** Lightweight liveness for local Sparks. */
    @@ -1377,6 +1518,34 @@ export class SystemCollector {
         return fs.promises.statfs(dir);
       }
     
    +  /**
    +   * Count NVRM `NV_ERR_NO_MEMORY` lines in the kernel journal since boot.
    +   * Cached for POLL_INTERVAL_NVERR — never on the 2s GPU/memory loop uncached.
    +   * @returns {Promise}
    +   */
    +  async _nvErrNoMemory() {
    +    const now = Date.now();
    +    if (this._nvErrCache.at > 0 && now - this._nvErrCache.at < POLL_INTERVAL_NVERR) {
    +      return this._nvErrCache.count;
    +    }
    +    try {
    +      let out;
    +      if (this.spark.isLocal) {
    +        out = this._hasHostProc()
    +          ? await this._execOnHost(NVERR_JOURNAL_CMD)
    +          : await this._exec(NVERR_JOURNAL_CMD);
    +      } else {
    +        out = await sshExec(this.spark, NVERR_JOURNAL_CMD, { timeoutMs: 8000 });
    +      }
    +      const count = parseNvErrNoMemoryCount(out);
    +      this._nvErrCache = { count, at: now };
    +      return count;
    +    } catch {
    +      this._nvErrCache.at = now;
    +      return this._nvErrCache.count;
    +    }
    +  }
    +
       // ─── Default metrics ─────────────────────────────────────
       _defaultGpu() {
         return {
    @@ -1386,6 +1555,7 @@ export class SystemCollector {
           vram: { used: 0, total: 0, percentage: 0, available: 0 },
           processes: [],
           throttle: this._defaultThrottle(),
    +      nvErrNoMemory: 0,
         };
       }
     
    diff --git a/server/collectors/TailscaleProbe.js b/server/collectors/TailscaleProbe.js
    new file mode 100644
    index 00000000..c658dd8d
    --- /dev/null
    +++ b/server/collectors/TailscaleProbe.js
    @@ -0,0 +1,152 @@
    +/**
    + * TailscaleProbe — reports whether a unit is actually present on its tailnet.
    + *
    + * A Spark/host can be healthy on the LAN (SSH, GPU, LLM) while `tailscaled`
    + * has lost its session with the coordination server. Verdict is Self.Online
    + * on this node — never a peer's (possibly stale) view.
    + *
    + * Command: `tailscale status --json` (read-only).
    + */
    +import { TAILSCALE_PROBE_TIMEOUT_MS, HOST_PATHS } from "../config.js";
    +import { sshExec } from "./ssh.js";
    +import fs from "fs";
    +import path from "path";
    +
    +/**
    + * @param {unknown} v
    + * @returns {string | null}
    + */
    +function str(v) {
    +  if (typeof v !== "string") return null;
    +  const s = v.trim();
    +  return s.length > 0 ? s : null;
    +}
    +
    +/**
    + * Normalize `tailscale status --json` into the fields the UI needs.
    + * Pure (no I/O) — exported for unit tests.
    + *
    + * @param {object} raw
    + * @returns {object}
    + */
    +export function parseTailscaleStatus(raw) {
    +  const self =
    +    raw && typeof raw === "object" && raw.Self && typeof raw.Self === "object"
    +      ? raw.Self
    +      : null;
    +
    +  const health = Array.isArray(raw?.Health)
    +    ? raw.Health.filter((m) => typeof m === "string" && m.trim().length > 0)
    +    : [];
    +
    +  const ips = Array.isArray(self?.TailscaleIPs)
    +    ? self.TailscaleIPs.filter((ip) => typeof ip === "string")
    +    : [];
    +
    +  return {
    +    available: self != null,
    +    online: typeof self?.Online === "boolean" ? self.Online : null,
    +    backendState: str(raw?.BackendState),
    +    hostName: str(self?.HostName),
    +    dnsName: str(self?.DNSName),
    +    tailscaleIp: ips[0] ?? null,
    +    relay: str(self?.Relay),
    +    keyExpiry: str(self?.KeyExpiry),
    +    keyExpired: self?.Expired === true,
    +    version: str(raw?.Version),
    +    health,
    +  };
    +}
    +
    +export class TailscaleProbe {
    +  /**
    +   * @param {object} spark
    +   */
    +  constructor(spark) {
    +    this.spark = spark;
    +    this.error = null;
    +  }
    +
    +  /** @param {object} spark */
    +  setTarget(spark) {
    +    this.spark = spark ?? this.spark;
    +    this.error = null;
    +  }
    +
    +  dispose() {}
    +
    +  _hasHostProc() {
    +    return fs.existsSync(path.join(HOST_PATHS.PROC, "1", "ns", "mnt"));
    +  }
    +
    +  /**
    +   * Local Docker: tailscaled's socket lives on the host, so enter the host
    +   * mount namespace (same approach as nvidia-smi).
    +   * @param {string} cmd
    +   * @returns {Promise}
    +   */
    +  async _execLocal(cmd) {
    +    const { execFile } = await import("child_process");
    +    const useHostNs = this._hasHostProc();
    +    const file = useHostNs ? "nsenter" : "sh";
    +    const args = useHostNs
    +      ? ["--mount=" + path.join(HOST_PATHS.PROC, "1", "ns", "mnt"), "--", "sh", "-c", cmd]
    +      : ["-c", cmd];
    +    return new Promise((resolve, reject) => {
    +      execFile(file, args, { timeout: TAILSCALE_PROBE_TIMEOUT_MS }, (err, stdout, stderr) => {
    +        if (err) return reject(new Error(String(stderr || "").trim() || err.message));
    +        resolve(String(stdout).trim());
    +      });
    +    });
    +  }
    +
    +  /**
    +   * Never throws — on failure returns `_default()` with `error` set.
    +   * @returns {Promise}
    +   */
    +  async probe() {
    +    const cmd = "tailscale status --json";
    +    let out;
    +    try {
    +      out = this.spark?.isLocal
    +        ? await this._execLocal(cmd)
    +        : await sshExec(this.spark, cmd, { timeoutMs: TAILSCALE_PROBE_TIMEOUT_MS });
    +    } catch (err) {
    +      this.error = err.message || "tailscale status failed";
    +      return this._default();
    +    }
    +
    +    let raw;
    +    try {
    +      raw = JSON.parse(out);
    +    } catch {
    +      this.error = "Unparseable `tailscale status --json` output";
    +      return this._default();
    +    }
    +
    +    const parsed = parseTailscaleStatus(raw);
    +    if (!parsed.available) {
    +      this.error = "No Self in `tailscale status --json`";
    +      return this._default();
    +    }
    +    this.error = null;
    +    return { ...parsed, error: null };
    +  }
    +
    +  _default() {
    +    return {
    +      available: false,
    +      online: null,
    +      backendState: null,
    +      hostName: null,
    +      dnsName: null,
    +      tailscaleIp: null,
    +      relay: null,
    +      keyExpiry: null,
    +      keyExpired: false,
    +      version: null,
    +      health: [],
    +      error: this.error,
    +    };
    +  }
    +}
    diff --git a/server/collectors/__tests__/HermesProbe.test.js b/server/collectors/__tests__/HermesProbe.test.js
    new file mode 100644
    index 00000000..66088f5a
    --- /dev/null
    +++ b/server/collectors/__tests__/HermesProbe.test.js
    @@ -0,0 +1,284 @@
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import { HermesProbe, chooseLocalInvocation, parseHostPasswd } from "../HermesProbe.js";
    +
    +/** Build a probe whose `_run` returns a canned remote output. */
    +function probeReturning(out) {
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "mia", auth: "key" },
    +  });
    +  probe._run = async () => out;
    +  return probe;
    +}
    +
    +function probeThrowing(err) {
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "mia", auth: "key" },
    +  });
    +  probe._run = async () => {
    +    throw err;
    +  };
    +  return probe;
    +}
    +
    +test("HermesProbe reports the binary missing (not an error)", async () => {
    +  const res = await probeReturning("__HERMES_MISSING__").check();
    +  assert.equal(res.installed, false);
    +  assert.equal(res.version, null);
    +  assert.equal(res.updateAvailable, null);
    +  assert.equal(res.error, null);
    +});
    +
    +test("HermesProbe parses an up-to-date check", async () => {
    +  const out = [
    +    "HERMES_BIN=/home/mia/.local/bin/hermes",
    +    "v0.20.0 (2026.8.3)",
    +    "__CHECK_PHASE__",
    +    "Checking for updates...",
    +    "Already up to date.",
    +    "__CHECK_EXIT__0",
    +  ].join("\n");
    +  const res = await probeReturning(out).check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.version, "0.20.0");
    +  assert.equal(res.updateAvailable, false);
    +  assert.equal(res.error, null);
    +});
    +
    +test("HermesProbe does NOT report a phantom update when the fetch fails", async () => {
    +  // Regression: `hermes update --check` exits 1 both when an update is available
    +  // AND when the git fetch itself fails ("✗ Failed to fetch"). The exit code
    +  // alone must not be read as "update available".
    +  const out = [
    +    "HERMES_BIN=/home/zurih/.local/bin/hermes",
    +    "Hermes Agent v0.20.0 (2026.8.3)",
    +    "__CHECK_PHASE__",
    +    "→ Fetching from origin...",
    +    "✗ Failed to fetch.",
    +    "  fatal: detected dubious ownership in repository",
    +    "__CHECK_EXIT__1",
    +  ].join("\n");
    +  const res = await probeReturning(out).check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.updateAvailable, null);
    +  assert.ok(res.error);
    +  assert.match(res.error, /fetch/i);
    +});
    +
    +test("HermesProbe parses an update-available check", async () => {
    +  const out = [
    +    "HERMES_BIN=/home/mia/.local/bin/hermes",
    +    "v0.19.1",
    +    "__CHECK_PHASE__",
    +    "Update available: 5 commits behind origin/main.",
    +    "__CHECK_EXIT__1",
    +  ].join("\n");
    +  const res = await probeReturning(out).check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.updateAvailable, true);
    +  assert.equal(res.behindCommits, 5);
    +  assert.equal(res.error, null);
    +});
    +
    +test("HermesProbe detects an install whose --check flag is unsupported", async () => {
    +  const out = [
    +    "HERMES_BIN=/usr/local/bin/hermes",
    +    "v0.9.0",
    +    "__CHECK_PHASE__",
    +    "usage: hermes [options]",
    +    "unrecognized arguments: --check",
    +    "__CHECK_EXIT__2",
    +  ].join("\n");
    +  const res = await probeReturning(out).check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.updateAvailable, null);
    +  assert.match(res.error, /--check/);
    +});
    +
    +test("HermesProbe surfaces SSH failures as an error, not a false 'no update'", async () => {
    +  const res = await probeThrowing(
    +    new Error("SSH to 10.0.0.5 failed: Connection timed out")
    +  ).check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.updateAvailable, null);
    +  assert.match(res.error, /timed out/);
    +});
    +
    +test("HermesProbe update reports failure when the binary is missing", async () => {
    +  const res = await probeReturning("__HERMES_MISSING__").update();
    +  assert.equal(res.ok, false);
    +  assert.equal(res.installed, false);
    +  assert.match(res.error, /not found/);
    +});
    +
    +test("parseHostPasswd resolves the host user identity", () => {
    +  const passwd =
    +    "root:x:0:0:root:/root:/bin/bash\n" +
    +    "zurih:x:1000:1000:zurih,,,:/home/zurih:/bin/bash\n";
    +  assert.deepEqual(parseHostPasswd(passwd, "zurih"), {
    +    uid: 1000,
    +    gid: 1000,
    +    home: "/home/zurih",
    +  });
    +  assert.equal(parseHostPasswd(passwd, "nobody"), null);
    +  assert.equal(parseHostPasswd("zurih:x:nope:also:/home/zurih:/bin/bash\n", "zurih"), null);
    +  assert.equal(parseHostPasswd(undefined, "zurih"), null);
    +  assert.equal(parseHostPasswd("", "zurih"), null);
    +});
    +
    +test("local execution as root drops to the host user via setpriv (host mount ns)", () => {
    +  // Regression for the venv/ownership corruption: the local Spark runs hermes
    +  // through the container (root). It must run as the host user so git and uv
    +  // never write root-owned files into the user's tree, and inside the host
    +  // mount namespace so the host's git binary is visible.
    +  const inv = chooseLocalInvocation({
    +    mntNs: "/host/proc/1/ns/mnt",
    +    passwdText: "zurih:x:1000:1000:zurih,,,:/home/zurih:/bin/bash\n",
    +    currentUid: 0,
    +    user: "zurih",
    +    cmd: "echo hi",
    +  });
    +  assert.equal(inv.file, "nsenter");
    +  assert.deepEqual(inv.args.slice(0, 2), ["--mount=/host/proc/1/ns/mnt", "--"]);
    +  assert.equal(inv.args[2], "setpriv");
    +  assert.ok(inv.args.includes("--reuid=1000"));
    +  assert.ok(inv.args.includes("--regid=1000"));
    +  assert.ok(inv.args.includes("--init-groups"));
    +  const script = inv.args[inv.args.length - 1];
    +  assert.match(script, /^export HOME='\/home\/zurih'; /);
    +  assert.deepEqual(inv.repair, {
    +    home: "/home/zurih",
    +    uid: 1000,
    +    gid: 1000,
    +    mntNs: "/host/proc/1/ns/mnt",
    +  });
    +});
    +
    +test("local execution without host namespace drops directly via setpriv", () => {
    +  const inv = chooseLocalInvocation({
    +    mntNs: null,
    +    passwdText: "zurih:x:1000:1000:zurih,,,:/home/zurih:/bin/bash\n",
    +    currentUid: 0,
    +    user: "zurih",
    +    cmd: "echo hi",
    +  });
    +  assert.equal(inv.file, "setpriv");
    +  assert.equal(inv.args[0], "setpriv");
    +  assert.ok(inv.args.includes("--reuid=1000"));
    +});
    +
    +test("local fallback keeps safe.directory only when privilege drop is impossible", () => {
    +  // Non-root process (e.g. bare-host dev server not running as root): fall back
    +  // to running as-is, but keep git safe.directory so "dubious ownership" can't
    +  // block checks.
    +  const inv = chooseLocalInvocation({
    +    mntNs: "/host/proc/1/ns/mnt",
    +    passwdText: "zurih:x:1000:1000:zurih,,,:/home/zurih:/bin/bash\n",
    +    currentUid: 1000,
    +    user: "zurih",
    +    cmd: "echo hi",
    +  });
    +  assert.equal(inv.file, "nsenter");
    +  assert.equal(inv.repair, null);
    +  assert.match(inv.args[inv.args.length - 1], /GIT_CONFIG_VALUE_0='\*'/);
    +});
    +
    +test("check() reports a broken hermes launcher instead of false 'no update'", async () => {
    +  const broken = [
    +    "HERMES_BIN=/home/zurih/.local/bin/hermes",
    +    "/home/zurih/.hermes/hermes-agent/venv/bin/hermes: No such file or directory",
    +    "__HERMES_LAUNCH_FAIL__",
    +    "__CHECK_PHASE__",
    +    "",
    +    "__CHECK_EXIT__127",
    +  ].join("\n");
    +  const probe = probeReturning(broken);
    +  const res = await probe.check();
    +  assert.equal(res.installed, true);
    +  assert.equal(res.updateAvailable, null);
    +  assert.match(res.error, /cannot launch/);
    +});
    +
    +test("update() auto-repairs a broken launcher via uv pip install then retries", async () => {
    +  const broken = [
    +    "HERMES_BIN=/home/zurih/.local/bin/hermes",
    +    "venv/bin/hermes: No such file or directory",
    +    "__HERMES_LAUNCH_FAIL__",
    +    "__CHECK_PHASE__",
    +    "",
    +    "__CHECK_EXIT__127",
    +  ].join("\n");
    +  const ok = [
    +    "HERMES_BIN=/home/zurih/.local/bin/hermes",
    +    "Hermes Agent v0.20.0 (2026.8.3)",
    +    "__CHECK_PHASE__",
    +    "Already up to date.",
    +    "__CHECK_EXIT__0",
    +  ].join("\n");
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "zurih", auth: "key" },
    +  });
    +  const calls = [];
    +  probe._run = async (cmd) => {
    +    calls.push(cmd);
    +    // 1 = initial update (broken), 2 = repair pip script, 3 = repair verify,
    +    // 4 = retried update (healed)
    +    if (calls.length === 1) return broken;
    +    if (calls.length === 2) return "";
    +    if (calls.length === 3) return "Hermes Agent v0.20.0";
    +    return ok;
    +  };
    +  const res = await probe.update();
    +  assert.equal(res.ok, true);
    +  assert.equal(res.version, "0.20.0");
    +  assert.equal(calls.length, 4);
    +  assert.match(calls[1], /pip install -e \./);
    +  assert.match(calls[1], /VIRTUAL_ENV='\/home\/zurih\/\.hermes\/hermes-agent\/venv'/);
    +});
    +
    +test("pendingCommits() parses the git probe output", async () => {
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "mia", auth: "key" },
    +  });
    +  probe._run = async () =>
    +    ["__COMMITS__", "abc123\tchore: bump", "__COUNT__", "1", "__HEAD__", "f00bad"].join("\n");
    +  const res = await probe.pendingCommits();
    +  assert.equal(res.count, 1);
    +  assert.equal(res.commits[0].sha, "abc123");
    +  assert.equal(res.headSha, "f00bad");
    +});
    +
    +test("pendingCommits() returns null when git cannot run", async () => {
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "mia", auth: "key" },
    +  });
    +  probe._run = async () => {
    +    throw new Error("spawn git ENOENT");
    +  };
    +  assert.equal(await probe.pendingCommits(), null);
    +});
    +
    +test("remote execution command carries no GIT_CONFIG (runs as the real user)", async () => {
    +  const probe = new HermesProbe({
    +    isLocal: false,
    +    lanIp: "10.0.0.5",
    +    ssh: { host: "10.0.0.5", user: "mia", auth: "key" },
    +  });
    +  let sent = "";
    +  probe._run = async (cmd) => {
    +    sent = cmd;
    +    return "__HERMES_MISSING__";
    +  };
    +  await probe.check();
    +  assert.doesNotMatch(sent, /GIT_CONFIG/);
    +});
    diff --git a/server/collectors/__tests__/HermesReleases.test.js b/server/collectors/__tests__/HermesReleases.test.js
    new file mode 100644
    index 00000000..3ee93f02
    --- /dev/null
    +++ b/server/collectors/__tests__/HermesReleases.test.js
    @@ -0,0 +1,69 @@
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import {
    +  compareSemver,
    +  parsePendingCommits,
    +  parseRelease,
    +} from "../HermesReleases.js";
    +
    +test("parseRelease maps and sanitizes a GitHub release payload", () => {
    +  const release = parseRelease({
    +    tag_name: "v2026.7.7.2",
    +    name: "Hermes Agent v0.18.1 (v2026.7.7.2)",
    +    published_at: "2026-07-08T00:00:00Z",
    +    html_url: "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.7.7.2",
    +    body: "## What's Changed\n\n* Updated things\n",
    +  });
    +  assert.equal(release.tagName, "v2026.7.7.2");
    +  assert.equal(release.name, "Hermes Agent v0.18.1 (v2026.7.7.2)");
    +  assert.equal(release.semver, "0.18.1");
    +  assert.equal(release.version, "2026.7.7.2");
    +  assert.equal(release.publishedAt, "2026-07-08T00:00:00Z");
    +  assert.equal(release.htmlUrl, "https://github.com/NousResearch/hermes-agent/releases/tag/v2026.7.7.2");
    +  assert.equal(release.body, "## What's Changed\n\n* Updated things\n");
    +});
    +
    +test("parseRelease extracts semver for bump detection", () => {
    +  // Name is preferred (carries the hermes v0.x.y version); the tag fallback
    +  // yields a date-style "semver" which is fine for a safe comparison.
    +  assert.equal(
    +    parseRelease({ tag_name: "v2026.8.3", name: "Hermes Agent v0.20.0 (2026.8.3)" })?.semver,
    +    "0.20.0"
    +  );
    +  assert.equal(parseRelease({ tag_name: "v1.0.0" })?.semver, "1.0.0");
    +  assert.equal(parseRelease({})?.semver, null);
    +});
    +
    +test("compareSemver orders dotted versions", () => {
    +  assert.equal(compareSemver("0.19.1", "0.20.0"), -1);
    +  assert.equal(compareSemver("0.20.0", "0.20.0"), 0);
    +  assert.equal(compareSemver("0.20.1", "0.20.0"), 1);
    +  assert.equal(compareSemver("1.0.0", "0.99.99"), 1);
    +  assert.equal(compareSemver("0.20", "0.20.0"), 0);
    +  assert.equal(compareSemver("garbage", "0.1.0"), -1);
    +});
    +
    +test("parsePendingCommits extracts commit lines and counters", () => {
    +  const out = [
    +    "__COMMITS__",
    +    "abc123\tfix(gpu): restore panel",
    +    "def456\tfeat(net): add link speed",
    +    "junk-line-without-tab",
    +    "__COUNT__",
    +    "2",
    +    "__HEAD__",
    +    "a726a4ae",
    +  ].join("\n");
    +  const parsed = parsePendingCommits(out);
    +  assert.equal(parsed.count, 2);
    +  assert.equal(parsed.headSha, "a726a4ae");
    +  assert.deepEqual(parsed.commits, [
    +    { sha: "abc123", title: "fix(gpu): restore panel" },
    +    { sha: "def456", title: "feat(net): add link speed" },
    +  ]);
    +});
    +
    +test("parsePendingCommits returns null on empty input", () => {
    +  assert.equal(parsePendingCommits(""), null);
    +  assert.equal(parsePendingCommits(undefined), null);
    +});
    diff --git a/server/collectors/__tests__/LlmDaily.test.js b/server/collectors/__tests__/LlmDaily.test.js
    new file mode 100644
    index 00000000..a3261772
    --- /dev/null
    +++ b/server/collectors/__tests__/LlmDaily.test.js
    @@ -0,0 +1,59 @@
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import fs from "fs";
    +import os from "os";
    +import path from "path";
    +import { LlmDailyStore } from "../LlmDaily.js";
    +
    +function tmpStore() {
    +  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "llm-daily-"));
    +  return new LlmDailyStore(path.join(dir, "llm-daily.json"));
    +}
    +
    +test("LlmDailyStore: busy samples roll into UTC day max/avg", () => {
    +  const store = tmpStore();
    +  const now = new Date("2026-08-16T12:00:00.000Z");
    +  store.record("spark-a", 8888, { available: true, generationTps: 10, prefillTps: 40 }, now);
    +  store.record("spark-a", 8888, { available: true, generationTps: 30, prefillTps: 0 }, now);
    +  store.record("spark-a", 8888, { available: true, generationTps: 0, prefillTps: 0 }, now);
    +  const { days } = store.getSeries("spark-a", 8888, { days: 1, now });
    +  assert.equal(days.length, 1);
    +  assert.equal(days[0].date, "2026-08-16");
    +  assert.equal(days[0].decodeMax, 30);
    +  assert.equal(days[0].decodeAvg, 20);
    +  assert.equal(days[0].prefillMax, 40);
    +  assert.equal(days[0].prefillAvg, 40);
    +  assert.equal(days[0].cachedPrefillMax, null);
    +});
    +
    +test("LlmDailyStore: ds4 split rates + calendar zeros", () => {
    +  const store = tmpStore();
    +  const now = new Date("2026-08-16T12:00:00.000Z");
    +  store.record(
    +    "spark-a",
    +    8888,
    +    {
    +      available: true,
    +      generationTps: 12,
    +      prefillTps: 80,
    +      cachedPrefillTps: 400,
    +      uncachedPrefillTps: 80,
    +    },
    +    now
    +  );
    +  const { days } = store.getSeries("spark-a", 8888, { days: 3, now });
    +  assert.equal(days.length, 3);
    +  assert.equal(days[0].date, "2026-08-14");
    +  assert.equal(days[0].decodeMax, 0);
    +  assert.equal(days[2].cachedPrefillMax, 400);
    +  assert.equal(days[2].uncachedPrefillMax, 80);
    +});
    +
    +test("LlmDailyStore: skips unavailable probes", () => {
    +  const store = tmpStore();
    +  const now = new Date("2026-08-16T12:00:00.000Z");
    +  store.record("spark-a", 8888, { available: false, generationTps: 99 }, now);
    +  const { days } = store.getSeries("spark-a", 8888, { days: 1, now });
    +  assert.equal(days[0].decodeMax, 0);
    +  assert.equal(days[0].decodeAvg, null);
    +});
    diff --git a/server/collectors/__tests__/LlmProbe.ds4.test.js b/server/collectors/__tests__/LlmProbe.ds4.test.js
    index 65457a45..df9f774b 100644
    --- a/server/collectors/__tests__/LlmProbe.ds4.test.js
    +++ b/server/collectors/__tests__/LlmProbe.ds4.test.js
    @@ -117,7 +117,19 @@ test("_applyDs4Metrics: gauges + counters + prefix hit rate", () => {
         );
       probe._applyDs4Metrics(active, 2);
       assert.equal(probe.generationTps, 20); // (90-50)/2
    -  assert.equal(probe.prefillTps, 40); // ((180+300)-(100+300))/2
    +  assert.equal(probe.prefillTps, 40); // computed only (180-100)/2
    +  assert.equal(probe.uncachedPrefillTps, 40);
    +  assert.equal(probe.cachedPrefillTps, 0);
    +
    +  // Cached tokens jumping must not inflate prefill
    +  const cachedJump = active.replace(
    +    'ds4_tokens_prefilled_total{kind="cached"} 300.0',
    +    'ds4_tokens_prefilled_total{kind="cached"} 3000.0'
    +  );
    +  probe._applyDs4Metrics(cachedJump, 2);
    +  assert.equal(probe.prefillTps, 0);
    +  assert.equal(probe.uncachedPrefillTps, 0);
    +  assert.equal(probe.cachedPrefillTps, 1350); // (3000-300)/2
     });
     
     test("probe: ds4 path reads context_length and does not mislabel as vllm", async () => {
    @@ -127,7 +139,7 @@ test("probe: ds4 path reads context_length and does not mislabel as vllm", async
       probe.authOpen = true;
       probe._lastDetectAt = Date.now();
       probe.lastProbeTime = Date.now() - 2000;
    -  probe.lastTokenCounts = { input: 400, output: 50 };
    +  probe.lastTokenCounts = { input: 100, output: 50 };
       probe._fetch = async (url) => {
         const u = String(url);
         if (u.endsWith("/v1/models")) {
    diff --git a/server/collectors/__tests__/LlmProbe.exl3.test.js b/server/collectors/__tests__/LlmProbe.exl3.test.js
    new file mode 100644
    index 00000000..ae132b43
    --- /dev/null
    +++ b/server/collectors/__tests__/LlmProbe.exl3.test.js
    @@ -0,0 +1,164 @@
    +/**
    + * Unit tests for EXL3 tools/serve_openai.py detection and /health tok/s.
    + */
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import { LlmProbe } from "../LlmProbe.js";
    +
    +function jsonRes(body, status = 200) {
    +  return {
    +    ok: status >= 200 && status < 300,
    +    status,
    +    json: async () => body,
    +    text: async () => JSON.stringify(body),
    +  };
    +}
    +
    +function notFound() {
    +  return {
    +    ok: false,
    +    status: 404,
    +    json: async () => ({}),
    +    text: async () => "",
    +  };
    +}
    +
    +test("_healthLooksLikeExl3: backend field or ok+busy", () => {
    +  assert.equal(LlmProbe._healthLooksLikeExl3({ backend: "exl3" }), true);
    +  assert.equal(LlmProbe._healthLooksLikeExl3({ ok: true, busy: false }), true);
    +  assert.equal(LlmProbe._healthLooksLikeExl3({ status: "ok" }), false);
    +  assert.equal(LlmProbe._healthLooksLikeExl3(null), false);
    +});
    +
    +test("_detectServerType: owned_by exl3 → exl3", async () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8888);
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/slots")) return notFound();
    +    if (u.endsWith("/v1/models")) {
    +      return jsonRes({
    +        data: [{ id: "qwen3.8-27b-exl3-3.5bpw-wm", owned_by: "exl3" }],
    +      });
    +    }
    +    return notFound();
    +  };
    +  await probe._detectServerType();
    +  assert.equal(probe.serverIsOpenAI, true);
    +  assert.equal(probe.backendType, "exl3");
    +});
    +
    +test("_detectServerType: OpenAI models + /health ok+busy → exl3 (not vllm)", async () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8888);
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/slots")) return notFound();
    +    if (u.endsWith("/v1/models")) {
    +      return jsonRes({
    +        data: [{ id: "qwen3.8-27b-exl3-3.5bpw-wm", owned_by: "local" }],
    +      });
    +    }
    +    if (u.endsWith("/metrics")) return notFound();
    +    if (u.endsWith("/get_server_info") || u.endsWith("/server_info")) return notFound();
    +    if (u.endsWith("/health")) {
    +      return jsonRes({
    +        ok: true,
    +        busy: true,
    +        backend: "exl3",
    +        prompt_tokens_total: 10,
    +        completion_tokens_total: 20,
    +      });
    +    }
    +    return notFound();
    +  };
    +  await probe._detectServerType();
    +  assert.equal(probe.backendType, "exl3");
    +});
    +
    +test("_applyExl3Health: counter diffs → tok/s; idle → 0", () => {
    +  const probe = new LlmProbe({ lanIp: "127.0.0.1" }, 8888);
    +  probe._applyExl3Health(
    +    {
    +      ok: true,
    +      busy: true,
    +      backend: "exl3",
    +      prompt_tokens_total: 100,
    +      completion_tokens_total: 50,
    +      context_length: 65536,
    +    },
    +    2
    +  );
    +  assert.equal(probe.totalOutputTokens, 50);
    +  assert.equal(probe.contextLength, 65536);
    +  assert.equal(probe.slotsActive, 1);
    +
    +  probe._applyExl3Health(
    +    {
    +      ok: true,
    +      busy: false,
    +      backend: "exl3",
    +      prompt_tokens_total: 100,
    +      completion_tokens_total: 50,
    +      context_length: 65536,
    +    },
    +    2
    +  );
    +  assert.equal(probe.generationTps, 0);
    +  assert.equal(probe.prefillTps, 0);
    +  assert.equal(probe.slotsActive, 0);
    +
    +  probe._applyExl3Health(
    +    {
    +      ok: true,
    +      busy: true,
    +      backend: "exl3",
    +      prompt_tokens_total: 180,
    +      completion_tokens_total: 90,
    +      context_length: 65536,
    +    },
    +    2
    +  );
    +  assert.equal(probe.generationTps, 20);
    +  assert.equal(probe.prefillTps, 40);
    +});
    +
    +test("probe: exl3 path does not mislabel as vllm", async (t) => {
    +  const now = 10_000;
    +  t.mock.method(Date, "now", () => now);
    +  const probe = new LlmProbe({ lanIp: "127.0.0.1" }, 8888);
    +  probe.serverIsOpenAI = true;
    +  probe.backendType = "exl3";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = now;
    +  probe.lastProbeTime = now - 2000;
    +  probe.lastTokenCounts = { input: 100, output: 50 };
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/v1/models")) {
    +      return jsonRes({
    +        data: [
    +          {
    +            id: "qwen3.8-27b-exl3-3.5bpw-wm",
    +            owned_by: "exl3",
    +            max_model_len: 65536,
    +          },
    +        ],
    +      });
    +    }
    +    if (u.endsWith("/health")) {
    +      return jsonRes({
    +        ok: true,
    +        busy: true,
    +        backend: "exl3",
    +        prompt_tokens_total: 140,
    +        completion_tokens_total: 90,
    +        context_length: 65536,
    +      });
    +    }
    +    return notFound();
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.backend, "exl3");
    +  assert.equal(snap.generationTps, 20);
    +  assert.equal(snap.prefillTps, 20);
    +  assert.equal(snap.available, true);
    +});
    diff --git a/server/collectors/__tests__/LlmProbe.sglang.test.js b/server/collectors/__tests__/LlmProbe.sglang.test.js
    index 9ec75a33..a08749f5 100644
    --- a/server/collectors/__tests__/LlmProbe.sglang.test.js
    +++ b/server/collectors/__tests__/LlmProbe.sglang.test.js
    @@ -67,6 +67,81 @@ test("_probeIsSglang: true when /get_server_info returns JSON object", async ()
       assert.equal(await probe._probeIsSglang(), true);
     });
     
    +test("_probeIsSglang: prefers /server_info and skips deprecated /get_server_info", async () => {
    +  const probe = new LlmProbe({ lanIp: "127.0.0.1" }, 30000);
    +  const hits = [];
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    hits.push(u.slice(u.lastIndexOf("/")));
    +    if (u.endsWith("/server_info")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({ version: "0.5.0", model_path: "org/model" }),
    +      };
    +    }
    +    if (u.endsWith("/get_server_info")) {
    +      assert.fail("must not call deprecated /get_server_info when /server_info works");
    +    }
    +    return { ok: false, status: 404, json: async () => ({}) };
    +  };
    +  assert.equal(await probe._probeIsSglang(), true);
    +  assert.deepEqual(hits, ["/server_info"]);
    +});
    +
    +test("probe: prefers /server_info and /model_info over deprecated aliases", async () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  probe.serverIsOpenAI = true;
    +  probe.backendType = "sglang";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = Date.now();
    +  probe.lastProbeTime = Date.now() - 2000;
    +  const hits = [];
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    const path = u.slice(u.lastIndexOf("/"));
    +    hits.push(path);
    +    if (u.endsWith("/v1/models")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          data: [{ id: "org/model", owned_by: "sglang", max_model_len: 8192 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/server_info")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          model_path: "org/model",
    +          context_length: 8192,
    +          internal_states: [{ last_gen_throughput: 0 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/model_info")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({ model_path: "org/ShortName" }),
    +      };
    +    }
    +    if (u.endsWith("/get_server_info") || u.endsWith("/get_model_info")) {
    +      assert.fail(`must not call deprecated ${path} when current endpoints work`);
    +    }
    +    return { ok: false, status: 404, json: async () => ({}), text: async () => "" };
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.backend, "sglang");
    +  assert.equal(snap.modelId, "org/ShortName");
    +  assert.equal(hits.includes("/get_server_info"), false);
    +  assert.equal(hits.includes("/get_model_info"), false);
    +  assert.equal(hits.includes("/server_info"), true);
    +  assert.equal(hits.includes("/model_info"), true);
    +});
    +
     test("_probeIsSglang: false when endpoints missing", async () => {
       const probe = new LlmProbe({ lanIp: "127.0.0.1" }, 8000);
       probe._fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
    @@ -290,3 +365,186 @@ test("probe: modern sglang without totals still reports last_gen tok/s", async (
       assert.equal(snap.generationTps, 41.2);
       assert.equal(snap.available, true);
     });
    +
    +test("_applySglangLoad: /v1/loads uses num_running_reqs", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  assert.equal(
    +    probe._applySglangLoad({
    +      loads: [{ num_running_reqs: 8, num_waiting_reqs: 26 }],
    +    }),
    +    true
    +  );
    +  assert.equal(probe.slotsActive, 8);
    +  assert.equal(probe.requestsRunning, 8);
    +  assert.equal(probe.requestsWaiting, 26);
    +});
    +
    +test("_applySglangLoad: /get_load num_reqs is running + waiting", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  assert.equal(
    +    probe._applySglangLoad([{ num_reqs: 34, num_waiting_reqs: 26 }]),
    +    true
    +  );
    +  assert.equal(probe.slotsActive, 8);
    +  assert.equal(probe.requestsRunning, 8);
    +  assert.equal(probe.requestsWaiting, 26);
    +});
    +
    +test("_applySglangLoad: empty / unknown payload is a no-op", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  assert.equal(probe._applySglangLoad(null), false);
    +  assert.equal(probe._applySglangLoad({}), false);
    +  assert.equal(probe.slotsActive, 0);
    +});
    +
    +test("_sglangStickyThroughput: inflight keeps a steady rate after the live window", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  assert.equal(probe._sglangStickyThroughput(40, true), 40);
    +  probe._sglangStickyTps.liveUntil = Date.now() - 1;
    +  assert.equal(probe._sglangStickyThroughput(40, true), 40);
    +  assert.equal(probe._sglangStickyThroughput(40, false), 0);
    +});
    +
    +test("probe: reachable SGLang without sleep metric is Active, not Sleeping", async () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  probe.serverIsOpenAI = true;
    +  probe.backendType = "sglang";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = Date.now();
    +  probe.lastProbeTime = Date.now() - 2000;
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/v1/models")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          data: [{ id: "org/model", owned_by: "sglang", max_model_len: 8192 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/get_server_info")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          model_path: "org/model",
    +          context_length: 8192,
    +          sleep_on_idle: true,
    +          internal_states: [{ last_gen_throughput: 0 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/v1/loads")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({ loads: [{ num_running_reqs: 0, num_waiting_reqs: 0 }] }),
    +      };
    +    }
    +    return { ok: false, status: 404, json: async () => ({}), text: async () => "" };
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.available, true);
    +  assert.equal(snap.backend, "sglang");
    +  assert.equal(snap.gpuMemoryUtilization, 1);
    +  assert.equal(snap.totalOutputTokens, 0);
    +});
    +
    +test("probe: /v1/loads inflight keeps last_gen on the first sample", async () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  probe.serverIsOpenAI = true;
    +  probe.backendType = "sglang";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = Date.now();
    +  probe.lastProbeTime = Date.now() - 2000;
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/v1/models")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          data: [{ id: "org/model", owned_by: "sglang", max_model_len: 8192 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/get_server_info")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({
    +          model_path: "org/model",
    +          context_length: 8192,
    +          internal_states: [{ last_gen_throughput: 55.5 }],
    +        }),
    +      };
    +    }
    +    if (u.endsWith("/v1/loads")) {
    +      return {
    +        ok: true,
    +        status: 200,
    +        json: async () => ({ loads: [{ num_running_reqs: 3, num_waiting_reqs: 1 }] }),
    +      };
    +    }
    +    return { ok: false, status: 404, json: async () => ({}), text: async () => "" };
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.generationTps, 55.5);
    +  assert.equal(snap.slotsActive, 3);
    +  assert.equal(snap.requestsWaiting, 1);
    +});
    +
    +test("_applySglangMetrics: cached_tokens_total vs prompt_tokens_total", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  probe._applySglangMetrics(
    +    [
    +      "sglang:generation_tokens_total 10",
    +      "sglang:prompt_tokens_total 100",
    +      'sglang:cached_tokens_total{cache_source="device"} 40',
    +      "sglang:num_running_reqs 1",
    +    ].join("\n") + "\n",
    +    2
    +  );
    +  assert.equal(probe.cachedPrefillTps, 0);
    +  assert.equal(probe.uncachedPrefillTps, 0);
    +
    +  probe._applySglangMetrics(
    +    [
    +      "sglang:generation_tokens_total 30",
    +      "sglang:prompt_tokens_total 140",
    +      'sglang:cached_tokens_total{cache_source="device"} 80',
    +      'sglang:cached_tokens_total{cache_source="host"} 80',
    +      "sglang:num_running_reqs 1",
    +    ].join("\n") + "\n",
    +    2
    +  );
    +  assert.equal(probe.generationTps, 10); // (30-10)/2
    +  assert.equal(probe.prefillTps, 20); // (140-100)/2
    +  assert.equal(probe.uncachedPrefillTps, 20);
    +  // device L1 only — do not sum HiCache host/storage layers
    +  assert.equal(probe.cachedPrefillTps, 20); // (80-40)/2
    +});
    +
    +test("_applySglangPrefillSplit does not clobber server_info tok/s", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 30000);
    +  probe.lastTokenCounts = { input: 100, output: 50 };
    +  probe._applySglangServerInfo(
    +    { total_input_tokens: 300, total_output_tokens: 150 },
    +    2
    +  );
    +  assert.equal(probe.generationTps, 50);
    +  assert.equal(probe.prefillTps, 100);
    +  probe._applySglangPrefillSplit(
    +    [
    +      "sglang:generation_tokens_total 9999",
    +      "sglang:prompt_tokens_total 140",
    +      'sglang:cached_tokens_total{cache_source="device"} 80',
    +    ].join("\n") + "\n",
    +    2
    +  );
    +  assert.equal(probe.generationTps, 50);
    +  assert.equal(probe.prefillTps, 100);
    +  assert.equal(probe.lastTokenCounts.output, 150);
    +  assert.equal(probe.cachedPrefillTps, 0); // first split sample seeds
    +});
    diff --git a/server/collectors/__tests__/LlmProbe.vllm-llamacpp.regression.test.js b/server/collectors/__tests__/LlmProbe.vllm-llamacpp.regression.test.js
    index 63fb6e9c..684ac1a0 100644
    --- a/server/collectors/__tests__/LlmProbe.vllm-llamacpp.regression.test.js
    +++ b/server/collectors/__tests__/LlmProbe.vllm-llamacpp.regression.test.js
    @@ -36,6 +36,12 @@ function textRes(txt, status = 200) {
       };
     }
     
    +function freezeProbeClock(t) {
    +  const now = 10_000;
    +  t.mock.method(Date, "now", () => now);
    +  return now;
    +}
    +
     test("vLLM detect: /v1/models + vllm /metrics → vllm (not ds4/sglang)", async () => {
       const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
       const hits = [];
    @@ -58,13 +64,14 @@ test("vLLM detect: /v1/models + vllm /metrics → vllm (not ds4/sglang)", async
       assert.ok(!hits.includes("/get_server_info") || hits.includes("/metrics"));
     });
     
    -test("vLLM probe: counter diffs + tiles; skips get_server_info when known vllm", async () => {
    +test("vLLM probe: counter diffs + tiles; skips get_server_info when known vllm", async (t) => {
    +  const now = freezeProbeClock(t);
       const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
       probe.serverIsOpenAI = true;
       probe.backendType = "vllm";
       probe.authOpen = true;
    -  probe._lastDetectAt = Date.now();
    -  probe.lastProbeTime = Date.now() - 2000;
    +  probe._lastDetectAt = now;
    +  probe.lastProbeTime = now - 2000;
       probe.lastTokenCounts = { input: 1000, output: 500 };
       const hits = [];
       probe._fetch = async (url) => {
    @@ -90,8 +97,8 @@ test("vLLM probe: counter diffs + tiles; skips get_server_info when known vllm",
       assert.equal(snap.backend, "vllm");
       assert.equal(snap.modelId, "meta-llama/Llama-3.1-8B");
       assert.equal(snap.contextLength, 8192);
    -  assert.equal(snap.generationTps, 200); // (900-500)/2
    -  assert.equal(snap.prefillTps, 100); // (1200-1000)/2
    +  assert.ok(Math.abs(snap.generationTps - 200) < 2, `generationTps ${snap.generationTps}`);
    +  assert.ok(Math.abs(snap.prefillTps - 100) < 2, `prefillTps ${snap.prefillTps}`);
       assert.equal(snap.slotsActive, 2);
       assert.equal(snap.requestsWaiting, 1);
       assert.equal(snap.kvCacheUsage, 0.42);
    @@ -124,6 +131,73 @@ test("vLLM idle: flat counters → 0 tok/s (not sticky gauge logic)", async () =
       assert.equal(snap.prefillTps, 0);
     });
     
    +test("vLLM prefill uses TTFT histogram sum when it advances", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    +  probe.lastTokenCounts = { input: 1000, output: 500 };
    +  probe.lastTtftSum = 10;
    +  const body = `${VLLM_METRICS.replace("1000.0", "1200.0").replace("500.0", "900.0")}
    +vllm:time_to_first_token_seconds_sum{engine="0"} 10.05
    +`;
    +  probe._applyVllmMetrics(body, 2);
    +  assert.equal(probe.generationTps, 200); // (900-500)/2
    +  // Δprompt 200 / Δttft 0.05s = 4000 tok/s, not smeared 200/2=100
    +  assert.equal(probe.prefillTps, 4000);
    +});
    +
    +test("vLLM prefill falls back to poll dt when TTFT sum is flat", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    +  probe.lastTokenCounts = { input: 1000, output: 500 };
    +  probe.lastTtftSum = 10;
    +  const body = `${VLLM_METRICS.replace("1000.0", "1200.0").replace("500.0", "900.0")}
    +vllm:time_to_first_token_seconds_sum{engine="0"} 10.0
    +`;
    +  probe._applyVllmMetrics(body, 2);
    +  assert.equal(probe.prefillTps, 100); // (1200-1000)/2
    +});
    +
    +test("vLLM prefill uses live iteration tokens before decode starts", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    +  probe.lastTokenCounts = { input: 1000, output: 500 };
    +  probe.lastIterSum = 2000;
    +  const prefillBody = `${VLLM_METRICS}
    +vllm:iteration_tokens_total_sum{engine="0"} 3600.0
    +`;
    +  probe._applyVllmMetrics(prefillBody, 2);
    +  // Δiter 1600 / 2s, generation flat → live prefill during PREFILL
    +  assert.equal(probe.prefillTps, 800);
    +  assert.equal(probe.generationTps, 0);
    +});
    +
    +test("vLLM prefill still counts when first decode tokens share the poll", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    +  probe.lastTokenCounts = { input: 1000, output: 500 };
    +  probe.lastIterSum = 2000;
    +  probe._applyVllmMetrics(
    +    `${VLLM_METRICS.replace("500.0", "700.0")}
    +vllm:iteration_tokens_total_sum{engine="0"} 4200.0
    +`,
    +    2
    +  );
    +  // Δiter 2200 − Δgen 200 = 2000 prefill tokens / 2s
    +  assert.equal(probe.prefillTps, 1000);
    +  assert.equal(probe.generationTps, 100);
    +});
    +
    +test("vLLM decode-only iteration surplus is treated as spec noise, not prefill", () => {
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    +  probe.lastTokenCounts = { input: 1000, output: 500 };
    +  probe.lastIterSum = 2000;
    +  probe._applyVllmMetrics(
    +    `${VLLM_METRICS.replace("500.0", "900.0")}
    +vllm:iteration_tokens_total_sum{engine="0"} 2480.0
    +`,
    +    2
    +  );
    +  // Δiter 480, Δgen 400 → surplus 80 < 50% of decode → ignore
    +  assert.equal(probe.generationTps, 200);
    +  assert.equal(probe.prefillTps, 0);
    +});
    +
     test("vLLM /metrics body is not misread as ds4", () => {
       assert.equal(LlmProbe._metricsLookLikeDs4(VLLM_METRICS), false);
       const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8000);
    @@ -152,13 +226,14 @@ test("llama.cpp detect: /slots array wins over OpenAI paths", async () => {
       assert.equal(probe.backendType, "llama.cpp");
     });
     
    -test("llama.cpp probe: slot deltas → tok/s; props for model", async () => {
    +test("llama.cpp probe: slot deltas → tok/s; props for model", async (t) => {
    +  const now = freezeProbeClock(t);
       const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8080);
       probe.serverIsOpenAI = false;
       probe.backendType = "llama.cpp";
       probe.authOpen = true;
    -  probe._lastDetectAt = Date.now();
    -  probe.lastProbeTime = Date.now() - 2000;
    +  probe._lastDetectAt = now;
    +  probe.lastProbeTime = now - 2000;
       probe.slotState.set(0, { decoded: 10, prompted: 5 });
       probe._fetch = async (url) => {
         const u = String(url);
    @@ -197,6 +272,72 @@ test("llama.cpp probe: slot deltas → tok/s; props for model", async () => {
       assert.equal(snap.prefillTps, 10); // (25-5)/2
       assert.equal(snap.totalOutputTokens, 50);
       assert.equal(snap.available, true);
    +  assert.equal(snap.cachedPrefillTps, null);
    +  assert.equal(snap.uncachedPrefillTps, null);
    +});
    +
    +test("llama.cpp probe: n_prompt_tokens_cache → cached vs uncached prefill", async (t) => {
    +  const now = freezeProbeClock(t);
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8080);
    +  probe.serverIsOpenAI = false;
    +  probe.backendType = "llama.cpp";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = now;
    +  probe.lastProbeTime = now - 2000;
    +  probe.slotState.set(0, { decoded: 10, prompted: 5 });
    +  probe.lastPrefillKinds = { cached: 10, computed: 5 };
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/slots")) {
    +      return jsonRes([
    +        {
    +          id: 0,
    +          n_decoded: 50,
    +          n_prompt_tokens_processed: 25,
    +          n_prompt_tokens_cache: 40,
    +          is_processing: true,
    +        },
    +      ]);
    +    }
    +    if (u.endsWith("/props")) return jsonRes({ model_alias: "m" });
    +    return jsonRes({}, 404);
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.prefillTps, 10); // processed (25-5)/2
    +  assert.equal(snap.uncachedPrefillTps, 10); // (25-5)/2
    +  assert.equal(snap.cachedPrefillTps, 15); // (40-10)/2
    +});
    +
    +test("llama.cpp: n_prompt_tokens_processed 0 is not treated as missing", async (t) => {
    +  const now = freezeProbeClock(t);
    +  const probe = new LlmProbe({ lanIp: "10.0.0.1" }, 8080);
    +  probe.serverIsOpenAI = false;
    +  probe.backendType = "llama.cpp";
    +  probe.authOpen = true;
    +  probe._lastDetectAt = now;
    +  probe.lastProbeTime = now - 2000;
    +  probe.slotState.set(0, { decoded: 10, prompted: 0 });
    +  probe.lastPrefillKinds = { cached: 10, computed: 0 };
    +  probe._fetch = async (url) => {
    +    const u = String(url);
    +    if (u.endsWith("/slots")) {
    +      return jsonRes([
    +        {
    +          id: 0,
    +          n_decoded: 10,
    +          n_prompt_tokens_processed: 0,
    +          n_prompt_tokens: 100,
    +          n_prompt_tokens_cache: 100,
    +          state: "idle",
    +        },
    +      ]);
    +    }
    +    if (u.endsWith("/props")) return jsonRes({ model_alias: "m" });
    +    return jsonRes({}, 404);
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.uncachedPrefillTps, 0);
    +  assert.equal(snap.cachedPrefillTps, 45); // (100-10)/2
     });
     
     test("llama.cpp idle: unchanged slot counters → 0 tok/s", async () => {
    diff --git a/server/collectors/__tests__/LlmStreaming.tokens.test.js b/server/collectors/__tests__/LlmStreaming.tokens.test.js
    index 66f8bebd..03fbec8d 100644
    --- a/server/collectors/__tests__/LlmStreaming.tokens.test.js
    +++ b/server/collectors/__tests__/LlmStreaming.tokens.test.js
    @@ -4,7 +4,15 @@
      */
     import { test } from "node:test";
     import { strict as assert } from "node:assert";
    -import { estimateTokenCount, round2 } from "../LlmStreaming.js";
    +import {
    +  applyThinkingFlags,
    +  coerceThinkingFlag,
    +  stripThinkingFlags,
    +  thinkingOffFallbackBody,
    +  estimateTokenCount,
    +  round2,
    +  describeStreamFetchError,
    +} from "../LlmStreaming.js";
     
     test("estimateTokenCount: empty → 0", () => {
       assert.equal(estimateTokenCount(""), 0);
    @@ -40,3 +48,57 @@ test("live decode rate formula matches final decodeTps shape", () => {
       const live = round2((decodeTokens / elapsedMs) * 1000);
       assert.equal(live, 24.75);
     });
    +
    +test("prefill tok/s is prompt tokens over TTFT", () => {
    +  const prefillTokens = 1024;
    +  const ttftMs = 80;
    +  const prefillTps = round2((prefillTokens / ttftMs) * 1000);
    +  assert.equal(prefillTps, 12800);
    +});
    +
    +test("coerceThinkingFlag defaults off", () => {
    +  assert.equal(coerceThinkingFlag(undefined), false);
    +  assert.equal(coerceThinkingFlag(false), false);
    +  assert.equal(coerceThinkingFlag("false"), false);
    +  assert.equal(coerceThinkingFlag(true), true);
    +  assert.equal(coerceThinkingFlag("true"), true);
    +});
    +
    +test("applyThinkingFlags always sends MiniMax thinking_mode even without model id", () => {
    +  const off = applyThinkingFlags({ messages: [] }, null, false);
    +  assert.equal(off.chat_template_kwargs.enable_thinking, false);
    +  assert.equal(off.chat_template_kwargs.thinking, false);
    +  assert.equal(off.chat_template_kwargs.thinking_mode, "disabled");
    +
    +  const on = applyThinkingFlags({ messages: [] }, "Qwen3-32B", true);
    +  assert.equal(on.chat_template_kwargs.enable_thinking, true);
    +  assert.equal(on.chat_template_kwargs.thinking_mode, "enabled");
    +});
    +
    +test("thinking-off 400 fallback keeps an explicit disable, strip does not", () => {
    +  const body = applyThinkingFlags({ model: "x", stream: true }, "MiniMax-M2.5", false);
    +  const fallback = thinkingOffFallbackBody(body);
    +  assert.equal(fallback.enable_thinking, false);
    +  assert.deepEqual(fallback.thinking, { type: "disabled" });
    +  assert.equal(fallback.chat_template_kwargs.enable_thinking, false);
    +
    +  const stripped = stripThinkingFlags({ ...body, chat_template_kwargs: { ...body.chat_template_kwargs } });
    +  assert.equal(stripped.chat_template_kwargs, undefined);
    +  assert.equal(stripped.enable_thinking, undefined);
    +});
    +
    +test("describeStreamFetchError maps undici 5-minute idle timeouts", () => {
    +  assert.equal(
    +    describeStreamFetchError({
    +      name: "HeadersTimeoutError",
    +      code: "UND_ERR_HEADERS_TIMEOUT",
    +      message: "Headers Timeout Error",
    +    }),
    +    "HTTP idle timeout (UND_ERR_HEADERS_TIMEOUT): no data from the LLM for 5 minutes"
    +  );
    +  assert.equal(
    +    describeStreamFetchError({ name: "AbortError", message: "This operation was aborted" }),
    +    "Request aborted or timed out"
    +  );
    +  assert.equal(describeStreamFetchError({ message: "ECONNRESET" }), "ECONNRESET");
    +});
    diff --git a/server/collectors/__tests__/PrefillBench.test.js b/server/collectors/__tests__/PrefillBench.test.js
    new file mode 100644
    index 00000000..d32dbd48
    --- /dev/null
    +++ b/server/collectors/__tests__/PrefillBench.test.js
    @@ -0,0 +1,89 @@
    +/**
    + * PrefillBench helpers + job-manager gates (no live LLM calls).
    + */
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import os from "os";
    +import path from "path";
    +import fs from "fs";
    +import {
    +  ALLOWED_CONTEXT_SIZES,
    +  DEFAULT_CONTEXT_SIZES,
    +  PrefillBenchManager,
    +  buildPrefillPrompt,
    +  formatContextSize,
    +  normalizeContextSizes,
    +  timeoutMsForSize,
    +} from "../PrefillBench.js";
    +
    +test("allowed sizes include 300k and power-of-two steps", () => {
    +  assert.ok(ALLOWED_CONTEXT_SIZES.includes(300000));
    +  assert.ok(ALLOWED_CONTEXT_SIZES.includes(1024));
    +  assert.ok(ALLOWED_CONTEXT_SIZES.includes(131072));
    +  assert.deepEqual(DEFAULT_CONTEXT_SIZES, [4096, 8192, 16384, 32768]);
    +});
    +
    +test("formatContextSize uses compact labels", () => {
    +  assert.equal(formatContextSize(1024), "1k");
    +  assert.equal(formatContextSize(32768), "32k");
    +  assert.equal(formatContextSize(300000), "300k");
    +  assert.equal(formatContextSize(262144), "256k");
    +});
    +
    +test("normalizeContextSizes sorts, uniques, and drops unknowns", () => {
    +  assert.deepEqual(normalizeContextSizes([8192, 1024, 8192, 99, "4096"]), [
    +    1024, 4096, 8192,
    +  ]);
    +  assert.deepEqual(normalizeContextSizes("nope"), []);
    +  assert.deepEqual(normalizeContextSizes([]), []);
    +});
    +
    +test("buildPrefillPrompt puts salt first so sizes do not share a prefix", () => {
    +  const a = buildPrefillPrompt(128, "salt-aaa");
    +  const b = buildPrefillPrompt(128, "salt-bbb");
    +  assert.ok(a.startsWith("[prefill-bench salt-aaa]"));
    +  assert.ok(b.startsWith("[prefill-bench salt-bbb]"));
    +  assert.notEqual(a.slice(0, 40), b.slice(0, 40));
    +  const small = buildPrefillPrompt(64, "x");
    +  const large = buildPrefillPrompt(4096, "x");
    +  assert.ok(large.length > small.length * 10);
    +});
    +
    +test("timeoutMsForSize scales with context and caps", () => {
    +  assert.equal(timeoutMsForSize(1024), 90_000);
    +  assert.ok(timeoutMsForSize(262144) > 1_800_000); // >30 min at 256k
    +  assert.ok(timeoutMsForSize(300000) <= 2_700_000);
    +  assert.ok(timeoutMsForSize(300000) > timeoutMsForSize(8192));
    +});
    +
    +test("PrefillBenchManager.start rejects empty sizes and overlapping jobs", () => {
    +  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "prefill-bench-"));
    +  const mgr = new PrefillBenchManager(
    +    path.join(dir, "hist.json"),
    +    path.join(dir, "active.json")
    +  );
    +  assert.throws(
    +    () =>
    +      mgr.start({
    +        sparkId: "s1",
    +        lanIp: "127.0.0.1",
    +        port: 8888,
    +        modelId: "m",
    +        contextSizes: [],
    +      }),
    +    /at least one context size/i
    +  );
    +
    +  mgr.activeBySpark.set("s1", "fake-id");
    +  assert.throws(
    +    () =>
    +      mgr.start({
    +        sparkId: "s1",
    +        lanIp: "127.0.0.1",
    +        port: 8888,
    +        modelId: "m",
    +        contextSizes: [1024],
    +      }),
    +    /already running/i
    +  );
    +});
    diff --git a/server/collectors/__tests__/SystemCollector.cpuTemp.test.js b/server/collectors/__tests__/SystemCollector.cpuTemp.test.js
    new file mode 100644
    index 00000000..463141a4
    --- /dev/null
    +++ b/server/collectors/__tests__/SystemCollector.cpuTemp.test.js
    @@ -0,0 +1,75 @@
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import { SystemCollector } from "../SystemCollector.js";
    +
    +const c = Object.create(SystemCollector.prototype);
    +const parse = (raw) => c._parseSensorTemp(raw);
    +
    +test("remote CPU command always includes the sensor dump", () => {
    +  const spark = new SystemCollector({ id: "spark-test", kind: "spark" });
    +  const host = new SystemCollector({ id: "host-test", kind: "host" });
    +  const sparkCmd = spark._buildRemoteCpuCommand();
    +  const hostCmd = host._buildRemoteCpuCommand();
    +  assert.equal(sparkCmd, hostCmd);
    +  assert.match(sparkCmd, /coretemp\|k10temp\|zenpower\|acpitz/);
    +  assert.match(sparkCmd, /thermal_zone\*\/temp/);
    +  assert.match(sparkCmd, /\|\| true$/);
    +  assert.equal((sparkCmd.match(/echo '---'/g) || []).length, 2);
    +});
    +
    +test("remote CPU collection returns temperature for DGX Spark nodes", async () => {
    +  const collector = new SystemCollector({ id: "spark-test", kind: "spark" });
    +  const result = await collector._getRemoteCpu(async (spark, command) => {
    +    assert.equal(spark.id, "spark-test");
    +    assert.match(command, /coretemp\|k10temp\|zenpower\|acpitz/);
    +    assert.match(command, /thermal_zone\*\/temp/);
    +    assert.equal((command.match(/echo '---'/g) || []).length, 2);
    +    return [
    +      "cpu 100 0 40 860 0 0 0 0",
    +      "---",
    +      "CPU architecture: 8",
    +      "---",
    +      "70900",
    +    ].join("\n");
    +  });
    +
    +  assert.equal(result.temperature, 70.9);
    +  assert.equal(result.tdp, 65);
    +});
    +
    +test("converts millidegrees to Celsius", () => {
    +  assert.equal(parse("70900"), 70.9);
    +  assert.equal(parse("69200"), 69.2);
    +});
    +
    +test("takes the first plausible reading, not the highest", () => {
    +  assert.equal(parse("70900\n80000\n62200"), 70.9);
    +});
    +
    +test("skips the blank line left by the section split", () => {
    +  assert.equal(parse("\n69200\n66200\n"), 69.2);
    +});
    +
    +test("skips unreadable sensors", () => {
    +  assert.equal(parse("\n\n64500"), 64.5);
    +  assert.equal(parse("not-a-number\n64500"), 64.5);
    +});
    +
    +test("rejects out-of-range values", () => {
    +  assert.equal(parse("0"), 0);
    +  assert.equal(parse("-5000"), 0);
    +  assert.equal(parse("200000"), 0);
    +  assert.equal(parse("250000"), 0);
    +  assert.equal(parse("0\n250000\n70900"), 70.9);
    +});
    +
    +test("returns 0 when nothing is reported", () => {
    +  assert.equal(parse(""), 0);
    +  assert.equal(parse("\n\n"), 0);
    +  assert.equal(parse(undefined), 0);
    +});
    +
    +test("rounds to one decimal", () => {
    +  assert.equal(parse("69250"), 69.3);
    +  assert.equal(parse("69240"), 69.2);
    +});
    diff --git a/server/collectors/__tests__/SystemCollector.hostNet.test.js b/server/collectors/__tests__/SystemCollector.hostNet.test.js
    new file mode 100644
    index 00000000..d1388378
    --- /dev/null
    +++ b/server/collectors/__tests__/SystemCollector.hostNet.test.js
    @@ -0,0 +1,29 @@
    +import assert from "node:assert/strict";
    +import fs from "node:fs";
    +import test from "node:test";
    +
    +import { SystemCollector } from "../SystemCollector.js";
    +
    +function localSpark() {
    +  return {
    +    id: "local-test",
    +    name: "Local Test",
    +    isLocal: true,
    +    lanIp: "127.0.0.1",
    +  };
    +}
    +
    +test("host network file fallback reads container proc without recursing", async (t) => {
    +  const collector = new SystemCollector(localSpark());
    +  collector._hasHostProc = () => false;
    +  const reads = [];
    +  t.mock.method(fs, "readFileSync", (filePath, encoding) => {
    +    reads.push({ filePath, encoding });
    +    return "Inter-| Receive | Transmit\n";
    +  });
    +
    +  const contents = await collector._readHostNetFile("dev");
    +
    +  assert.match(contents, /Inter-\|/);
    +  assert.deepEqual(reads, [{ filePath: "/proc/net/dev", encoding: "utf-8" }]);
    +});
    diff --git a/server/collectors/__tests__/SystemCollector.nvErr.test.js b/server/collectors/__tests__/SystemCollector.nvErr.test.js
    new file mode 100644
    index 00000000..5a4036db
    --- /dev/null
    +++ b/server/collectors/__tests__/SystemCollector.nvErr.test.js
    @@ -0,0 +1,16 @@
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import { parseNvErrNoMemoryCount } from "../SystemCollector.js";
    +
    +test("parseNvErrNoMemoryCount reads grep -c output", () => {
    +  assert.equal(parseNvErrNoMemoryCount("12"), 12);
    +  assert.equal(parseNvErrNoMemoryCount("0"), 0);
    +  assert.equal(parseNvErrNoMemoryCount(" 43\n"), 43);
    +});
    +
    +test("parseNvErrNoMemoryCount defaults invalid input to 0", () => {
    +  assert.equal(parseNvErrNoMemoryCount(""), 0);
    +  assert.equal(parseNvErrNoMemoryCount("not-a-number"), 0);
    +  assert.equal(parseNvErrNoMemoryCount(undefined), 0);
    +  assert.equal(parseNvErrNoMemoryCount("-3"), 0);
    +});
    diff --git a/server/collectors/__tests__/TailscaleProbe.test.js b/server/collectors/__tests__/TailscaleProbe.test.js
    new file mode 100644
    index 00000000..fbe492bf
    --- /dev/null
    +++ b/server/collectors/__tests__/TailscaleProbe.test.js
    @@ -0,0 +1,115 @@
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import { TailscaleProbe, parseTailscaleStatus } from "../TailscaleProbe.js";
    +
    +const HEALTHY = {
    +  BackendState: "Running",
    +  Version: "1.102.2-t6cac91817",
    +  Self: {
    +    HostName: "spark-1",
    +    DNSName: "spark-1.example.ts.net.",
    +    Online: true,
    +    TailscaleIPs: ["100.64.0.1", "fd7a:115c:a1e0::1"],
    +    Relay: "nyc",
    +    KeyExpiry: "2026-09-06T20:02:31Z",
    +    Expired: false,
    +  },
    +  Health: [],
    +};
    +
    +test("parseTailscaleStatus reads a healthy node", () => {
    +  const p = parseTailscaleStatus(HEALTHY);
    +  assert.equal(p.available, true);
    +  assert.equal(p.online, true);
    +  assert.equal(p.backendState, "Running");
    +  assert.equal(p.hostName, "spark-1");
    +  assert.equal(p.tailscaleIp, "100.64.0.1");
    +  assert.equal(p.relay, "nyc");
    +  assert.equal(p.keyExpired, false);
    +  assert.deepEqual(p.health, []);
    +});
    +
    +test("parseTailscaleStatus surfaces the wedged-netmap case (healthy box, off tailnet)", () => {
    +  const p = parseTailscaleStatus({
    +    ...HEALTHY,
    +    Self: { ...HEALTHY.Self, Online: false },
    +    Health: ["Tailscale hasn't received a network map from the coordination server in 2m7s."],
    +  });
    +  assert.equal(p.available, true);
    +  assert.equal(p.online, false);
    +  assert.equal(p.backendState, "Running");
    +  assert.equal(p.health.length, 1);
    +  assert.match(p.health[0], /coordination server/);
    +});
    +
    +test("parseTailscaleStatus flags an expired key", () => {
    +  const p = parseTailscaleStatus({
    +    ...HEALTHY,
    +    Self: { ...HEALTHY.Self, Online: false, Expired: true },
    +  });
    +  assert.equal(p.keyExpired, true);
    +  assert.equal(p.online, false);
    +});
    +
    +test("parseTailscaleStatus tolerates missing and malformed fields", () => {
    +  const p = parseTailscaleStatus({ Self: {} });
    +  assert.equal(p.available, true);
    +  assert.equal(p.online, null);
    +  assert.equal(p.tailscaleIp, null);
    +  assert.equal(p.relay, null);
    +  assert.equal(p.keyExpiry, null);
    +  assert.equal(p.keyExpired, false);
    +  assert.deepEqual(p.health, []);
    +
    +  const empty = parseTailscaleStatus({});
    +  assert.equal(empty.available, false);
    +  assert.equal(empty.online, null);
    +
    +  const junk = parseTailscaleStatus({ Self: {}, Health: ["ok", "", 42, null] });
    +  assert.deepEqual(junk.health, ["ok"]);
    +});
    +
    +test("parseTailscaleStatus blanks whitespace-only strings", () => {
    +  const p = parseTailscaleStatus({
    +    BackendState: "   ",
    +    Self: { HostName: "", Relay: "  ", Online: true },
    +  });
    +  assert.equal(p.backendState, null);
    +  assert.equal(p.hostName, null);
    +  assert.equal(p.relay, null);
    +});
    +
    +test("TailscaleProbe returns default shape with error when the command fails", async () => {
    +  const probe = new TailscaleProbe({ isLocal: true });
    +  probe._execLocal = async () => {
    +    throw new Error("tailscale: command not found");
    +  };
    +  const snap = await probe.probe();
    +  assert.equal(snap.available, false);
    +  assert.equal(snap.online, null);
    +  assert.equal(snap.keyExpired, false);
    +  assert.deepEqual(snap.health, []);
    +  assert.match(snap.error, /command not found/);
    +  probe.dispose();
    +});
    +
    +test("TailscaleProbe reports an error on unparseable output rather than throwing", async () => {
    +  const probe = new TailscaleProbe({ isLocal: true });
    +  probe._execLocal = async () => "not json at all";
    +  const snap = await probe.probe();
    +  assert.equal(snap.available, false);
    +  assert.match(snap.error, /Unparseable/);
    +});
    +
    +test("TailscaleProbe clears a stale error after recovery", async () => {
    +  const probe = new TailscaleProbe({ isLocal: true });
    +  probe._execLocal = async () => {
    +    throw new Error("boom");
    +  };
    +  assert.ok((await probe.probe()).error);
    +
    +  probe._execLocal = async () => JSON.stringify(HEALTHY);
    +  const ok = await probe.probe();
    +  assert.equal(ok.error, null);
    +  assert.equal(ok.online, true);
    +});
    diff --git a/server/collectors/__tests__/llmPrompts.test.js b/server/collectors/__tests__/llmPrompts.test.js
    new file mode 100644
    index 00000000..d1386631
    --- /dev/null
    +++ b/server/collectors/__tests__/llmPrompts.test.js
    @@ -0,0 +1,93 @@
    +/**
    + * Shared prompt catalog used by Showcase and Decode bench.
    + */
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import {
    +  STRUCTURAL_PROMPTS,
    +  DECODE_STRUCTURED_PROMPT,
    +  DECODE_PROSE_PROMPT,
    +  DECODE_CODE_PROMPT,
    +  DECODE_JSON_PROMPT,
    +  DECODE_BENCH_DEFAULT_TYPE,
    +  pickShowcasePrompts,
    +  pickDecodeBenchPrompts,
    +  normalizeDecodeBenchType,
    +  decodeBenchPromptForType,
    +  withFillToMaxInstruction,
    +} from "../../../src/shared/llmPrompts.js";
    +
    +test("pickShowcasePrompts structural cycles the shared catalog", () => {
    +  const one = pickShowcasePrompts("structural", 1);
    +  assert.equal(one.length, 1);
    +  assert.equal(one[0], STRUCTURAL_PROMPTS[0]);
    +  const many = pickShowcasePrompts("structural", STRUCTURAL_PROMPTS.length + 2);
    +  assert.equal(many[0], STRUCTURAL_PROMPTS[0]);
    +  assert.equal(many[STRUCTURAL_PROMPTS.length], STRUCTURAL_PROMPTS[0]);
    +});
    +
    +test("bench-style showcase prompts apply fill-to-max", () => {
    +  const prompts = pickShowcasePrompts("structural", 2).map(withFillToMaxInstruction);
    +  for (const p of prompts) {
    +    assert.match(p, /maximum output length/);
    +  }
    +});
    +
    +test("decode bench uses lab count-1-200, not the Showcase catalog", () => {
    +  const one = pickDecodeBenchPrompts(1);
    +  assert.equal(one.length, 1);
    +  assert.equal(one[0], DECODE_STRUCTURED_PROMPT);
    +  assert.match(one[0], /Count from 1 to 200/);
    +  assert.doesNotMatch(one[0], /maximum output length/);
    +  assert.notEqual(one[0], STRUCTURAL_PROMPTS[0]);
    +
    +  const many = pickDecodeBenchPrompts(4);
    +  assert.equal(many.length, 4);
    +  assert.equal(new Set(many).size, 4);
    +  for (const p of many) {
    +    assert.match(p, /Count from 1 to 200/);
    +  }
    +});
    +
    +test("decode bench types pick fixed prompts (structured default)", () => {
    +  assert.equal(normalizeDecodeBenchType(undefined), DECODE_BENCH_DEFAULT_TYPE);
    +  assert.equal(normalizeDecodeBenchType("nope"), "structured");
    +  assert.equal(decodeBenchPromptForType("structured"), DECODE_STRUCTURED_PROMPT);
    +  assert.equal(decodeBenchPromptForType("prose"), DECODE_PROSE_PROMPT);
    +  assert.match(DECODE_PROSE_PROMPT, /hash map works/);
    +  assert.equal(decodeBenchPromptForType("code"), DECODE_CODE_PROMPT);
    +  assert.match(DECODE_CODE_PROMPT, /clamp_00 through clamp_49/);
    +  assert.match(DECODE_CODE_PROMPT, /No comments/);
    +  assert.doesNotMatch(DECODE_CODE_PROMPT, /JSON schema|response_format|xgrammar/i);
    +  assert.equal(decodeBenchPromptForType("json"), DECODE_JSON_PROMPT);
    +  assert.equal(DECODE_JSON_PROMPT, STRUCTURAL_PROMPTS[0]);
    +
    +  const prose = pickDecodeBenchPrompts(1, "prose");
    +  assert.equal(prose[0], DECODE_PROSE_PROMPT);
    +  const codeMany = pickDecodeBenchPrompts(3, "code");
    +  assert.equal(codeMany.length, 3);
    +  assert.equal(new Set(codeMany).size, 3);
    +  for (const p of codeMany) {
    +    assert.match(p, /clamp_00 through clamp_49/);
    +  }
    +});
    +
    +test("concurrent structural streams are unique until the catalog wraps", () => {
    +  const n = STRUCTURAL_PROMPTS.length;
    +  assert.equal(n, 18);
    +  const uniq = new Set(STRUCTURAL_PROMPTS);
    +  assert.equal(uniq.size, n, "catalog entries must be distinct");
    +
    +  const at16 = pickShowcasePrompts("structural", 16);
    +  assert.equal(new Set(at16).size, 16);
    +
    +  const at24 = pickShowcasePrompts("structural", 24);
    +  assert.equal(at24.length, 24);
    +  assert.equal(new Set(at24).size, n);
    +  assert.equal(at24[0], at24[n]);
    +});
    +
    +test("import path from collectors resolves src/shared (Docker + Node)", async () => {
    +  const mod = await import("../DecodeBench.js");
    +  assert.equal(mod.DECODE_BENCH_DEFAULTS.defaultMaxTokens, 400);
    +});
    diff --git a/server/collectors/__tests__/llmTarget.test.js b/server/collectors/__tests__/llmTarget.test.js
    new file mode 100644
    index 00000000..45e9cde4
    --- /dev/null
    +++ b/server/collectors/__tests__/llmTarget.test.js
    @@ -0,0 +1,46 @@
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import { formatLlmBaseUrl, parseLlmTargetInput } from "../../../src/shared/llmTarget.js";
    +
    +test("parseLlmTargetInput: Tailscale HTTPS URL", () => {
    +  const t = parseLlmTargetInput(
    +    "https://promaxgb10-bc60.tail96824a.ts.net/v1/models"
    +  );
    +  assert.deepEqual(t, {
    +    host: "promaxgb10-bc60.tail96824a.ts.net",
    +    port: 443,
    +    tls: true,
    +  });
    +  assert.equal(
    +    formatLlmBaseUrl(t),
    +    "https://promaxgb10-bc60.tail96824a.ts.net"
    +  );
    +});
    +
    +test("parseLlmTargetInput: host + port + tls fields", () => {
    +  const t = parseLlmTargetInput("promaxgb10-bc60.tail96824a.ts.net", 443, true);
    +  assert.equal(t.host, "promaxgb10-bc60.tail96824a.ts.net");
    +  assert.equal(t.port, 443);
    +  assert.equal(t.tls, true);
    +});
    +
    +test("parseLlmTargetInput: host:port without scheme", () => {
    +  const t = parseLlmTargetInput("192.168.1.143:8888", null, false);
    +  assert.deepEqual(t, { host: "192.168.1.143", port: 8888, tls: false });
    +  assert.equal(formatLlmBaseUrl(t), "http://192.168.1.143:8888");
    +});
    +
    +test("parseLlmTargetInput: empty host throws", () => {
    +  assert.throws(() => parseLlmTargetInput("  "), /Enter a host/);
    +});
    +
    +test("formatLlmBaseUrl omits default ports", () => {
    +  assert.equal(
    +    formatLlmBaseUrl({ host: "ex.ts.net", port: 443, tls: true }),
    +    "https://ex.ts.net"
    +  );
    +  assert.equal(
    +    formatLlmBaseUrl({ host: "ex.ts.net", port: 8443, tls: true }),
    +    "https://ex.ts.net:8443"
    +  );
    +});
    diff --git a/server/collectors/__tests__/llmTunnel.test.js b/server/collectors/__tests__/llmTunnel.test.js
    new file mode 100644
    index 00000000..1b3c469e
    --- /dev/null
    +++ b/server/collectors/__tests__/llmTunnel.test.js
    @@ -0,0 +1,187 @@
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import net from "net";
    +import os from "os";
    +import path from "path";
    +import fs from "fs";
    +import {
    +  allocateLocalPort,
    +  onceClose,
    +  probeLlmHttp,
    +  resolveLlmHttpTarget,
    +  waitForTcp,
    +} from "../llmTunnel.js";
    +import { DecodeBenchManager } from "../DecodeBench.js";
    +
    +test("onceClose runs the inner fn only once", () => {
    +  let n = 0;
    +  const close = onceClose(() => {
    +    n += 1;
    +  });
    +  close();
    +  close();
    +  assert.equal(n, 1);
    +});
    +
    +test("allocateLocalPort + waitForTcp round-trip", async () => {
    +  const port = await allocateLocalPort();
    +  assert.ok(port > 0);
    +  const server = net.createServer();
    +  await new Promise((resolve, reject) => {
    +    server.once("error", reject);
    +    server.listen(port, "127.0.0.1", resolve);
    +  });
    +  try {
    +    await waitForTcp("127.0.0.1", port, 2000);
    +  } finally {
    +    await new Promise((resolve) => server.close(resolve));
    +  }
    +});
    +
    +test("waitForTcp times out on a closed port", async () => {
    +  const port = await allocateLocalPort();
    +  await assert.rejects(() => waitForTcp("127.0.0.1", port, 200), /did not become ready/);
    +});
    +
    +test("probeLlmHttp treats 200 as reachable and fetch errors as not", async () => {
    +  const ok = await probeLlmHttp("127.0.0.1", 9, {
    +    timeoutMs: 50,
    +    fetchImpl: async () => ({ status: 200 }),
    +  });
    +  assert.equal(ok, true);
    +  const auth = await probeLlmHttp("127.0.0.1", 9, {
    +    timeoutMs: 50,
    +    fetchImpl: async () => ({ status: 401 }),
    +  });
    +  assert.equal(auth, true);
    +  const missing = await probeLlmHttp("127.0.0.1", 9, {
    +    timeoutMs: 50,
    +    fetchImpl: async () => ({ status: 404 }),
    +  });
    +  assert.equal(missing, false);
    +  const down = await probeLlmHttp("127.0.0.1", 9, {
    +    timeoutMs: 50,
    +    fetchImpl: async () => {
    +      throw new Error("econnrefused");
    +    },
    +  });
    +  assert.equal(down, false);
    +});
    +
    +test("resolveLlmHttpTarget: local uses loopback without probing", async () => {
    +  let probed = 0;
    +  const t = await resolveLlmHttpTarget(
    +    { isLocal: true, lanIp: "192.168.1.151" },
    +    8888,
    +    {
    +      probe: async () => {
    +        probed += 1;
    +        return true;
    +      },
    +      openTunnel: async () => {
    +        throw new Error("should not tunnel");
    +      },
    +    }
    +  );
    +  assert.equal(t.host, "127.0.0.1");
    +  assert.equal(t.port, 8888);
    +  assert.equal(t.via, "direct");
    +  assert.equal(probed, 0);
    +});
    +
    +test("resolveLlmHttpTarget: remote uses LAN when probe succeeds", async () => {
    +  const t = await resolveLlmHttpTarget(
    +    { isLocal: false, lanIp: "192.168.1.143" },
    +    8888,
    +    {
    +      probe: async () => true,
    +      openTunnel: async () => {
    +        throw new Error("should not tunnel");
    +      },
    +    }
    +  );
    +  assert.equal(t.host, "192.168.1.143");
    +  assert.equal(t.port, 8888);
    +  assert.equal(t.via, "direct");
    +});
    +
    +test("resolveLlmHttpTarget: remote falls back to SSH tunnel when LAN is closed", async () => {
    +  let tunneled = 0;
    +  const t = await resolveLlmHttpTarget(
    +    {
    +      isLocal: false,
    +      lanIp: "192.168.1.143",
    +      ssh: { host: "192.168.1.143", user: "mia", auth: "key" },
    +    },
    +    8888,
    +    {
    +      probe: async () => false,
    +      openTunnel: async (_spark, port) => {
    +        tunneled += 1;
    +        assert.equal(port, 8888);
    +        return {
    +          host: "127.0.0.1",
    +          port: 41234,
    +          via: "ssh-tunnel",
    +          close: () => {},
    +        };
    +      },
    +    }
    +  );
    +  assert.equal(tunneled, 1);
    +  assert.equal(t.host, "127.0.0.1");
    +  assert.equal(t.port, 41234);
    +  assert.equal(t.via, "ssh-tunnel");
    +});
    +
    +test("resolveLlmHttpTarget: combines LAN + tunnel errors", async () => {
    +  await assert.rejects(
    +    () =>
    +      resolveLlmHttpTarget(
    +        { isLocal: false, lanIp: "192.168.1.212", ssh: { user: "zurih", auth: "key" } },
    +        8888,
    +        {
    +          probe: async () => false,
    +          openTunnel: async () => {
    +            throw new Error("SSH config missing");
    +          },
    +        }
    +      ),
    +    /192\.168\.1\.212:8888[\s\S]*SSH config missing/
    +  );
    +});
    +
    +test("DecodeBench closes resolveTarget when the LLM is down", async () => {
    +  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "decode-bench-"));
    +  const mgr = new DecodeBenchManager(
    +    path.join(dir, "hist.json"),
    +    path.join(dir, "active.json")
    +  );
    +  let closed = 0;
    +  const started = mgr.start({
    +    sparkId: "remote-1",
    +    lanIp: "127.0.0.1",
    +    port: 1,
    +    modelId: "m",
    +    concurrencies: [1],
    +    maxTokens: 64,
    +    resolveTarget: async () => ({
    +      host: "127.0.0.1",
    +      port: 1,
    +      via: "direct",
    +      close: () => {
    +        closed += 1;
    +      },
    +    }),
    +  });
    +  const deadline = Date.now() + 8000;
    +  while (Date.now() < deadline) {
    +    const job = mgr.getJob(started.benchId);
    +        if (job && job.status !== "running") {
    +          assert.equal(closed, 1);
    +          return;
    +        }
    +    await new Promise((r) => setTimeout(r, 50));
    +  }
    +  assert.fail("decode bench did not finish");
    +});
    diff --git a/server/collectors/__tests__/showcasePrompts.test.js b/server/collectors/__tests__/showcasePrompts.test.js
    index 50a6b7d3..5910d12e 100644
    --- a/server/collectors/__tests__/showcasePrompts.test.js
    +++ b/server/collectors/__tests__/showcasePrompts.test.js
    @@ -18,12 +18,20 @@ const managerSrc = readFileSync(
       "utf8"
     );
     const promptsSrc = readFileSync(
    +  path.join(__dirname, "../../../src/shared/llmPrompts.js"),
    +  "utf8"
    +);
    +const showcaseUiSrc = readFileSync(
       path.join(
         __dirname,
         "../../../src/components/ShowcasePage/showcasePrompts.ts"
       ),
       "utf8"
     );
    +const benchSrc = readFileSync(
    +  path.join(__dirname, "../DecodeBench.js"),
    +  "utf8"
    +);
     
     test("withFillToMaxInstruction appends hard length rule to soft prompts", () => {
       const out = withFillToMaxInstruction("Keep expanding with more examples.");
    @@ -78,6 +86,23 @@ test("catalog prompts exist for text, structural, and mixed pickers", () => {
       assert.match(promptsSrc, /export const TEXT_PROMPTS/);
       assert.match(promptsSrc, /export const STRUCTURAL_PROMPTS/);
       assert.match(promptsSrc, /export function pickShowcasePrompts/);
    +  assert.match(showcaseUiSrc, /from \"\.\.\/\.\.\/shared\/llmPrompts\.js\"/);
       const textCount = (promptsSrc.match(/TEXT_PROMPTS/g) || []).length;
       assert.ok(textCount >= 2);
     });
    +
    +test("DecodeBench uses the lab structured protocol at temperature 0, thinking off", () => {
    +  // 1.8.3 moved DecodeBench off the Showcase structural catalog + fill-to-max
    +  // and onto the lab structured protocol (count 1 -> 200); 1.8.4 added the
    +  // output-type picker. These assertions still referenced the removed API.
    +  assert.match(benchSrc, /pickDecodeBenchPrompts\(/);
    +  assert.match(benchSrc, /decodeBenchPromptForType\(/);
    +  assert.match(benchSrc, /normalizeDecodeBenchType\(/);
    +  assert.match(benchSrc, /temperature:\s*0/);
    +  assert.match(benchSrc, /top_p:\s*1/);
    +  assert.match(benchSrc, /applyThinkingFlags\(body,\s*modelId,\s*false\)/);
    +  assert.match(benchSrc, /min_tokens:\s*maxTokens/);
    +  assert.doesNotMatch(benchSrc, /withFillToMaxInstruction/);
    +  assert.doesNotMatch(benchSrc, /uniquePrefillPrefix/);
    +  assert.doesNotMatch(benchSrc, /BENCH_PROMPTS/);
    +});
    diff --git a/server/collectors/__tests__/ssh.multiplex.test.js b/server/collectors/__tests__/ssh.multiplex.test.js
    new file mode 100644
    index 00000000..12d2d61e
    --- /dev/null
    +++ b/server/collectors/__tests__/ssh.multiplex.test.js
    @@ -0,0 +1,132 @@
    +import assert from "node:assert/strict";
    +import fs from "node:fs";
    +import test from "node:test";
    +import childProcess from "node:child_process";
    +import { syncBuiltinESMExports } from "node:module";
    +import { ensureMultiplexReady, sshExec, sshMultiplexConfig } from "../ssh.js";
    +
    +function mockExec(t, handler) {
    +  const mocked = t.mock.method(childProcess, "execFile", handler);
    +  syncBuiltinESMExports();
    +  t.after(() => {
    +    mocked.mock.restore();
    +    syncBuiltinESMExports();
    +  });
    +}
    +
    +function unit(id) {
    +  return { id, ssh: { host: "10.0.0.2", user: "sparky" } };
    +}
    +
    +const tick = () => new Promise((resolve) => setImmediate(resolve));
    +
    +test("builds a private, isolated control socket config", () => {
    +  delete process.env.SSH_CONTROL_PERSIST_SECONDS;
    +  const config = sshMultiplexConfig({ id: "spark-2" }, "10.0.0.2", "sparky", "key", null);
    +  assert.equal(config.persistSeconds, 60);
    +  assert.ok(config.args.includes("ControlMaster=auto"));
    +  assert.ok(config.args.includes("ControlPersist=60"));
    +  const pathArg = config.args.find((arg) => arg.startsWith("ControlPath="));
    +  const socketDir = pathArg.slice("ControlPath=".length).replace(/\/[^/]+$/, "");
    +  assert.equal(fs.statSync(socketDir).mode & 0o777, 0o700);
    +});
    +
    +test("isolates password credentials without exposing them", () => {
    +  const spark = { id: "test" };
    +  const first = sshMultiplexConfig(spark, "192.168.1.2", "user", "pass", "secret-one");
    +  const second = sshMultiplexConfig(spark, "192.168.1.2", "user", "pass", "secret-two");
    +  assert.notEqual(first.key, second.key);
    +  assert.equal(first.args.join(" ").includes("secret-one"), false);
    +});
    +
    +test("supports disable and clamps excessive persistence", () => {
    +  process.env.SSH_CONTROL_PERSIST_SECONDS = "0";
    +  assert.equal(sshMultiplexConfig({ id: "s" }, "10.0.0.1", "u", "key", null), null);
    +  process.env.SSH_CONTROL_PERSIST_SECONDS = "99999";
    +  assert.equal(sshMultiplexConfig({ id: "s" }, "10.0.0.1", "u", "key", null).persistSeconds, 3600);
    +  delete process.env.SSH_CONTROL_PERSIST_SECONDS;
    +});
    +
    +test("gates concurrent cold probes behind one connection setup", async () => {
    +  const config = { key: `gate-${Date.now()}`, persistSeconds: 60 };
    +  let releaseProbe;
    +  let calls = 0;
    +  const initial = ensureMultiplexReady(config, async () => {
    +    calls += 1;
    +    await new Promise((resolve) => { releaseProbe = resolve; });
    +  });
    +  await Promise.resolve();
    +  const follower = ensureMultiplexReady(config, async () => {
    +    calls += 1;
    +  });
    +  await Promise.resolve();
    +  assert.equal(calls, 1);
    +  releaseProbe();
    +  await Promise.all([initial, follower]);
    +  assert.equal(calls, 1);
    +});
    +
    +test("post-probe transport failure gates recovery and ignores stale failures", async (t) => {
    +  const spark = unit("transport-recovery");
    +  let probes = 0;
    +  let releaseRecovery;
    +  let failLate;
    +  let brokenCalls = 0;
    +  mockExec(t, (_file, args, _options, callback) => {
    +    const cmd = args.at(-1);
    +    if (cmd === "true") {
    +      probes += 1;
    +      if (probes === 2) { releaseRecovery = callback; return; }
    +    }
    +    if (cmd === "late") { failLate = callback; return; }
    +    if (cmd === "broken") {
    +      brokenCalls += 1;
    +      callback(Object.assign(new Error("lost transport"), { code: 255 }), "", "connection lost");
    +      return;
    +    }
    +    callback(null, "ok", "");
    +  });
    +  assert.equal(await sshExec(spark, "seed"), "ok");
    +  const late = assert.rejects(sshExec(spark, "late"), /connection lost/);
    +  await tick();
    +  await assert.rejects(sshExec(spark, "broken"), /connection lost/);
    +  assert.equal(brokenCalls, 1, "failed command is not replayed");
    +  const wave = Array.from({ length: 12 }, () => sshExec(spark, "metric"));
    +  await tick();
    +  assert.equal(probes, 2, "one new probe before TTL expiry");
    +  failLate(Object.assign(new Error("lost transport"), { code: 255 }), "", "connection lost");
    +  await late;
    +  const follower = sshExec(spark, "metric");
    +  await tick();
    +  assert.equal(probes, 2, "late failure preserves the recovery generation");
    +  releaseRecovery(null, "", "");
    +  assert.deepEqual(await Promise.all([...wave, follower]), Array(13).fill("ok"));
    +});
    +
    +test("remote command exit does not invalidate a healthy transport", async (t) => {
    +  let probes = 0;
    +  mockExec(t, (_file, args, _options, callback) => {
    +    if (args.at(-1) === "true") probes += 1;
    +    if (args.at(-1) === "missing") {
    +      callback(Object.assign(new Error("exit 1"), { code: 1 }), "", "not found");
    +    } else callback(null, "ok", "");
    +  });
    +  const spark = unit("remote-exit");
    +  await assert.rejects(sshExec(spark, "missing"), /not found/);
    +  await sshExec(spark, "metric");
    +  assert.equal(probes, 1);
    +});
    +
    +test("timed-out command invalidates multiplex readiness", async (t) => {
    +  let probes = 0;
    +  mockExec(t, (_file, args, _options, callback) => {
    +    if (args.at(-1) === "true") probes += 1;
    +    if (args.at(-1) === "slow") {
    +      callback(Object.assign(new Error("timed out"), { killed: true, signal: "SIGTERM", code: null }), "", "");
    +    } else callback(null, "ok", "");
    +  });
    +  const spark = unit("timeout-recovery");
    +  await assert.rejects(sshExec(spark, "slow"), /timed out/);
    +  await Promise.all([sshExec(spark, "metric"), sshExec(spark, "metric")]);
    +  assert.equal(probes, 2);
    +});
    diff --git a/server/collectors/__tests__/ssh.test.js b/server/collectors/__tests__/ssh.test.js
    new file mode 100644
    index 00000000..4d209c1b
    --- /dev/null
    +++ b/server/collectors/__tests__/ssh.test.js
    @@ -0,0 +1,42 @@
    +import { test } from "node:test";
    +import { strict as assert } from "node:assert";
    +import { sshCommandSpec } from "../ssh.js";
    +
    +const keySpark = {
    +  id: "s1",
    +  lanIp: "192.168.1.143",
    +  ssh: { host: "192.168.1.143", user: "mia", auth: "key" },
    +};
    +
    +test("sshCommandSpec: key auth uses BatchMode and destination after --", () => {
    +  const spec = sshCommandSpec(keySpark, { remoteArgv: ["echo ok"] });
    +  assert.equal(spec.file, "ssh");
    +  assert.equal(spec.targetHost, "192.168.1.143");
    +  assert.ok(spec.args.includes("BatchMode=yes"));
    +  const dash = spec.args.indexOf("--");
    +  assert.ok(dash >= 0);
    +  assert.equal(spec.args[dash + 1], "mia@192.168.1.143");
    +  assert.equal(spec.args[dash + 2], "echo ok");
    +  assert.equal(spec.env.SSHPASS, undefined);
    +});
    +
    +test("sshCommandSpec: extraSshArgs land before destination (tunnel flags)", () => {
    +  const spec = sshCommandSpec(keySpark, {
    +    extraSshArgs: ["-N", "-L", "127.0.0.1:9:127.0.0.1:8888"],
    +  });
    +  const dash = spec.args.indexOf("--");
    +  const n = spec.args.indexOf("-N");
    +  const l = spec.args.indexOf("-L");
    +  assert.ok(n >= 0 && n < dash);
    +  assert.ok(l >= 0 && l < dash);
    +  assert.equal(spec.args[l + 1], "127.0.0.1:9:127.0.0.1:8888");
    +  assert.equal(spec.args[dash + 1], "mia@192.168.1.143");
    +  assert.equal(spec.args.length, dash + 2);
    +});
    +
    +test("sshCommandSpec: missing user throws", () => {
    +  assert.throws(
    +    () => sshCommandSpec({ id: "s", lanIp: "192.168.1.1", ssh: { auth: "key" } }),
    +    /SSH config missing/
    +  );
    +});
    diff --git a/server/collectors/llmHost.js b/server/collectors/llmHost.js
    index 11edf996..fb8635db 100644
    --- a/server/collectors/llmHost.js
    +++ b/server/collectors/llmHost.js
    @@ -4,7 +4,9 @@
      * Local Sparks probe loopback: engines like ds4-server (Entrpi/ds4-on-spark
      * via ~/models/ds4f/start.sh) default to `--host 127.0.0.1`, so probing the
      * LAN IP would miss them. Remote Sparks still use lanIp (they must bind a
    - * reachable interface or sit behind a tunnel).
    + * reachable interface or sit behind a tunnel). Decode and prefill benches
    + * additionally fall back to an SSH local-forward onto remote loopback when
    + * LAN HTTP is closed.
      *
      * Requires the dashboard process to share the host network namespace when
      * running in Docker (see docker-compose `network_mode: host`).
    diff --git a/server/collectors/llmTunnel.js b/server/collectors/llmTunnel.js
    new file mode 100644
    index 00000000..d4d94a7e
    --- /dev/null
    +++ b/server/collectors/llmTunnel.js
    @@ -0,0 +1,296 @@
    +/**
    + * Resolve the HTTP target for an LLM server on a Spark.
    + *
    + * Local units (and remotes whose LLM is LAN-reachable) use llmProbeHost
    + * directly. Remote units that bind only loopback (ds4 `start.sh` default
    + * `--host 127.0.0.1`) are reached with an SSH local forward:
    + *   ssh -N -L 127.0.0.1::127.0.0.1:
    + *
    + * Decode / Prefill benches hold the tunnel for the job and close it after.
    + */
    +
    +import net from "net";
    +import { spawn } from "child_process";
    +import { LLM_PROBE_TIMEOUT_MS } from "../config.js";
    +import { isAllowedTargetHost } from "../validate.js";
    +import { llmProbeHost } from "./llmHost.js";
    +import { sshCommandSpec } from "./ssh.js";
    +
    +const TUNNEL_READY_MS = 12_000;
    +const TUNNEL_POLL_MS = 50;
    +
    +/**
    + * @param {() => void} fn
    + * @returns {() => void}
    + */
    +export function onceClose(fn) {
    +  let done = false;
    +  return () => {
    +    if (done) return;
    +    done = true;
    +    try {
    +      fn();
    +    } catch {
    +      /* ignore */
    +    }
    +  };
    +}
    +
    +/**
    + * Bind 127.0.0.1:0, then close so ssh -L can reuse the port.
    + * @returns {Promise}
    + */
    +export function allocateLocalPort() {
    +  return new Promise((resolve, reject) => {
    +    const server = net.createServer();
    +    server.once("error", reject);
    +    server.listen(0, "127.0.0.1", () => {
    +      const addr = server.address();
    +      const port = addr && typeof addr === "object" ? addr.port : 0;
    +      server.close((err) => {
    +        if (err) reject(err);
    +        else if (!Number.isInteger(port) || port < 1) reject(new Error("Failed to allocate local port"));
    +        else resolve(port);
    +      });
    +    });
    +  });
    +}
    +
    +/**
    + * Wait until a TCP connect to host:port succeeds (or timeout / abort).
    + * @param {string} host
    + * @param {number} port
    + * @param {number} timeoutMs
    + * @param {AbortSignal} [signal]
    + */
    +export async function waitForTcp(host, port, timeoutMs, signal) {
    +  const deadline = Date.now() + timeoutMs;
    +  let lastErr = null;
    +  while (Date.now() < deadline) {
    +    if (signal?.aborted) {
    +      const err = new Error("aborted");
    +      err.name = "AbortError";
    +      throw err;
    +    }
    +    try {
    +      await new Promise((resolve, reject) => {
    +        const sock = net.connect({ host, port }, () => {
    +          sock.end();
    +          resolve();
    +        });
    +        sock.once("error", reject);
    +        sock.setTimeout(400, () => {
    +          sock.destroy();
    +          reject(new Error("timeout"));
    +        });
    +      });
    +      return;
    +    } catch (err) {
    +      lastErr = err;
    +      await new Promise((r) => setTimeout(r, TUNNEL_POLL_MS));
    +    }
    +  }
    +  throw new Error(
    +    `SSH tunnel did not become ready on ${host}:${port}${lastErr ? `: ${lastErr.message}` : ""}`
    +  );
    +}
    +
    +/**
    + * Cheap reachability check (same /v1/models the connectivity test uses).
    + * @param {string} host
    + * @param {number} port
    + * @param {{ apiKey?: string | null, timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
    + */
    +export async function probeLlmHttp(host, port, opts = {}) {
    +  if (!host || !isAllowedTargetHost(host)) return false;
    +  const timeoutMs =
    +    Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
    +      ? opts.timeoutMs
    +      : LLM_PROBE_TIMEOUT_MS;
    +  const fetchImpl = opts.fetchImpl || fetch;
    +  /** @type {Record} */
    +  const headers = {};
    +  const apiKey = opts.apiKey != null ? String(opts.apiKey).trim() : "";
    +  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
    +  try {
    +    const res = await fetchImpl(`http://${host}:${port}/v1/models`, {
    +      signal: AbortSignal.timeout(timeoutMs),
    +      headers,
    +    });
    +    return res.status !== 404 && res.status < 500;
    +  } catch {
    +    return false;
    +  }
    +}
    +
    +/**
    + * Open `ssh -N -L 127.0.0.1:local:127.0.0.1:remotePort` and wait until the
    + * local side accepts connections.
    + *
    + * @param {object} spark
    + * @param {number} remotePort
    + * @param {{
    + *   signal?: AbortSignal,
    + *   onStatus?: (msg: string) => void,
    + *   spawnImpl?: typeof spawn,
    + *   allocatePort?: () => Promise,
    + * }} [opts]
    + * @returns {Promise<{ host: string, port: number, via: "ssh-tunnel", close: () => void }>}
    + */
    +export async function openSshLlmTunnel(spark, remotePort, opts = {}) {
    +  const p = Number(remotePort);
    +  if (!Number.isInteger(p) || p < 1 || p > 65535) {
    +    throw new Error("Invalid LLM port for SSH tunnel");
    +  }
    +
    +  const spawnImpl = opts.spawnImpl || spawn;
    +  const allocatePort = opts.allocatePort || allocateLocalPort;
    +  const localPort = await allocatePort();
    +  const forward = `127.0.0.1:${localPort}:127.0.0.1:${p}`;
    +
    +  opts.onStatus?.(`Opening SSH tunnel to 127.0.0.1:${p}…`);
    +
    +  const spec = sshCommandSpec(spark, {
    +    extraSshArgs: [
    +      "-N",
    +      "-o",
    +      "ExitOnForwardFailure=yes",
    +      "-o",
    +      "ServerAliveInterval=30",
    +      "-o",
    +      "ServerAliveCountMax=6",
    +      "-L",
    +      forward,
    +    ],
    +  });
    +
    +  let stderr = "";
    +  const child = spawnImpl(spec.file, spec.args, {
    +    env: spec.env,
    +    stdio: ["ignore", "ignore", "pipe"],
    +  });
    +  if (child.stderr) {
    +    child.stderr.setEncoding("text");
    +    child.stderr.on("data", (chunk) => {
    +      stderr += String(chunk);
    +      if (stderr.length > 4000) stderr = stderr.slice(-2000);
    +    });
    +  }
    +
    +  const close = onceClose(() => {
    +    try {
    +      child.kill("SIGTERM");
    +    } catch {
    +      /* ignore */
    +    }
    +    setTimeout(() => {
    +      try {
    +        if (!child.killed) child.kill("SIGKILL");
    +      } catch {
    +        /* ignore */
    +      }
    +    }, 1500).unref?.();
    +  });
    +
    +  // Ignore exit/error after the local port is accepting — mid-job drops
    +  // surface as HTTP failures on the bench streams.
    +  let opened = false;
    +  const exitPromise = new Promise((_, reject) => {
    +    child.once("error", (err) => {
    +      if (opened) return;
    +      close();
    +      reject(new Error(`SSH tunnel failed to start: ${err.message}`));
    +    });
    +    child.once("exit", (code, signal) => {
    +      if (opened) return;
    +      const detail = stderr.trim() || `code ${code}${signal ? ` signal ${signal}` : ""}`;
    +      reject(new Error(`SSH tunnel exited: ${detail}`));
    +    });
    +  });
    +
    +  const onAbort = () => {
    +    close();
    +  };
    +  if (opts.signal) {
    +    if (opts.signal.aborted) {
    +      close();
    +      const err = new Error("aborted");
    +      err.name = "AbortError";
    +      throw err;
    +    }
    +    opts.signal.addEventListener("abort", onAbort, { once: true });
    +  }
    +
    +  try {
    +    await Promise.race([
    +      waitForTcp("127.0.0.1", localPort, TUNNEL_READY_MS, opts.signal),
    +      exitPromise,
    +    ]);
    +    opened = true;
    +  } catch (err) {
    +    close();
    +    if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
    +    throw err;
    +  }
    +
    +  if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
    +
    +  return {
    +    host: "127.0.0.1",
    +    port: localPort,
    +    via: "ssh-tunnel",
    +    close,
    +  };
    +}
    +
    +/**
    + * Pick a direct LAN/loopback HTTP target, or fall back to an SSH tunnel.
    + *
    + * @param {object} spark
    + * @param {number} port
    + * @param {{
    + *   apiKey?: string | null,
    + *   signal?: AbortSignal,
    + *   onStatus?: (msg: string) => void,
    + *   probe?: typeof probeLlmHttp,
    + *   openTunnel?: typeof openSshLlmTunnel,
    + * }} [opts]
    + * @returns {Promise<{ host: string, port: number, via: "direct" | "ssh-tunnel", close: () => void }>}
    + */
    +export async function resolveLlmHttpTarget(spark, port, opts = {}) {
    +  const p = Number(port);
    +  if (!Number.isInteger(p) || p < 1 || p > 65535) {
    +    throw new Error("Invalid LLM port");
    +  }
    +
    +  const probe = opts.probe || probeLlmHttp;
    +  const openTunnel = opts.openTunnel || openSshLlmTunnel;
    +  const host = llmProbeHost(spark);
    +  const direct = {
    +    host,
    +    port: p,
    +    via: /** @type {const} */ ("direct"),
    +    close: onceClose(() => {}),
    +  };
    +
    +  if (spark?.isLocal) return direct;
    +
    +  if (host && isAllowedTargetHost(host)) {
    +    opts.onStatus?.(`Reaching LLM on ${host}:${p}…`);
    +    const ok = await probe(host, p, { apiKey: opts.apiKey });
    +    if (ok) return direct;
    +  }
    +
    +  try {
    +    return await openTunnel(spark, p, {
    +      signal: opts.signal,
    +      onStatus: opts.onStatus,
    +    });
    +  } catch (err) {
    +    if (err?.name === "AbortError") throw err;
    +    const lan = host ? `${host}:${p}` : `(no LAN IP)`;
    +    throw new Error(
    +      `LLM on ${lan} is not reachable over the LAN, and SSH tunnel to 127.0.0.1:${p} failed: ${err?.message || err}`
    +    );
    +  }
    +}
    diff --git a/server/collectors/ssh.js b/server/collectors/ssh.js
    index e2a940e5..d1b7c73d 100644
    --- a/server/collectors/ssh.js
    +++ b/server/collectors/ssh.js
    @@ -6,6 +6,7 @@
      * Password auth uses sshpass -e (password via env), not -p on the command line.
      */
     import { execFile } from "child_process";
    +import crypto from "crypto";
     import fs from "fs";
     import { COMFY_PORT, COMFY_PROBE_TIMEOUT_MS, SSH_CONNECT_TIMEOUT } from "../config.js";
     import { isAllowedTargetHost, isValidSshUser } from "../validate.js";
    @@ -14,6 +15,89 @@ import { llmProbeHost } from "./llmHost.js";
     // Detect sshpass without shelling out to `which` on every cold call —
     // checking PATH entries directly is faster and avoids spawning a shell.
     let _sshpassAvailable = null;
    +const _multiplexStates = new Map();
    +const _controlDir = fs.mkdtempSync("/tmp/sparkdash-ssh-");
    +const _controlSalt = crypto.randomBytes(32);
    +fs.chmodSync(_controlDir, 0o700);
    +
    +function controlPersistSeconds() {
    +  const configured = Number.parseInt(process.env.SSH_CONTROL_PERSIST_SECONDS ?? "60", 10);
    +  return Number.isFinite(configured) ? Math.min(3600, Math.max(0, configured)) : 60;
    +}
    +
    +function ensureControlDir() {
    +  fs.mkdirSync(_controlDir, { recursive: true, mode: 0o700 });
    +  fs.chmodSync(_controlDir, 0o700);
    +}
    +
    +/**
    + * Build an isolated OpenSSH control socket config. Credentials are included
    + * only in the digest so two password records cannot share an authenticated
    + * transport; the secret itself is never exposed in argv or the socket path.
    + */
    +export function sshMultiplexConfig(spark, targetHost, user, auth, password) {
    +  const persistSeconds = controlPersistSeconds();
    +  if (persistSeconds === 0) return null;
    +
    +  ensureControlDir();
    +  const identityFile = process.env.SSH_IDENTITY_FILE || "default";
    +  const isolationKey = [spark.id, user, targetHost, auth || "key", identityFile, password || ""].join("\0");
    +  const digest = crypto
    +    .createHash("sha256")
    +    .update(_controlSalt)
    +    .update(isolationKey)
    +    .digest("hex")
    +    .slice(0, 24);
    +  return {
    +    key: digest,
    +    persistSeconds,
    +    args: [
    +      "-o",
    +      "ControlMaster=auto",
    +      "-o",
    +      `ControlPersist=${persistSeconds}`,
    +      "-o",
    +      `ControlPath=${_controlDir}/${digest}`,
    +    ],
    +  };
    +}
    +
    +/** Establish or re-check the master before concurrent pollers run. */
    +export async function ensureMultiplexReady(config, establish) {
    +  if (!config) return;
    +
    +  const now = Date.now();
    +  let state = _multiplexStates.get(config.key);
    +  if (state && now >= state.expiresAt) {
    +    _multiplexStates.delete(config.key);
    +    state = null;
    +  }
    +
    +  if (!state) {
    +    state = {
    +      ready: Promise.resolve().then(establish),
    +      expiresAt: now + config.persistSeconds * 1000,
    +    };
    +    _multiplexStates.set(config.key, state);
    +  }
    +
    +  try {
    +    await state.ready;
    +    return state;
    +  } catch (err) {
    +    if (_multiplexStates.get(config.key) === state) _multiplexStates.delete(config.key);
    +    throw err;
    +  }
    +}
    +
    +process.once("exit", () => {
    +  try {
    +    fs.rmSync(_controlDir, { recursive: true, force: true });
    +  } catch {
    +    // ControlPersist bounds any master left behind by an abrupt shutdown.
    +  }
    +});
    +
     function sshpassAvailable() {
       if (_sshpassAvailable !== null) return _sshpassAvailable;
       try {
    @@ -55,21 +139,24 @@ function sshpassAvailable() {
     }
     
     /**
    - * Execute a command on a remote Spark via SSH.
    + * Build file/args/env for an ssh (or sshpass) invocation. No shell interpolation.
      *
    - * @param {Object} spark - Spark config object
    - * @param {string} cmd - Command to execute (passed as a single remote argv via bash -c)
    - * @param {{ timeoutMs?: number }} [options]
    - * @returns {Promise} - Trimmed stdout
    + * `extraSshArgs` sit after the shared ConnectTimeout / StrictHostKeyChecking
    + * options and before `-- user@host`. `remoteArgv` is the remote command (omit
    + * for `-N` tunnels).
    + *
    + * @param {object} spark
    + * @param {{ extraSshArgs?: string[], remoteArgv?: string[] }} [opts]
    + * @returns {{ file: string, args: string[], env: NodeJS.ProcessEnv, targetHost: string }}
      */
    -export async function sshExec(spark, cmd, options = {}) {
    -  const timeoutMs =
    -    Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;
    -  const { host, user, auth, password } = spark.ssh || {};
    -  const targetHost = host || spark.lanIp;
    +export function sshCommandSpec(spark, opts = {}) {
    +  const extraSshArgs = Array.isArray(opts.extraSshArgs) ? opts.extraSshArgs : [];
    +  const remoteArgv = Array.isArray(opts.remoteArgv) ? opts.remoteArgv : [];
    +  const { host, user, auth, password } = spark?.ssh || {};
    +  const targetHost = host || spark?.lanIp;
     
       if (!targetHost || !user) {
    -    throw new Error(`SSH config missing for ${spark.id}: host=${targetHost}, user=${user}`);
    +    throw new Error(`SSH config missing for ${spark?.id}: host=${targetHost}, user=${user}`);
       }
     
       if (!isAllowedTargetHost(targetHost)) {
    @@ -79,10 +166,6 @@ export async function sshExec(spark, cmd, options = {}) {
         throw new Error(`SSH user not allowed: ${user}`);
       }
     
    -  if (typeof cmd !== "string" || !cmd) {
    -    throw new Error("SSH command must be a non-empty string");
    -  }
    -
       // Base SSH options (no shell metacharacters in argv)
       // accept-new: trust first-seen host key (LAN ops); pin known_hosts for stricter envs
       const baseOpts = [
    @@ -93,8 +176,7 @@ export async function sshExec(spark, cmd, options = {}) {
       ];
     
       const remote = `${user}@${targetHost}`;
    -  // Remote command as a single argument — ssh does not invoke a local shell for it
    -  // when using execFile without a shell. `--` stops option parsing before destination.
    +  // `--` stops option parsing before destination.
       let file;
       let args;
       // Minimal child env — only what ssh/sshpass actually need. Spreading the full
    @@ -123,23 +205,78 @@ export async function sshExec(spark, cmd, options = {}) {
         // Password via env (sshpass -e) — never on argv or in process list as -p
         env.SSHPASS = password;
         file = "sshpass";
    -    args = ["-e", "ssh", ...baseOpts, "--", remote, cmd];
    +    args = ["-e", "ssh", ...baseOpts, ...extraSshArgs, "--", remote, ...remoteArgv];
       } else {
         // Key-based SSH (default) — BatchMode prevents hanging on missing keys
         file = "ssh";
    -    args = [...baseOpts, "-o", "BatchMode=yes", "--", remote, cmd];
    +    args = [...baseOpts, "-o", "BatchMode=yes"];
    +    const identityFile = process.env.SSH_IDENTITY_FILE;
    +    if (identityFile) {
    +      args.push("-i", identityFile);
    +    }
    +    args.push(...extraSshArgs, "--", remote, ...remoteArgv);
    +  }
    +
    +  return { file, args, env, targetHost };
    +}
    +
    +/**
    + * Execute a command on a remote Spark via SSH.
    + *
    + * @param {Object} spark - Spark config object
    + * @param {string} cmd - Command to execute (passed as a single remote argv via bash -c)
    + * @param {{ timeoutMs?: number }} [options]
    + * @returns {Promise} - Trimmed stdout
    + */
    +export async function sshExec(spark, cmd, options = {}) {
    +  const timeoutMs =
    +    Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;
    +
    +  if (typeof cmd !== "string" || !cmd) {
    +    throw new Error("SSH command must be a non-empty string");
       }
     
    -  return new Promise((resolve, reject) => {
    -    execFile(file, args, { timeout: timeoutMs, env, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
    +  const spec = sshCommandSpec(spark, { remoteArgv: [cmd] });
    +  const { user, auth, password } = spark.ssh || {};
    +  const multiplex = sshMultiplexConfig(spark, spec.targetHost, user, auth, password);
    +  // Keep tunnel callers of sshCommandSpec independent of collector mux state.
    +  const { file, args, env, targetHost } = multiplex
    +    ? sshCommandSpec(spark, { extraSshArgs: multiplex.args, remoteArgv: [cmd] })
    +    : spec;
    +
    +  const execute = (execArgs) => new Promise((resolve, reject) => {
    +    execFile(file, execArgs, { timeout: timeoutMs, env, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
           if (err) {
             const msg = stderr?.trim() || err.message;
    -        reject(new Error(`SSH to ${targetHost} failed: ${msg}`));
    +        reject(new Error(`SSH to ${targetHost} failed: ${msg}`, { cause: err }));
           } else {
             resolve(String(stdout).trim());
           }
         });
       });
    +
    +  let multiplexState;
    +  if (multiplex) {
    +    const probeArgs = [...args];
    +    probeArgs[probeArgs.length - 1] = "true";
    +    multiplexState = await ensureMultiplexReady(multiplex, () => execute(probeArgs));
    +  }
    +  try {
    +    return await execute(args);
    +  } catch (err) {
    +    // OpenSSH reports transport errors as 255. Signals/timeouts and local
    +    // execution errors also leave transport health unknown. Ordinary remote
    +    // nonzero exits do not mean the shared connection is dead.
    +    const failure = err.cause;
    +    const transportFailed = failure?.code === 255 || failure?.killed ||
    +      failure?.signal || typeof failure?.code !== "number";
    +    // A late failure from an old command must not evict a newer recovery probe.
    +    if (multiplex && transportFailed && _multiplexStates.get(multiplex.key) === multiplexState) {
    +      _multiplexStates.delete(multiplex.key);
    +    }
    +    // Never replay the command: the remote side may already have executed it.
    +    throw err;
    +  }
     }
     
     /**
    diff --git a/server/config.js b/server/config.js
    index 123c2b26..2bbf2aac 100644
    --- a/server/config.js
    +++ b/server/config.js
    @@ -15,10 +15,14 @@ const SPARKS_SECRETS_PATH =
     /** AES key file (auto-generated if SPARKDASH_SECRETS_KEY unset). */
     const SECRETS_KEY_PATH =
       process.env.SECRETS_KEY_PATH || path.join(ROOT, "config", ".secrets-key");
    +/** Daily LLM tok/s rollups (gitignored). */
    +const LLM_DAILY_JSON_PATH =
    +  process.env.LLM_DAILY_JSON_PATH || path.join(ROOT, "config", "llm-daily.json");
     
     // ─── LLM / Comfy probe timeouts ──────────────────────────
     const LLM_PROBE_TIMEOUT_MS = 3000;
     const COMFY_PROBE_TIMEOUT_MS = parseInt(process.env.COMFY_PROBE_TIMEOUT_MS || "3000", 10);
    +const TAILSCALE_PROBE_TIMEOUT_MS = parseInt(process.env.TAILSCALE_PROBE_TIMEOUT_MS || "8000", 10);
     const SSH_CONNECT_TIMEOUT = 5; // seconds
     
     // ─── Poll intervals (milliseconds) ───────────────────────
    @@ -28,10 +32,22 @@ const POLL_INTERVAL_NETWORK = parseInt(process.env.POLL_INTERVAL_NETWORK || "200
     const POLL_INTERVAL_STORAGE = parseInt(process.env.POLL_INTERVAL_STORAGE || "5000", 10);
     const POLL_INTERVAL_LLM = parseInt(process.env.POLL_INTERVAL_LLM || "2000", 10);
     const POLL_INTERVAL_COMFY = parseInt(process.env.POLL_INTERVAL_COMFY || "2000", 10);
    +// Tailnet membership changes slowly; each poll is an SSH round-trip.
    +const POLL_INTERVAL_TAILSCALE = parseInt(process.env.POLL_INTERVAL_TAILSCALE || "30000", 10);
    +// Kernel journal scan for NV_ERR_NO_MEMORY — not on the 2s GPU loop.
    +const POLL_INTERVAL_NVERR = parseInt(process.env.POLL_INTERVAL_NVERR || "60000", 10);
     // dmon -c 1 -d 1 blocks ~1s; default 2s avoids stacking with in-flight guards
     const POLL_INTERVAL_BANDWIDTH = parseInt(process.env.POLL_INTERVAL_BANDWIDTH || "2000", 10);
     // Dedicated liveness (sshTest / local ping) cadence — not a metric domain.
     const POLL_INTERVAL_LIVENESS = parseInt(process.env.POLL_INTERVAL_LIVENESS || "5000", 10);
    +// Hermes Agent update check cadence. `hermes update --check` runs `git fetch`
    +// on the target every time, so keep it slow (default 10 min).
    +const POLL_INTERVAL_HERMES = parseInt(process.env.POLL_INTERVAL_HERMES || "600000", 10);
    +// Hard cap while running `hermes update` over SSH (repo pull + dep reinstall).
    +const HERMES_UPDATE_TIMEOUT_MS = parseInt(
    +  process.env.HERMES_UPDATE_TIMEOUT_MS || "600000",
    +  10
    +);
     
     // ─── Port ────────────────────────────────────────────────
     const PORT = parseInt(process.env.PORT || "5555", 10);
    @@ -80,8 +96,10 @@ export {
       GPU_MEMORY_JSON_PATH,
       SPARKS_SECRETS_PATH,
       SECRETS_KEY_PATH,
    +  LLM_DAILY_JSON_PATH,
       LLM_PROBE_TIMEOUT_MS,
       COMFY_PROBE_TIMEOUT_MS,
    +  TAILSCALE_PROBE_TIMEOUT_MS,
       SSH_CONNECT_TIMEOUT,
       POLL_INTERVAL_GPU,
       POLL_INTERVAL_CPU,
    @@ -89,8 +107,12 @@ export {
       POLL_INTERVAL_STORAGE,
       POLL_INTERVAL_LLM,
       POLL_INTERVAL_COMFY,
    +  POLL_INTERVAL_TAILSCALE,
    +  POLL_INTERVAL_NVERR,
       POLL_INTERVAL_BANDWIDTH,
       POLL_INTERVAL_LIVENESS,
    +  POLL_INTERVAL_HERMES,
    +  HERMES_UPDATE_TIMEOUT_MS,
       PORT,
       LLM_PORT,
       COMFY_PORT,
    diff --git a/server/index.js b/server/index.js
    index 4d6de7a7..55a4c84f 100644
    --- a/server/index.js
    +++ b/server/index.js
    @@ -10,15 +10,23 @@ import { SparkRegistry } from "./sparks/SparkRegistry.js";
     import { SparkMonitor } from "./sparks/SparkMonitor.js";
     import { sshExec, sshTest, llmTest, comfyTest } from "./collectors/ssh.js";
     import { comfyCancelJob } from "./collectors/comfyActions.js";
    -import { validateSparkTarget, createRateLimiter } from "./validate.js";
    +import { validateSparkTarget, createRateLimiter, isAllowedTargetHost } from "./validate.js";
     import { getSettings, updateSettings, loadSettings } from "./settings.js";
     import { broadcastForLanIp, effectiveMac, normalizeMac, sendWol } from "./wol.js";
     import {
       decodeBenchManager,
       DECODE_BENCH_DEFAULTS,
     } from "./collectors/DecodeBench.js";
    +import {
    +  prefillBenchManager,
    +  PREFILL_BENCH_DEFAULTS,
    +} from "./collectors/PrefillBench.js";
     import { showcaseManager } from "./collectors/ShowcaseManager.js";
     import { llmProbeHost } from "./collectors/llmHost.js";
    +import { onceClose, resolveLlmHttpTarget } from "./collectors/llmTunnel.js";
    +import { formatLlmBaseUrl, parseLlmTargetInput } from "../src/shared/llmTarget.js";
    +import { llmDaily } from "./collectors/LlmDaily.js";
    +import { compareSemver, getLatestRelease } from "./collectors/HermesReleases.js";
     
     dotenv.config();
     
    @@ -26,7 +34,10 @@ const __filename = fileURLToPath(import.meta.url);
     const __dirname = path.dirname(__filename);
     const ROOT = path.resolve(__dirname, "..");
     
    -const BIND_HOST = process.env.BIND_HOST || "0.0.0.0";
    +// Default to loopback: the dashboard exposes SSH and remote power controls, so it
    +// should not be reachable on the LAN unless explicitly opted in. Set BIND_HOST to the
    +// host's LAN IP (or 0.0.0.0) to expose it; docker-compose.yml already sets 0.0.0.0.
    +const BIND_HOST = process.env.BIND_HOST || "127.0.0.1";
     const PORT = parseInt(process.env.PORT || "5555", 10);
     const LLM_PORT = parseInt(process.env.LLM_PORT || "8888", 10);
     const COMFY_PORT = parseInt(process.env.COMFY_PORT || "8188", 10);
    @@ -74,6 +85,68 @@ function resolveLlmApiKey(spark, port) {
       return key || null;
     }
     
    +/**
    + * Local Spark LLM port, or an on-demand remote host (HTTPS Tailscale, etc.).
    + * Custom `host` skips the configured-port allowlist and SSH tunnel.
    + *
    + * @param {object} spark
    + * @param {number[]} configuredPorts
    + * @param {object} body
    + */
    +function benchHttpTarget(spark, configuredPorts, body) {
    +  const hostRaw = body?.host != null ? String(body.host).trim() : "";
    +  if (hostRaw) {
    +    const parsed = parseLlmTargetInput(hostRaw, body?.port, body?.tls);
    +    if (!isAllowedTargetHost(parsed.host)) {
    +      const err = new Error(`Invalid or disallowed host: ${parsed.host}`);
    +      err.status = 400;
    +      throw err;
    +    }
    +    return {
    +      port: parsed.port,
    +      host: parsed.host,
    +      tls: parsed.tls,
    +      custom: true,
    +      apiKey: null,
    +      resolveTarget: async ({ onStatus }) => {
    +        onStatus?.(`Reaching ${formatLlmBaseUrl(parsed)}…`);
    +        return {
    +          host: parsed.host,
    +          port: parsed.port,
    +          tls: parsed.tls,
    +          via: "direct",
    +          close: onceClose(() => {}),
    +        };
    +      },
    +    };
    +  }
    +
    +  let port = body?.port != null ? Number(body.port) : configuredPorts[0];
    +  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    +    const err = new Error("Invalid port");
    +    err.status = 400;
    +    throw err;
    +  }
    +  if (!configuredPorts.includes(port)) {
    +    const err = new Error("port is not configured for this Spark");
    +    err.status = 400;
    +    throw err;
    +  }
    +  return {
    +    port,
    +    host: null,
    +    tls: false,
    +    custom: false,
    +    apiKey: resolveLlmApiKey(spark, port),
    +    resolveTarget: ({ onStatus, signal }) =>
    +      resolveLlmHttpTarget(spark, port, {
    +        apiKey: resolveLlmApiKey(spark, port),
    +        onStatus,
    +        signal,
    +      }),
    +  };
    +}
    +
     // Rate-limit ephemeral + registered connectivity tests (per client IP)
     const allowTest = createRateLimiter(20, 60_000);
     
    @@ -94,6 +167,8 @@ function startMonitor(spark) {
             if (mon) mon.updateConfig(registry.getSpark(id));
           }
         },
    +    // Hermes check / update results must not wait for the next broadcast tick.
    +    onHermesChange: () => forceBroadcast(),
       });
       monitors.set(spark.id, monitor);
       monitor.start();
    @@ -236,7 +311,11 @@ app.patch("/api/sparks/:id", (req, res) => {
           return res.json({ success: true, spark, hasPassword: true });
         }
     
    -    const spark = registry.updateSpark(req.params.id, body);
    +    // LLM API keys: an llmPorts change is the second bypass besides out-of-band
    +    // sparks.json writes. patchSpark() arms the reconcile on the llmPorts
    +    // own-property (any shape — [] and the legacy scalar are applied by the
    +    // normalizer too) and syncs against the post-normalize ports.
    +    const { spark } = registry.patchSpark(req.params.id, body);
         // Restart monitor so collectors pick up host/auth/isLocal changes
         stopMonitor(req.params.id);
         startMonitor(spark);
    @@ -372,16 +451,134 @@ app.post("/api/sparks/:id/refresh/:domain", async (req, res) => {
           return res.status(400).json({ error: "Only 'storage' domain is supported" });
         }
         await monitor.refreshDomain(domain);
    -    // Broadcast updated snapshot immediately (force, ignoring the diff cache)
    -    const payload = buildSnapshotPayload();
    -    _lastBroadcastPayload = payload;
    -    broadcastPayload(payload);
    +    forceBroadcast();
         res.json({ success: true, domain });
       } catch (err) {
         res.status(500).json({ error: err.message });
       }
     });
     
    +// ─── Hermes Agent ───────────────────────────────────
    +// Batch route first (like shutdown-all/wake-all): a plain Sparks-suffixed
    +// path (3 segments) that cannot be captured by /api/sparks/:id/hermes/* (4).
    +/** One-click `hermes update` on every Spark with hermes monitoring enabled. */
    +app.post("/api/sparks/hermes/update-all", async (_req, res) => {
    +  const results = [];
    +  for (const spark of registry.sparks) {
    +    const monitor = monitors.get(spark.id);
    +    const entry = { id: spark.id, name: spark.name, ok: false, started: false, skipped: false };
    +    if (!spark.hermesMonitoring || !monitor) {
    +      entry.skipped = true;
    +      entry.reason = spark.hermesMonitoring
    +        ? "monitor not running"
    +        : "Hermes Agent monitoring is disabled (enable it in Edit Spark)";
    +      results.push(entry);
    +      continue;
    +    }
    +    const result = monitor.runHermesUpdate();
    +    entry.started = Boolean(result.started);
    +    entry.ok = Boolean(result.started);
    +    if (!result.started) {
    +      entry.skipped = true;
    +      entry.reason = result.reason || "update already running";
    +    }
    +    results.push(entry);
    +  }
    +  res.json({ success: true, results });
    +});
    +
    +/** Re-check for a hermes update now (bypasses the poll cadence). */
    +app.post("/api/sparks/:id/hermes/check", async (req, res) => {
    +  try {
    +    const spark = registry.getSpark(req.params.id);
    +    if (!spark) return res.status(404).json({ error: "Spark not found" });
    +    if (!spark.hermesMonitoring) {
    +      return res.status(400).json({
    +        error: "Hermes Agent monitoring is disabled for this Spark (enable it in Edit Spark)",
    +      });
    +    }
    +    const monitor = monitors.get(req.params.id);
    +    if (!monitor) return res.status(404).json({ error: "Spark not found" });
    +    const result = await monitor.hermesProbe.check();
    +    monitor.applyHermesCheck(result);
    +    res.json({ success: true, hermes: monitor.snapshot().hermes });
    +  } catch (err) {
    +    res.status(500).json({ error: err.message });
    +  }
    +});
    +
    +/** One-click `hermes update` via SSH. Returns 202; progress via snapshot. */
    +app.post("/api/sparks/:id/hermes/update", async (req, res) => {
    +  try {
    +    const spark = registry.getSpark(req.params.id);
    +    if (!spark) return res.status(404).json({ error: "Spark not found" });
    +    if (!spark.hermesMonitoring) {
    +      return res.status(400).json({
    +        error: "Hermes Agent monitoring is disabled for this Spark (enable it in Edit Spark)",
    +      });
    +    }
    +    const monitor = monitors.get(req.params.id);
    +    if (!monitor) return res.status(404).json({ error: "Spark not found" });
    +    const result = await monitor.runHermesUpdate();
    +    res.status(result.started ? 202 : 200).json({
    +      success: result.started,
    +      reason: result.reason,
    +    });
    +  } catch (err) {
    +    res.status(500).json({ error: err.message });
    +  }
    +});
    +
    +// Per-Spark Hermes update preview: the latest release (cached globally), the
    +// installed version, the actual pending commits on this Spark (HEAD..origin/main)
    +// and a resolved `view` so the dialog shows the commit list for minor /
    +// no-bump updates and the full release changelog only when a real version bump
    +// is pending.
    +app.get("/api/sparks/:id/hermes/updates", async (req, res) => {
    +  try {
    +    const spark = registry.getSpark(req.params.id);
    +    if (!spark) return res.status(404).json({ error: "Spark not found" });
    +    if (!spark.hermesMonitoring) {
    +      return res.status(400).json({
    +        error: "Hermes Agent monitoring is disabled for this Spark (enable it in Edit Spark)",
    +      });
    +    }
    +    const monitor = monitors.get(req.params.id);
    +    if (!monitor) return res.status(404).json({ error: "Spark not found" });
    +
    +    const installedVersion = monitor.snapshot().hermes?.version || null;
    +    const pending = await monitor.hermesProbe.pendingCommits();
    +
    +    let release = null;
    +    let releaseError = null;
    +    try {
    +      release = await getLatestRelease();
    +    } catch (err) {
    +      releaseError = err instanceof Error ? err.message : String(err);
    +    }
    +
    +    // Resolve which content the dialog should lead with. A version bump exists
    +    // only when the latest tagged release is newer than what is installed;
    +    // otherwise the pending update is commits on main and those are the honest
    +    // changelog. Without both versions, fall back to the release when available.
    +    const hasPending = Boolean(pending && pending.commits && pending.commits.length > 0);
    +    const releaseNewer =
    +      release?.semver && installedVersion && compareSemver(release.semver, installedVersion) > 0;
    +    const view = releaseNewer ? "release" : hasPending ? "commits" : "release";
    +
    +    res.json({
    +      success: true,
    +      view,
    +      release,
    +      releaseError,
    +      installedVersion,
    +      pending,
    +    });
    +  } catch (err) {
    +    res.status(500).json({ error: err.message });
    +  }
    +});
    +
     // Save / update SSH password only (works while host is offline)
     app.put("/api/sparks/:id/password", (req, res) => {
       try {
    @@ -629,10 +826,31 @@ app.put("/api/sparks/:id/llm-ports/:port/api-key", (req, res) => {
       }
     });
     
    +/**
    + * Daily decode / prefill tok/s rollups (busy samples, last 14 UTC days by default).
    + * Query: port (required for multi-port), days (1–30).
    + */
    +app.get("/api/sparks/:id/llm/daily", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  const ports =
    +    Array.isArray(spark.llmPorts) && spark.llmPorts.length
    +      ? spark.llmPorts
    +      : [resolveLlmPort(spark)];
    +  let port = req.query.port != null ? Number(req.query.port) : ports[0];
    +  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    +    return res.status(400).json({ error: "Invalid port" });
    +  }
    +  let days = req.query.days != null ? Number(req.query.days) : 14;
    +  if (!Number.isFinite(days)) days = 14;
    +  res.json(llmDaily.getSeries(spark.id, port, { days }));
    +});
    +
     /**
      * Decode throughput benchmark (streaming, post-first-token tok/s).
      *
    - * POST body: { port?, concurrencies: number[], maxTokens? }
    + * POST body: { port?, concurrencies: number[], maxTokens?, promptType? }
    + * promptType is structured | prose | code | json (default structured).
      * Returns immediately with a bench job; poll GET for progress/results.
      */
     app.post("/api/sparks/:id/llm/bench", (req, res) => {
    @@ -647,20 +865,26 @@ app.post("/api/sparks/:id/llm/bench", (req, res) => {
       if (showcaseManager.getActive(spark.id)) {
         return res.status(409).json({ error: "A prompt showcase is already running for this Spark" });
       }
    +  if (prefillBenchManager.getActive(spark.id)) {
    +    return res.status(409).json({ error: "A prefill benchmark is already running for this Spark" });
    +  }
     
       const monitor = monitors.get(req.params.id);
       const ports = Array.isArray(spark.llmPorts) && spark.llmPorts.length
         ? spark.llmPorts
         : [resolveLlmPort(spark)];
     
    -  let port = req.body?.port != null ? Number(req.body.port) : ports[0];
    -  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    -    return res.status(400).json({ error: "Invalid port" });
    +  let target;
    +  try {
    +    target = benchHttpTarget(spark, ports, req.body || {});
    +  } catch (err) {
    +    return res.status(err.status || 400).json({ error: err.message });
       }
    +  const port = target.port;
     
       // Resolve model id for this port from live snapshot when possible
       let modelId = req.body?.modelId || null;
    -  if (!modelId && monitor) {
    +  if (!modelId && !target.custom && monitor) {
         const snap = monitor.snapshot();
         const llmList = Array.isArray(snap?.metrics?.llm) ? snap.metrics.llm : [];
         const portIndex = ports.indexOf(port);
    @@ -680,10 +904,14 @@ app.post("/api/sparks/:id/llm/bench", (req, res) => {
           modelId,
           concurrencies: req.body?.concurrencies,
           maxTokens: req.body?.maxTokens,
    +      promptType: req.body?.promptType,
           debug: benchDebug,
    -      apiKey: resolveLlmApiKey(spark, port),
    +      apiKey: target.apiKey,
    +      host: target.host,
    +      tls: target.tls,
    +      resolveTarget: target.resolveTarget,
           sampleHardware:
    -        benchDebug && monitor
    +        benchDebug && monitor && !target.custom
               ? async () => {
                   const fromGpu = (gpu, um) =>
                     gpu
    @@ -783,6 +1011,130 @@ app.delete("/api/sparks/:id/llm/bench/:benchId", (req, res) => {
       res.json(job);
     });
     
    +/**
    + * Prefill throughput + TTFT at selected context sizes (up to 300k).
    + *
    + * POST body: { port?, contextSizes: number[] }
    + * Returns 202 job; poll GET for progress/results.
    + */
    +app.post("/api/sparks/:id/llm/prefill-bench", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  if (spark.workerNode) {
    +    return res.status(400).json({ error: "Worker nodes do not expose a local LLM API" });
    +  }
    +  if (spark.llmMonitoring === false) {
    +    return res.status(400).json({ error: "LLM monitoring is disabled for this Spark" });
    +  }
    +  if (showcaseManager.getActive(spark.id)) {
    +    return res.status(409).json({ error: "A prompt showcase is already running for this Spark" });
    +  }
    +  if (decodeBenchManager.getActive(spark.id)) {
    +    return res.status(409).json({ error: "A decode benchmark is already running for this Spark" });
    +  }
    +
    +  const monitor = monitors.get(req.params.id);
    +  const ports = Array.isArray(spark.llmPorts) && spark.llmPorts.length
    +    ? spark.llmPorts
    +    : [resolveLlmPort(spark)];
    +
    +  let target;
    +  try {
    +    target = benchHttpTarget(spark, ports, req.body || {});
    +  } catch (err) {
    +    return res.status(err.status || 400).json({ error: err.message });
    +  }
    +  const port = target.port;
    +
    +  let modelId = req.body?.modelId || null;
    +  if (!modelId && !target.custom && monitor) {
    +    const snap = monitor.snapshot();
    +    const llmList = Array.isArray(snap?.metrics?.llm) ? snap.metrics.llm : [];
    +    const portIndex = ports.indexOf(port);
    +    const llm =
    +      (portIndex >= 0 ? llmList[portIndex] : null) ||
    +      llmList.find((m) => m?.available) ||
    +      llmList[0];
    +    modelId = llm?.modelId || null;
    +  }
    +
    +  try {
    +    const job = prefillBenchManager.start({
    +      sparkId: spark.id,
    +      lanIp: llmProbeHost(spark),
    +      port,
    +      modelId,
    +      contextSizes: req.body?.contextSizes,
    +      apiKey: target.apiKey,
    +      host: target.host,
    +      tls: target.tls,
    +      resolveTarget: target.resolveTarget,
    +    });
    +    res.status(202).json(job);
    +  } catch (err) {
    +    const status = err.status || 500;
    +    res.status(status).json({ error: err.message });
    +  }
    +});
    +
    +app.get("/api/sparks/:id/llm/prefill-bench", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  const active = prefillBenchManager.getActive(spark.id);
    +  const history = prefillBenchManager.getHistory(spark.id);
    +  const portRaw = req.query.port;
    +  const port =
    +    portRaw != null && portRaw !== ""
    +      ? parseInt(String(portRaw), 10)
    +      : null;
    +  const last = prefillBenchManager.getLast(
    +    spark.id,
    +    Number.isInteger(port) ? port : null
    +  );
    +  res.json({
    +    active,
    +    last,
    +    history,
    +    defaults: PREFILL_BENCH_DEFAULTS,
    +  });
    +});
    +
    +app.delete("/api/sparks/:id/llm/prefill-bench", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  if (prefillBenchManager.getActive(spark.id)) {
    +    return res.status(409).json({ error: "Cannot clear history while a benchmark is running" });
    +  }
    +  const portRaw = req.query.port ?? req.body?.port;
    +  const port =
    +    portRaw != null && portRaw !== ""
    +      ? parseInt(String(portRaw), 10)
    +      : null;
    +  prefillBenchManager.clearHistory(
    +    spark.id,
    +    Number.isInteger(port) ? port : null
    +  );
    +  res.json({ success: true });
    +});
    +
    +app.get("/api/sparks/:id/llm/prefill-bench/:benchId", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  const job = prefillBenchManager.getJob(req.params.benchId);
    +  if (!job || job.sparkId !== spark.id) {
    +    return res.status(404).json({ error: "Benchmark not found" });
    +  }
    +  res.json(job);
    +});
    +
    +app.delete("/api/sparks/:id/llm/prefill-bench/:benchId", (req, res) => {
    +  const spark = registry.getSpark(req.params.id);
    +  if (!spark) return res.status(404).json({ error: "Spark not found" });
    +  const job = prefillBenchManager.cancel(spark.id, req.params.benchId);
    +  if (!job) return res.status(404).json({ error: "Benchmark not found" });
    +  res.json(job);
    +});
    +
     /**
      * LLM Prompt Showcase — concurrent streaming demos.
      *
    @@ -1188,6 +1540,17 @@ function broadcastPayload(payload) {
       });
     }
     
    +/**
    + * Force an immediate broadcast, ignoring the diff cache.
    + * Used after a user action (manual refresh / hermes check / update) so the
    + * UI reflects the result right away instead of on the next poll tick.
    + */
    +function forceBroadcast() {
    +  const payload = buildSnapshotPayload();
    +  _lastBroadcastPayload = payload;
    +  broadcastPayload(payload);
    +}
    +
     function startBroadcast() {
       const interval = getSettings().pollIntervalMs;
       broadcastTimer = setInterval(() => {
    @@ -1216,6 +1579,15 @@ startBroadcast();
     server.listen(PORT, BIND_HOST, () => {
       console.log(`[sparkDash] server listening on http://${BIND_HOST}:${PORT}`);
       console.log(`[sparkDash] WebSocket endpoint ws://${BIND_HOST}:${PORT}/ws`);
    +  const isLoopback =
    +    BIND_HOST === "localhost" || BIND_HOST === "::1" || /^127\./.test(BIND_HOST);
    +  if (isLoopback) {
    +    console.log("[sparkDash] localhost-only; set BIND_HOST=0.0.0.0 (or a LAN IP) to allow remote access");
    +  } else {
    +    console.warn(
    +      `[sparkDash] WARNING: bound to ${BIND_HOST} — reachable on the LAN. This dashboard is unauthenticated and can SSH into and power off your Sparks; restrict access at the network/firewall layer.`
    +    );
    +  }
       startAllMonitors();
     });
     
    @@ -1225,6 +1597,23 @@ function shutdown(signal) {
       if (_shuttingDown) return;
       _shuttingDown = true;
       console.log(`[sparkDash] ${signal} received, shutting down…`);
    +  try {
    +    // Finalize in-flight benches before the process dies so clients polling
    +    // GET /llm/bench/:id do not hit "Benchmark not found" after --watch reload.
    +    decodeBenchManager.interruptAll(
    +      "Interrupted — server restarted while the benchmark was running"
    +    );
    +    prefillBenchManager.interruptAll(
    +      "Interrupted — server restarted while the benchmark was running"
    +    );
    +  } catch (err) {
    +    console.error("[sparkDash] failed to finalize benchmarks:", err.message);
    +  }
    +  try {
    +    llmDaily.flush();
    +  } catch (err) {
    +    console.error("[sparkDash] failed to flush LLM daily history:", err.message);
    +  }
       try {
         if (broadcastTimer) {
           clearInterval(broadcastTimer);
    diff --git a/server/settings.js b/server/settings.js
    index 4fd450e0..811f8afe 100644
    --- a/server/settings.js
    +++ b/server/settings.js
    @@ -14,6 +14,8 @@ const DEFAULTS = Object.freeze({
       pollIntervalMs: 2000,
       defaultLlmPort: 8888,
       autoHideOffline: false,
    +  /** Hide worker-role Sparks from Overview and the tab bar. */
    +  hideWorkers: false,
       temperatureUnit: "celsius",
       /** Persist prompts / HTTP traces / GPU samples on decode benchmark runs. */
       benchDebugTraces: false,
    @@ -36,6 +38,7 @@ function _clampSettings(settings) {
       }
       // Ensure autoHideOffline is boolean
       s.autoHideOffline = Boolean(s.autoHideOffline);
    +  s.hideWorkers = Boolean(s.hideWorkers);
       // Ensure benchDebugTraces is boolean
       s.benchDebugTraces = Boolean(s.benchDebugTraces);
       // Ensure temperatureUnit is valid
    diff --git a/server/sparks/SparkMonitor.js b/server/sparks/SparkMonitor.js
    index f2292b44..b89b3be0 100644
    --- a/server/sparks/SparkMonitor.js
    +++ b/server/sparks/SparkMonitor.js
    @@ -3,6 +3,9 @@ import path from "path";
     import { SystemCollector } from "../collectors/SystemCollector.js";
     import { LlmProbe } from "../collectors/LlmProbe.js";
     import { ComfyProbe } from "../collectors/ComfyProbe.js";
    +import { HermesProbe } from "../collectors/HermesProbe.js";
    +import { TailscaleProbe } from "../collectors/TailscaleProbe.js";
    +import { llmDaily } from "../collectors/LlmDaily.js";
     import { sshTest, sshExec } from "../collectors/ssh.js";
     import {
       POLL_INTERVAL_GPU,
    @@ -13,6 +16,8 @@ import {
       POLL_INTERVAL_COMFY,
       POLL_INTERVAL_BANDWIDTH,
       POLL_INTERVAL_LIVENESS,
    +  POLL_INTERVAL_HERMES,
    +  POLL_INTERVAL_TAILSCALE,
       LLM_PORT,
       COMFY_PORT,
       HOST_PATHS,
    @@ -32,6 +37,8 @@ export class SparkMonitor {
       constructor(spark, options = {}) {
         this.spark = spark;
         this._onWolMac = typeof options.onWolMac === "function" ? options.onWolMac : null;
    +    this._onHermesChange =
    +      typeof options.onHermesChange === "function" ? options.onHermesChange : null;
         this.collector = new SystemCollector(spark);
     
         // One LlmProbe per port — none when LLM monitoring is off
    @@ -47,6 +54,31 @@ export class SparkMonitor {
           ? new ComfyProbe(spark, this._comfyPort(spark))
           : null;
     
    +    /** @type {TailscaleProbe | null} */
    +    this.tailscaleProbe = this._tailscaleMonitoringEnabled(spark)
    +      ? new TailscaleProbe(spark)
    +      : null;
    +
    +    /** @type {HermesProbe | null} */
    +    this.hermesProbe = this._hermesMonitoringEnabled(spark)
    +      ? new HermesProbe(spark)
    +      : null;
    +    // Hermes status is surfaced in the snapshot (not under `metrics`) and is
    +    // always present so the UI never has to special-case a missing field.
    +    this._hermes = {
    +      monitoring: this._hermesMonitoringEnabled(spark),
    +      installed: null,
    +      version: null,
    +      updateAvailable: null,
    +      behindCommits: null,
    +      checkedAt: null,
    +      // idle | running | success | error
    +      status: "idle",
    +      startedAt: null,
    +      finishedAt: null,
    +      error: null,
    +    };
    +
         // Online status from dedicated liveness checks (not metric poll success)
         this.online = false;
         this.lastOnlineOk = 0;
    @@ -64,15 +96,35 @@ export class SparkMonitor {
           unifiedMemory: this.collector._defaultUnifiedMemory(),
           llm: [],
           comfy: null,
    +      tailscale: null,
         };
         this._lastUpdate = {};
     
    +    // Hardware summary: kind "spark" uses the static DGX Spark specs; kind
    +    // "host" (dedicated GPU Linux box) detects real hardware once in the
    +    // background so the header doesn't mislabel the machine as a Spark.
    +    this._hardwareSummary = this._staticHardwareSummary(spark);
    +    this._stopped = false;
    +    if (spark?.kind === "host") {
    +      void this.collector
    +        .detectHardware()
    +        .then((detected) => {
    +          if (this._stopped || !detected) return;
    +          this._hardwareSummary = { ...this._hardwareSummary, ...detected };
    +        })
    +        .catch(() => {});
    +    }
    +
         // Timers
         this._intervals = [];
         /** @type {ReturnType | null} */
         this._llmIntervalId = null;
         /** @type {ReturnType | null} */
         this._comfyIntervalId = null;
    +    /** @type {ReturnType | null} */
    +    this._hermesIntervalId = null;
    +    /** @type {ReturnType | null} */
    +    this._tailscaleIntervalId = null;
         this._running = false;
         /** @type {Record} in-flight domain guards */
         this._inflight = {};
    @@ -83,6 +135,8 @@ export class SparkMonitor {
         const wasLlm = this._llmMonitoringEnabled(this.spark);
         const wasComfy = this._comfyMonitoringEnabled(this.spark);
         const prevComfyPort = this._comfyPort(this.spark);
    +    const wasHermes = this._hermesMonitoringEnabled(this.spark);
    +    const wasTailscale = this._tailscaleMonitoringEnabled(this.spark);
         this.spark = spark;
         this.collector.spark = spark;
     
    @@ -123,6 +177,34 @@ export class SparkMonitor {
           this._metrics.comfy = null;
         }
     
    +    // Tailscale probe — create / update / clear
    +    if (this._tailscaleMonitoringEnabled()) {
    +      if (this.tailscaleProbe) {
    +        this.tailscaleProbe.setTarget(spark);
    +      } else {
    +        this.tailscaleProbe = new TailscaleProbe(spark);
    +      }
    +    } else {
    +      this.tailscaleProbe = null;
    +      this._metrics.tailscale = null;
    +    }
    +
    +    // Hermes probe — create / update / clear
    +    if (this._hermesMonitoringEnabled()) {
    +      if (this.hermesProbe) {
    +        this.hermesProbe.setTarget(spark);
    +      } else {
    +        this.hermesProbe = new HermesProbe(spark);
    +      }
    +    } else {
    +      this.hermesProbe = null;
    +      this._hermes.status = "idle";
    +    }
    +    this._hermes.monitoring = this._hermesMonitoringEnabled();
    +    if (this._running && wasHermes !== this._hermesMonitoringEnabled()) {
    +      this._restartHermesPollInterval();
    +    }
    +
         // Toggle LLM poll interval when monitoring enablement flips
         if (this._running && wasLlm !== this._llmMonitoringEnabled()) {
           this._restartLlmPollInterval();
    @@ -132,6 +214,9 @@ export class SparkMonitor {
         if (this._running && (wasComfy !== comfyOn || comfyPortChanged)) {
           this._restartComfyPollInterval();
         }
    +    if (this._running && wasTailscale !== this._tailscaleMonitoringEnabled()) {
    +      this._restartTailscalePollInterval();
    +    }
       }
     
       /**
    @@ -188,6 +273,56 @@ export class SparkMonitor {
         }
       }
     
    +  /**
    +   * Opt-in tailnet monitoring (all roles; default off).
    +   * @param {object} [spark]
    +   */
    +  _tailscaleMonitoringEnabled(spark = this.spark) {
    +    return Boolean(spark?.tailscaleMonitoring);
    +  }
    +
    +  /** Start or clear the tailnet poll timer based on monitoring flag. */
    +  _restartTailscalePollInterval() {
    +    if (this._tailscaleIntervalId != null) {
    +      clearInterval(this._tailscaleIntervalId);
    +      this._intervals = this._intervals.filter((id) => id !== this._tailscaleIntervalId);
    +      this._tailscaleIntervalId = null;
    +    }
    +    if (this._tailscaleMonitoringEnabled() && this._running) {
    +      this._tailscaleIntervalId = setInterval(
    +        () => this._pollDomain("tailscale"),
    +        POLL_INTERVAL_TAILSCALE
    +      );
    +      this._intervals.push(this._tailscaleIntervalId);
    +      void this._pollDomain("tailscale");
    +    }
    +  }
    +
    +  /**
    +   * Opt-in Hermes Agent monitoring (all roles; default off).
    +   * @param {object} [spark]
    +   */
    +  _hermesMonitoringEnabled(spark = this.spark) {
    +    return Boolean(spark?.hermesMonitoring);
    +  }
    +
    +  /** Start or clear the Hermes update-check timer when monitoring flips. */
    +  _restartHermesPollInterval() {
    +    if (this._hermesIntervalId != null) {
    +      clearInterval(this._hermesIntervalId);
    +      this._intervals = this._intervals.filter((id) => id !== this._hermesIntervalId);
    +      this._hermesIntervalId = null;
    +    }
    +    if (this._hermesMonitoringEnabled() && this._running) {
    +      this._hermesIntervalId = setInterval(
    +        () => this._pollDomain("hermes"),
    +        POLL_INTERVAL_HERMES
    +      );
    +      this._intervals.push(this._hermesIntervalId);
    +      void this._pollDomain("hermes");
    +    }
    +  }
    +
       /** Returns array of LLM ports from spark config. */
       _llmPorts() {
         const raw = this.spark?.llmPorts;
    @@ -207,6 +342,7 @@ export class SparkMonitor {
       start() {
         if (this._running) return;
         this._running = true;
    +    this._stopped = false;
         this._poll();
         this._intervals.push(setInterval(() => this._pollDomain("gpu"), POLL_INTERVAL_GPU));
         this._intervals.push(setInterval(() => this._pollDomain("cpu"), POLL_INTERVAL_CPU));
    @@ -216,6 +352,8 @@ export class SparkMonitor {
         this._intervals.push(setInterval(() => this._pollDomain("memory"), POLL_INTERVAL_BANDWIDTH));
         this._restartLlmPollInterval();
         this._restartComfyPollInterval();
    +    this._restartHermesPollInterval();
    +    this._restartTailscalePollInterval();
         // Liveness on a slightly slower cadence
         this._intervals.push(setInterval(() => this._checkOnline(), POLL_INTERVAL_LIVENESS));
         console.log(`[SparkMonitor] ${this.spark.id} started`);
    @@ -224,10 +362,13 @@ export class SparkMonitor {
       /** Stop background polling. */
       stop() {
         this._running = false;
    +    this._stopped = true;
         for (const id of this._intervals) clearInterval(id);
         this._intervals = [];
         this._llmIntervalId = null;
         this._comfyIntervalId = null;
    +    this._hermesIntervalId = null;
    +    this._tailscaleIntervalId = null;
         this._inflight = {};
         if (this.comfyProbe) {
           try {
    @@ -243,9 +384,11 @@ export class SparkMonitor {
       snapshot() {
         const ports = this._llmMonitoringEnabled() ? this._llmPorts() : [];
         const comfyOn = this._comfyMonitoringEnabled();
    +    const tailscaleOn = this._tailscaleMonitoringEnabled();
         return {
           id: this.spark.id,
           name: this.spark.name,
    +      kind: this.spark.kind || "spark",
           online: this.online,
           uptime: this._uptimeSeconds,
           lanIp: this.spark.lanIp || "",
    @@ -267,7 +410,9 @@ export class SparkMonitor {
                 .filter((n) => Number.isInteger(n)),
           comfyMonitoring: comfyOn,
           comfyPort: this._comfyPort(),
    -      hardware: this._getHardwareSummary(),
    +      tailscaleMonitoring: tailscaleOn,
    +      hermes: this._hermes,
    +      hardware: this._hardwareSummary,
           metrics: {
             // NOTE: no `timestamp` here on purpose. The broadcast path skips
             // snapshots whose JSON is byte-identical to the previous one (see
    @@ -284,6 +429,7 @@ export class SparkMonitor {
             unifiedMemory: this._metrics.unifiedMemory,
             llm: this._metrics.llm,
             comfy: comfyOn ? this._metrics.comfy : null,
    +        tailscale: tailscaleOn ? this._metrics.tailscale : null,
           },
         };
       }
    @@ -352,6 +498,8 @@ export class SparkMonitor {
           this._pollDomain("memory"),
           this._pollDomain("llm"),
           this._pollDomain("comfy"),
    +      this._pollDomain("hermes"),
    +      this._pollDomain("tailscale"),
         ]);
       }
     
    @@ -362,6 +510,8 @@ export class SparkMonitor {
         // Worker nodes: no local LLM API
         if (domain === "llm" && !this._llmMonitoringEnabled()) return;
         if (domain === "comfy" && !this._comfyMonitoringEnabled()) return;
    +    if (domain === "hermes" && !this._hermesMonitoringEnabled()) return;
    +    if (domain === "tailscale" && !this._tailscaleMonitoringEnabled()) return;
         this._inflight[domain] = true;
         try {
           let result;
    @@ -393,6 +543,12 @@ export class SparkMonitor {
             case "comfy":
               result = this.comfyProbe ? await this.comfyProbe.probe() : null;
               break;
    +        case "tailscale":
    +          result = this.tailscaleProbe ? await this.tailscaleProbe.probe() : null;
    +          break;
    +        case "hermes":
    +          result = this.hermesProbe ? await this.hermesProbe.check() : null;
    +          break;
           }
           // Re-check after the await — `stop()`/`updateSpark()` may have torn
           // this monitor down mid-flight. Writing `_metrics` on a dead monitor
    @@ -428,10 +584,23 @@ export class SparkMonitor {
               break;
             case "llm":
               this._metrics.llm = result;
    +          {
    +            const probes = Array.from(this.llmProbes.values());
    +            for (let i = 0; i < result.length; i++) {
    +              const probe = probes[i];
    +              if (probe) llmDaily.record(this.spark.id, probe.port, result[i]);
    +            }
    +          }
               break;
             case "comfy":
               this._metrics.comfy = result;
               break;
    +        case "tailscale":
    +          this._metrics.tailscale = result;
    +          break;
    +        case "hermes":
    +          this.applyHermesCheck(result);
    +          break;
           }
           this._lastUpdate[domain] = Date.now();
         } catch (err) {
    @@ -466,8 +635,137 @@ export class SparkMonitor {
         }
       }
     
    -  // ─── Hardware summary (cached, computed once) ─────────────
    -  _getHardwareSummary() {
    +  // ─── Hermes Agent ─────────────────────────────────────────
    +  /**
    +   * Apply a Hermes check result to monitor state. Fires onHermesChange (force
    +   * broadcast) only when a user-meaningful field actually changed, so idle
    +   * re-checks do not spam the WS.
    +   * @param {object|null} result
    +   */
    +  applyHermesCheck(result) {
    +    if (!result || !this._running) return;
    +    const prev = this._hermes;
    +    const changed =
    +      result.updateAvailable !== prev.updateAvailable ||
    +      result.installed !== prev.installed ||
    +      result.version !== prev.version;
    +    this._hermes = {
    +      ...prev,
    +      installed: result.installed,
    +      version: result.version,
    +      updateAvailable: result.updateAvailable,
    +      behindCommits: result.behindCommits,
    +      checkedAt: result.checkedAt,
    +      error: result.error ?? null,
    +    };
    +    // A clean check self-heals the transient one-shot update job status, so a
    +    // "success / error" flag never lingers past the following poll cycle.
    +    if (!result.error && this._hermes.status !== "running") {
    +      this._hermes.status = "idle";
    +    }
    +    if (changed) this._notifyHermesChange();
    +  }
    +
    +  /**
    +   * Kick off `hermes update` in the background. Returns immediately; progress
    +   * and result are surfaced through the snapshot + onHermesChange broadcast.
    +   * @returns {{ started: boolean, reason?: string }}
    +   */
    +  runHermesUpdate() {
    +    if (!this.hermesProbe) {
    +      return { started: false, reason: "Hermes Agent monitoring is disabled for this Spark" };
    +    }
    +    if (this._hermes.status === "running") {
    +      return { started: false, reason: "An update is already running" };
    +    }
    +    this._hermes = {
    +      ...this._hermes,
    +      status: "running",
    +      startedAt: Date.now(),
    +      finishedAt: null,
    +      error: null,
    +    };
    +    this._notifyHermesChange();
    +    // Defer the long-running SSH work so the broadcast above lands first.
    +    void (async () => {
    +      try {
    +        const res = await this.hermesProbe.update();
    +        if (!this._running) return;
    +        if (res?.ok) {
    +          this._hermes = {
    +            ...this._hermes,
    +            status: "success",
    +            installed: res.installed,
    +            version: res.version,
    +            error: null,
    +            finishedAt: res.finishedAt ?? Date.now(),
    +          };
    +          // Refresh update availability right away (don't wait for the next poll).
    +          try {
    +            const check = await this.hermesProbe.check();
    +            if (this._running && check) {
    +              this._hermes = {
    +                ...this._hermes,
    +                installed: check.installed,
    +                version: check.version,
    +                updateAvailable: check.updateAvailable,
    +                behindCommits: check.behindCommits,
    +                checkedAt: check.checkedAt,
    +                error: check.error ?? null,
    +              };
    +            }
    +          } catch {
    +            /* keep the success result if the follow-up check fails */
    +          }
    +        } else {
    +          this._hermes = {
    +            ...this._hermes,
    +            status: "error",
    +            error: res?.error || res?.output?.slice(-400) || "hermes update failed",
    +            finishedAt: res?.finishedAt ?? Date.now(),
    +          };
    +        }
    +      } catch (err) {
    +        if (!this._running) return;
    +        this._hermes = {
    +          ...this._hermes,
    +          status: "error",
    +          error: err instanceof Error ? err.message : String(err),
    +          finishedAt: Date.now(),
    +        };
    +      }
    +      this._notifyHermesChange();
    +    })();
    +    return { started: true };
    +  }
    +
    +  _notifyHermesChange() {
    +    if (typeof this._onHermesChange !== "function") return;
    +    try {
    +      this._onHermesChange(this.spark.id);
    +    } catch (err) {
    +      console.error(`[SparkMonitor] ${this.spark.id} hermes change error:`, err.message);
    +    }
    +  }
    +
    +  // ─── Hardware summary ─────────────────────────────────────
    +  /**
    +   * Static summary used for kind "spark" (DGX Spark specs) and as the
    +   * pre-detection fallback for kind "host". kind "host" is then enriched
    +   * with real hardware from `detectHardware()` once available.
    +   */
    +  _staticHardwareSummary(spark) {
    +    if (spark?.kind === "host") {
    +      return {
    +        device: "Linux GPU host",
    +        cpuModel: null,
    +        cpuCores: null,
    +        totalMemoryGB: null,
    +        gpuChip: null,
    +        cudaDriver: null,
    +        storageModel: null,
    +      };
    +    }
         return {
           device: "NVIDIA DGX Spark",
           cpuModel: "GB10",
    @@ -479,3 +777,4 @@ export class SparkMonitor {
         };
       }
     }
    +
    diff --git a/server/sparks/SparkRegistry.js b/server/sparks/SparkRegistry.js
    index d05bcff1..2e6d0465 100644
    --- a/server/sparks/SparkRegistry.js
    +++ b/server/sparks/SparkRegistry.js
    @@ -250,6 +250,40 @@ export class SparkRegistry {
             this._sparks = [];
           }
         }
    +
    +    // Out-of-band config edits (sparks.json edited directly without resyncing
    +    // secrets) can rename llmPorts underneath stored API keys. Reconcile once
    +    // here — before the registry is handed to anything — and never delete key
    +    // material at load.
    +    this._reconcileLlmApiKeysAtLoad();
    +  }
    +
    +  /**
    +   * Load-time LLM key/port reconcile.
    +   * An out-of-band sparks.json edit that renames llmPorts (without going
    +   * through PATCH / PUT llm-ports) leaves keys keyed on ports the spark no
    +   * longer exposes. When exactly one keyed port is orphaned and exactly one
    +   * configured port lacks a key, the rename shape is unambiguous → MOVE the
    +   * key. Any other mismatch shape is warn-only: never prune, never delete
    +   * stored key material at load.
    +   */
    +  _reconcileLlmApiKeysAtLoad() {
    +    for (const spark of this._sparks) {
    +      const configured = Array.isArray(spark.llmPorts) ? spark.llmPorts : [];
    +      const keyed = this.llmApiKeyPorts(spark.id);
    +      const orphans = keyed.filter((p) => !configured.includes(p));
    +      const missing = configured.filter((p) => !this.hasLlmApiKey(spark.id, p));
    +      if (orphans.length === 1 && missing.length === 1) {
    +        this.moveLlmApiKey(spark.id, orphans[0], missing[0]);
    +        console.warn(
    +          `[SparkRegistry] migrated LLM API key for spark ${spark.id}: port ${orphans[0]} -> port ${missing[0]} after out-of-band config change`
    +        );
    +      } else if (orphans.length > 0 || missing.length > 0) {
    +        console.warn(
    +          `[SparkRegistry] spark ${spark.id} LLM key/port mismatch: keyed=<${orphans.join(", ")}> missing=<${missing.join(", ")}>`
    +        );
    +      }
    +    }
       }
     
       _save() {
    @@ -434,6 +468,35 @@ export class SparkRegistry {
        * @param {number[]} prevPorts
        * @param {number[]} nextPorts
        */
    +  /**
    +   * updateSpark() plus LLM API key reconcile for PATCH /api/sparks/:id.
    +   *
    +   * Armed whenever the patch carries an `llmPorts` own-property — including
    +   * `[]` and the legacy single-value shape — because _normalizeLlmPorts()
    +   * applies those too (empty/invalid -> [LLM_PORT], scalar -> [n]). The sync
    +   * runs against the POST-normalize ports on the stored spark, so it sees the
    +   * same rename the persisted record does, not the raw request body.
    +   * @param {string} id
    +   * @param {object} updates PATCH body
    +   * @returns {{ spark: object, llmPortsSynced: boolean }}
    +   */
    +  patchSpark(id, updates) {
    +    const body = updates || {};
    +    const armed = Object.prototype.hasOwnProperty.call(body, "llmPorts");
    +    let prevPorts = null;
    +    if (armed) {
    +      const existing = this.getSpark(id);
    +      if (!existing) throw new Error(`Spark ${id} not found`);
    +      prevPorts = Array.isArray(existing.llmPorts) ? [...existing.llmPorts] : [];
    +    }
    +    const updated = this.updateSpark(id, body);
    +    const llmPortsSynced = armed && Array.isArray(updated.llmPorts);
    +    if (llmPortsSynced) {
    +      this.syncLlmApiKeysToPorts(id, prevPorts, updated.llmPorts);
    +    }
    +    return { spark: llmPortsSynced ? this.getSpark(id) : updated, llmPortsSynced };
    +  }
    +
       syncLlmApiKeysToPorts(id, prevPorts, nextPorts) {
         const prev = Array.isArray(prevPorts) ? prevPorts : [];
         const next = Array.isArray(nextPorts) ? nextPorts : [];
    @@ -481,6 +544,8 @@ export class SparkRegistry {
         return {
           id: config.id,
           name: config.name || config.id,
    +      /** Unit type: spark (DGX Spark) or host (dedicated GPU Linux box). */
    +      kind: config.kind === "host" ? "host" : "spark",
           lanIp: config.lanIp || "",
           cx7Ip: config.cx7Ip || null,
           /** Optional user override for Wake-on-LAN. Empty → use detectedMacAddress. */
    @@ -511,6 +576,15 @@ export class SparkRegistry {
           comfyMonitoring: Boolean(config.comfyMonitoring),
           /** ComfyUI HTTP port (default 8188). */
           comfyPort: this._normalizeComfyPort(config.comfyPort),
    +      /**
    +       * Opt-in tailnet presence via `tailscale status --json` (default false).
    +       */
    +      tailscaleMonitoring: Boolean(config.tailscaleMonitoring),
    +      /**
    +       * Opt-in: Hermes Agent CLI is installed on this machine. When enabled,
    +       * the SparkMonitor checks for updates and allows one-click `hermes update`.
    +       */
    +      hermesMonitoring: Boolean(config.hermesMonitoring),
           disabledDevices: Array.isArray(config.disabledDevices) ? config.disabledDevices : [],
           disabledInterfaces: Array.isArray(config.disabledInterfaces) ? config.disabledInterfaces : [],
           storagePollDisabled: Boolean(config.storagePollDisabled),
    diff --git a/server/sparks/__tests__/SparkRegistry.llmApiKeys.test.js b/server/sparks/__tests__/SparkRegistry.llmApiKeys.test.js
    new file mode 100644
    index 00000000..adbfabbb
    --- /dev/null
    +++ b/server/sparks/__tests__/SparkRegistry.llmApiKeys.test.js
    @@ -0,0 +1,181 @@
    +/**
    + * SparkRegistry LLM API key sync guard (follow-up to #25/#26).
    + *
    + * An out-of-band sparks.json edit can rename llmPorts without touching the
    + * encrypted secrets store; PATCH /api/sparks/:id was the other bypass. Result:
    + * the registry kept an orphaned key on the old port (probe then hit the host
    + * without auth → 401s). Covered here:
    + *   - load-time reconcile: unambiguous single-port rename MOVES the key
    + *   - load-time reconcile: every other mismatch shape is warn-only and NEVER
    + *     deletes stored key material at load
    + *   - syncLlmApiKeysToPorts: rename move + prune semantics (the rails the
    + *     PATCH llmPorts path and PUT /api/sparks/:id/llm-ports drive)
    + *
    + * Run: npm test
    + */
    +import test from "node:test";
    +import assert from "node:assert/strict";
    +import fs from "node:fs";
    +import os from "node:os";
    +import path from "node:path";
    +
    +// Env must be set before the first import of config.js (paths are captured as
    +// consts at module evaluation); each node --test file runs in its own process.
    +const TMPDIR = fs.mkdtempSync(path.join(os.tmpdir(), "sparkdash-registry-keys-"));
    +process.env.SPARKS_JSON_PATH = path.join(TMPDIR, "sparks.json");
    +process.env.SPARKS_SECRETS_PATH = path.join(TMPDIR, "sparks-secrets.json");
    +process.env.SECRETS_KEY_PATH = path.join(TMPDIR, ".secrets-key");
    +process.env.LLM_PORT = "8888";
    +
    +const { SparkRegistry } = await import("../SparkRegistry.js");
    +const { saveSecrets } = await import("../../secretsStore.js");
    +const { LLM_PORT } = await import("../../config.js");
    +
    +/** Synthetic test key only — never real key material. */
    +const TEST_KEY = "sk-test-0000";
    +
    +const SPARK_ID = "t";
    +
    +/**
    + * Seed sparks.json + the encrypted secrets store, then construct the registry
    + * through its real load path so the load-time reconcile provably runs.
    + * @param {number[]} llmPorts configured ports
    + * @param {Record} llmApiKeys port -> key in the secrets store
    + * @param {{ reset?: boolean }} [opts] reset wipes seeded state first
    + */
    +function loadRegistry(llmPorts, llmApiKeys = {}, { reset = false } = {}) {
    +  if (reset) {
    +    fs.rmSync(process.env.SPARKS_JSON_PATH, { force: true });
    +    fs.rmSync(process.env.SPARKS_SECRETS_PATH, { force: true });
    +  }
    +  fs.writeFileSync(
    +    process.env.SPARKS_JSON_PATH,
    +    JSON.stringify({
    +      sparks: [{ id: SPARK_ID, name: "T", lanIp: "127.0.0.1", llmPorts }],
    +    })
    +  );
    +  saveSecrets(
    +    new Map(),
    +    new Map([[SPARK_ID, Object.fromEntries(Object.entries(llmApiKeys))]])
    +  );
    +  return new SparkRegistry();
    +}
    +
    +/** Capture `[SparkRegistry]` warns for the duration of fn(). */
    +function captureRegistryWarns(fn) {
    +  const lines = [];
    +  const orig = console.warn;
    +  console.warn = (...args) => {
    +    if (typeof args[0] === "string" && args[0].includes("[SparkRegistry]")) {
    +      lines.push(args[0]);
    +    }
    +  };
    +  try {
    +    fn();
    +  } finally {
    +    console.warn = orig;
    +  }
    +  return lines;
    +}
    +
    +// ─── Load-time reconcile ─────────────────────────────────
    +
    +test("load reconcile: unambiguous single-port rename moves the key", () => {
    +  const r = loadRegistry([8899], { "8888": TEST_KEY });
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [8899]);
    +  assert.equal(r.hasLlmApiKey(SPARK_ID, 8888), false);
    +  assert.equal(r.getSpark(SPARK_ID).llmApiKeys["8899"], TEST_KEY);
    +});
    +
    +test("load reconcile: single-port rename warns with migration message", () => {
    +  const warns = captureRegistryWarns(() => loadRegistry([8899], { "8888": TEST_KEY }));
    +  assert.equal(warns.length, 1);
    +  assert.equal(
    +    warns[0],
    +    "[SparkRegistry] migrated LLM API key for spark t: port 8888 -> port 8899 after out-of-band config change"
    +  );
    +});
    +
    +test("load reconcile: ambiguous shape never prunes — key on 8888 survives", () => {
    +  const r = loadRegistry([8015, 8899], { "8888": TEST_KEY });
    +  // No destructive load path: the orphaned key must still exist, unmoved.
    +  assert.equal(r.hasLlmApiKey(SPARK_ID, 8888), true);
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [8888]);
    +  assert.equal(r.getSpark(SPARK_ID).llmApiKeys["8888"], TEST_KEY);
    +});
    +
    +test("load reconcile: ambiguous shape warns listing port numbers only", () => {
    +  const warns = captureRegistryWarns(() => loadRegistry([8015, 8899], { "8888": TEST_KEY }));
    +  assert.equal(warns.length, 1);
    +  assert.equal(
    +    warns[0],
    +    "[SparkRegistry] spark t LLM key/port mismatch: keyed=<8888> missing=<8015, 8899>"
    +  );
    +  assert.doesNotMatch(warns[0], /sk-test-0000/);
    +});
    +
    +test("load reconcile: aligned shape stays silent and unchanged", () => {
    +  const warns = captureRegistryWarns(() => loadRegistry([8899], { "8899": TEST_KEY }));
    +  assert.deepEqual(warns, []);
    +  const r = loadRegistry([8899], { "8899": TEST_KEY });
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [8899]);
    +});
    +
    +// ─── syncLlmApiKeysToPorts (PATCH llmPorts / PUT llm-ports rails) ──
    +
    +function registryWithKey() {
    +  const r = loadRegistry([8899], { "8899": TEST_KEY }, { reset: true });
    +  r.addSpark({ id: "sp", name: "SP", lanIp: "127.0.0.1", llmPorts: [8888] });
    +  r.setLlmApiKey("sp", 8888, TEST_KEY);
    +  return r;
    +}
    +
    +test("syncLlmApiKeysToPorts: single rename moves the key with value intact", () => {
    +  const r = registryWithKey();
    +  r.syncLlmApiKeysToPorts("sp", [8888], [8899]);
    +  assert.deepEqual(r.llmApiKeyPorts("sp"), [8899]);
    +  assert.equal(r.getSpark("sp").llmApiKeys["8899"], TEST_KEY);
    +  assert.equal(r.hasLlmApiKey("sp", 8888), false);
    +});
    +
    +test("syncLlmApiKeysToPorts: no-shape change is a no-op", () => {
    +  const r = registryWithKey();
    +  r.syncLlmApiKeysToPorts("sp", [8888], [8888]);
    +  assert.equal(r.hasLlmApiKey("sp", 8888), true);
    +  assert.equal(r.getSpark("sp").llmApiKeys["8888"], TEST_KEY);
    +});
    +
    +test("syncLlmApiKeysToPorts: removed port without rename is pruned", () => {
    +  const r = registryWithKey();
    +  r.setLlmApiKey("sp", 9001, TEST_KEY);
    +  r.syncLlmApiKeysToPorts("sp", [8888, 9001], [8888]);
    +  assert.deepEqual(r.llmApiKeyPorts("sp"), [8888]);
    +});
    +
    +// ─── patchSpark (PATCH /api/sparks/:id path) ─────────────────────
    +
    +test("patchSpark: llmPorts [] normalizes to the default port and moves the key", () => {
    +  const r = loadRegistry([8899], { "8899": TEST_KEY }, { reset: true });
    +  const { spark, llmPortsSynced } = r.patchSpark(SPARK_ID, { llmPorts: [] });
    +  assert.equal(llmPortsSynced, true);
    +  assert.deepEqual(spark.llmPorts, [LLM_PORT]);
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [LLM_PORT]);
    +  assert.equal(r.getSpark(SPARK_ID).llmApiKeys[String(LLM_PORT)], TEST_KEY);
    +  assert.equal(r.hasLlmApiKey(SPARK_ID, 8899), false);
    +});
    +
    +test("patchSpark: legacy scalar llmPorts is applied and the key follows", () => {
    +  const r = loadRegistry([8888], { "8888": TEST_KEY }, { reset: true });
    +  const { spark } = r.patchSpark(SPARK_ID, { llmPorts: "8899" });
    +  assert.deepEqual(spark.llmPorts, [8899]);
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [8899]);
    +  assert.equal(r.getSpark(SPARK_ID).llmApiKeys["8899"], TEST_KEY);
    +});
    +
    +test("patchSpark: a body without llmPorts never touches keys", () => {
    +  const r = loadRegistry([8888], { "8888": TEST_KEY }, { reset: true });
    +  const { llmPortsSynced } = r.patchSpark(SPARK_ID, { name: "renamed" });
    +  assert.equal(llmPortsSynced, false);
    +  assert.deepEqual(r.llmApiKeyPorts(SPARK_ID), [8888]);
    +  assert.equal(r.getSpark(SPARK_ID).name, "renamed");
    +});
    diff --git a/server/sparks/__tests__/role-normalize.test.js b/server/sparks/__tests__/role-normalize.test.js
    index 0091c3fc..fab08d1d 100644
    --- a/server/sparks/__tests__/role-normalize.test.js
    +++ b/server/sparks/__tests__/role-normalize.test.js
    @@ -27,6 +27,16 @@ test("comfyMonitoring is opt-in for all roles; comfyPort defaults to 8188", () =
       assert.equal(n({ comfyPort: "8190" }).comfyPort, 8190);
     });
     
    +test("tailscaleMonitoring is opt-in for all roles", () => {
    +  assert.equal(n({ role: "head" }).tailscaleMonitoring, false);
    +  assert.equal(n({ role: "worker" }).tailscaleMonitoring, false);
    +  assert.equal(n({ role: "standalone" }).tailscaleMonitoring, false);
    +  assert.equal(n({ role: "standalone", tailscaleMonitoring: true }).tailscaleMonitoring, true);
    +  assert.equal(n({ role: "worker", tailscaleMonitoring: true }).tailscaleMonitoring, true);
    +  assert.equal(n({ tailscaleMonitoring: "yes" }).tailscaleMonitoring, true);
    +  assert.equal(n({ tailscaleMonitoring: 0 }).tailscaleMonitoring, false);
    +});
    +
     test("legacy workerNode-only becomes worker", () => {
       const out = n({ workerNode: true, workerLabel: "  DS  ", workerHeadId: "s5" });
       assert.equal(out.role, "worker");
    diff --git a/src/App.tsx b/src/App.tsx
    index 065fa7a2..95577386 100644
    --- a/src/App.tsx
    +++ b/src/App.tsx
    @@ -6,6 +6,7 @@ import { SparkTabs } from "./components/SparkTabs";
     import { AddSparkDialog } from "./components/AddSparkDialog";
     import { EditSparkDialog } from "./components/EditSparkDialog";
     import { SparkPage } from "./components/SparkPage/SparkPage";
    +import { HermesUpdateDialog } from "./components/SparkPage/HermesUpdateDialog";
     import { OverviewPage } from "./components/OverviewPage/OverviewPage";
     import { ShowcasePage } from "./components/ShowcasePage/ShowcasePage";
     import { ThemeSwitch } from "./components/ThemeSwitch";
    @@ -13,6 +14,27 @@ import { SettingsDialog } from "./components/SettingsDialog";
     import { GearIcon, BoltIcon } from "./components/ui/icons";
     import { OVERVIEW_ID } from "./constants";
     import type { Settings, SparkSnapshot } from "./api/types";
    +import { isWorkerSpark } from "./api/sparkRole";
    +
    +/** Keep hidden worker ids in their original slots when the visible tabs are reordered. */
    +function mergeTabOrderKeepingHidden(
    +  allSparks: SparkSnapshot[],
    +  visibleOrder: string[],
    +  hiddenIds: Set
    +): string[] {
    +  if (hiddenIds.size === 0) return visibleOrder;
    +  const result: string[] = [];
    +  let vi = 0;
    +  for (const spark of allSparks) {
    +    if (hiddenIds.has(spark.id)) {
    +      result.push(spark.id);
    +    } else if (vi < visibleOrder.length) {
    +      result.push(visibleOrder[vi++]);
    +    }
    +  }
    +  while (vi < visibleOrder.length) result.push(visibleOrder[vi++]);
    +  return result;
    +}
     
     function placeholderSnapshot(
       id: string,
    @@ -28,6 +50,8 @@ function placeholderSnapshot(
         llmMonitoring?: boolean;
         comfyMonitoring?: boolean;
         comfyPort?: number;
    +    tailscaleMonitoring?: boolean;
    +    kind?: "spark" | "host";
       }
     ): SparkSnapshot {
       const role =
    @@ -42,6 +66,7 @@ function placeholderSnapshot(
       return {
         id,
         name,
    +    kind: roleFields?.kind ?? "spark",
         online: false,
         uptime: null,
         disabledDevices,
    @@ -60,6 +85,19 @@ function placeholderSnapshot(
               : roleFields?.llmMonitoring !== false,
         comfyMonitoring: Boolean(roleFields?.comfyMonitoring),
         comfyPort: roleFields?.comfyPort ?? 8188,
    +    tailscaleMonitoring: Boolean(roleFields?.tailscaleMonitoring),
    +    hermes: {
    +      monitoring: false,
    +      installed: null,
    +      version: null,
    +      updateAvailable: null,
    +      behindCommits: null,
    +      checkedAt: null,
    +      status: "idle",
    +      startedAt: null,
    +      finishedAt: null,
    +      error: null,
    +    },
         hardware: {
           device: "NVIDIA DGX Spark",
           cpuModel: "…",
    @@ -78,6 +116,7 @@ function placeholderSnapshot(
           unifiedMemory: null,
           llm: [],
           comfy: null,
    +      tailscale: null,
         },
       };
     }
    @@ -119,7 +158,21 @@ function DashboardApp() {
         if (live === orderOverride.join("\0")) setOrderOverride(null);
       }, [liveSparks, orderOverride]);
     
    +
       const isOverview = activeId === OVERVIEW_ID;
    +  const hideWorkers = settings?.hideWorkers ?? false;
    +  const hiddenWorkerIds = useMemo(() => {
    +    if (!hideWorkers) return new Set();
    +    return new Set(
    +      displaySparks
    +        .filter((s) => isWorkerSpark(s) && s.id !== activeId)
    +        .map((s) => s.id)
    +    );
    +  }, [displaySparks, hideWorkers, activeId]);
    +  const tabSparks = useMemo(
    +    () => (hideWorkers ? displaySparks.filter((s) => !hiddenWorkerIds.has(s.id)) : displaySparks),
    +    [displaySparks, hideWorkers, hiddenWorkerIds]
    +  );
       const displayActive = isOverview
         ? null
         : displaySparks.find((s) => s.id === activeId) || displaySparks[0] || activeSpark || null;
    @@ -165,10 +218,12 @@ function DashboardApp() {
                   llmMonitoring: c.llmMonitoring ?? existing.llmMonitoring,
                   comfyMonitoring: c.comfyMonitoring ?? existing.comfyMonitoring,
                   comfyPort: c.comfyPort ?? existing.comfyPort,
    +              tailscaleMonitoring: c.tailscaleMonitoring ?? existing.tailscaleMonitoring,
                   disabledDevices: c.disabledDevices || existing.disabledDevices,
                   disabledInterfaces: c.disabledInterfaces || existing.disabledInterfaces,
                   llmPorts: c.llmPorts ?? existing.llmPorts,
                   llmPort: c.llmPorts?.[0] ?? c.llmPort ?? existing.llmPort,
    +              kind: c.kind ?? existing.kind,
                 };
               }
               return placeholderSnapshot(
    @@ -185,6 +240,8 @@ function DashboardApp() {
                   llmMonitoring: c.llmMonitoring,
                   comfyMonitoring: c.comfyMonitoring,
                   comfyPort: c.comfyPort,
    +              tailscaleMonitoring: c.tailscaleMonitoring,
    +              kind: c.kind,
                 }
               );
             })
    @@ -198,15 +255,19 @@ function DashboardApp() {
         }
       }, [sparks, activeId, setActiveId]);
     
    -  const handleReorder = useCallback(async (orderedIds: string[]) => {
    -    setOrderOverride(orderedIds);
    -    try {
    -      await reorderSparks(orderedIds);
    -    } catch (err) {
    -      console.error("Failed to reorder Sparks:", err);
    -      setOrderOverride(null);
    -    }
    -  }, []);
    +  const handleReorder = useCallback(
    +    async (orderedIds: string[]) => {
    +      const next = mergeTabOrderKeepingHidden(displaySparks, orderedIds, hiddenWorkerIds);
    +      setOrderOverride(next);
    +      try {
    +        await reorderSparks(next);
    +      } catch (err) {
    +        console.error("Failed to reorder Sparks:", err);
    +        setOrderOverride(null);
    +      }
    +    },
    +    [displaySparks, hiddenWorkerIds]
    +  );
     
       return (
         
    @@ -223,7 +284,7 @@ function DashboardApp() { setShowAdd(true)} @@ -248,6 +309,7 @@ function DashboardApp() { @@ -272,6 +334,7 @@ function DashboardApp() { )}
    + setShowAdd(false)} diff --git a/src/api/client.ts b/src/api/client.ts index d9aafe45..e76c4f4d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,7 +1,10 @@ import type { DecodeBenchJob, DecodeBenchListResponse, + HermesBatchUpdateResponse, + HermesUpdatesResponse, LlmMetrics, + LlmDailyResponse, Settings, ShowcaseListResponse, ShowcaseSessionState, @@ -10,6 +13,9 @@ import type { SparkConfig, SparkTestResponse, StartDecodeBenchRequest, + PrefillBenchJob, + PrefillBenchListResponse, + StartPrefillBenchRequest, } from "./types"; const BASE = ""; @@ -44,6 +50,16 @@ export function fetchSparkMetrics(id: string): Promise<{ return apiFetch(`/api/sparks/${id}/metrics`); } +/** Daily busy tok/s rollups for one Spark LLM port. */ +export function fetchLlmDaily( + id: string, + port: number, + days = 14 +): Promise { + const q = new URLSearchParams({ port: String(port), days: String(days) }); + return apiFetch(`/api/sparks/${encodeURIComponent(id)}/llm/daily?${q.toString()}`); +} + export function addSpark(config: SparkConfig): Promise<{ success: boolean; spark: SparkConfig }> { return apiFetch("/api/sparks", { method: "POST", @@ -188,6 +204,51 @@ export function clearDecodeBenchHistory( return apiFetch(`/api/sparks/${id}/llm/bench${q}`, { method: "DELETE" }); } +// ─── LLM prefill benchmark ──────────────────────────── +export function startPrefillBench( + id: string, + body: StartPrefillBenchRequest +): Promise { + return apiFetch(`/api/sparks/${id}/llm/prefill-bench`, { + method: "POST", + body: JSON.stringify(body), + }); +} + +export function getPrefillBench( + id: string, + benchId: string +): Promise { + return apiFetch(`/api/sparks/${id}/llm/prefill-bench/${benchId}`); +} + +export function listPrefillBench( + id: string, + port?: number +): Promise { + const q = + port != null && Number.isInteger(port) ? `?port=${encodeURIComponent(port)}` : ""; + return apiFetch(`/api/sparks/${id}/llm/prefill-bench${q}`); +} + +export function cancelPrefillBench( + id: string, + benchId: string +): Promise { + return apiFetch(`/api/sparks/${id}/llm/prefill-bench/${benchId}`, { + method: "DELETE", + }); +} + +export function clearPrefillBenchHistory( + id: string, + port?: number +): Promise<{ success: boolean }> { + const q = + port != null && Number.isInteger(port) ? `?port=${encodeURIComponent(port)}` : ""; + return apiFetch(`/api/sparks/${id}/llm/prefill-bench${q}`, { method: "DELETE" }); +} + // ─── LLM Prompt Showcase ────────────────────────────── /** Start a concurrent prompt showcase (returns 202 session). */ export function startShowcase( @@ -296,6 +357,22 @@ export function setLlmApiKey( }); } +// ─── Hermes Agent ──────────────────────────────────── +/** One-click `hermes update` via SSH on the Spark (background job; 202 when started). */ +export function updateHermes(id: string): Promise<{ success: boolean; reason?: string }> { + return apiFetch(`/api/sparks/${id}/hermes/update`, { method: "POST" }); +} + +/** Run `hermes update` on every Spark with Hermes Agent monitoring enabled. */ +export function updateAllHermes(): Promise { + return apiFetch("/api/sparks/hermes/update-all", { method: "POST" }); +} + +/** Force an immediate `hermes update --check` on the Spark. */ +export function checkHermes(id: string): Promise<{ success: boolean }> { + return apiFetch(`/api/sparks/${id}/hermes/check`, { method: "POST" }); +} + // ─── Power management ──────────────────────────────────── export interface PowerResult { success: boolean; @@ -338,6 +415,12 @@ export function wakeAllSparks(): Promise { return apiFetch("/api/sparks/wake-all", { method: "POST" }); } +// ─── Hermes update preview ─────────────────────────────── +/** Per-Spark update preview (release + pending commits + resolved view). */ +export function fetchHermesUpdates(id: string): Promise { + return apiFetch(`/api/sparks/${encodeURIComponent(id)}/hermes/updates`); +} + // ─── Global settings ────────────────────────────────────── export function fetchSettings(): Promise { return apiFetch("/api/settings"); diff --git a/src/api/sparkRole.ts b/src/api/sparkRole.ts index 11e750da..828c14e2 100644 --- a/src/api/sparkRole.ts +++ b/src/api/sparkRole.ts @@ -11,6 +11,13 @@ export function resolveSparkRole(spark: { return spark.workerNode ? "worker" : "standalone"; } +export function isWorkerSpark(spark: { + role?: SparkRole | string | null; + workerNode?: boolean | null; +}): boolean { + return resolveSparkRole(spark) === "worker"; +} + /** * Whether this Spark should probe/show the local LLM API. * Workers: never. Head: always. Standalone: llmMonitoring (default true). diff --git a/src/api/types.ts b/src/api/types.ts index f4c64ff5..39e70cfe 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -2,6 +2,13 @@ export interface SparkConfig { id: string; name: string; + /** + * Unit type: + * - spark: NVIDIA DGX Spark (default) — DGX Spark specs shown in the header. + * - host: any Linux box with an NVIDIA GPU (still monitored via nvidia-smi, + * just not a Spark). Real hardware is auto-detected once online. + */ + kind?: "spark" | "host"; lanIp: string; cx7Ip?: string | null; /** @@ -65,19 +72,102 @@ export interface SparkConfig { comfyMonitoring?: boolean; /** ComfyUI HTTP port (default 8188). */ comfyPort?: number; + /** + * Opt-in: Hermes Agent CLI (nousresearch/hermes-agent) is installed on this + * machine. When enabled, sparkDash checks for Hermes updates and can run + * `hermes update` for you via SSH. + */ + hermesMonitoring?: boolean; + /** + * Report tailnet presence via `tailscale status --json` (default false; all roles). + */ + tailscaleMonitoring?: boolean; /** When true, storage is only updated on manual refresh, not auto-polled. */ storagePollDisabled?: boolean; } export type SparkRole = "head" | "worker" | "standalone"; +// ─── Hermes Agent status ─────────────────────────────── +/** Opt-in Hermes Agent update monitoring state, pushed in every snapshot. */ +export interface HermesStatus { + /** Opt-in setting from Edit Spark (hermes installed on this machine). */ + monitoring: boolean; + /** Whether the `hermes` binary was found on the target. null before first check. */ + installed: boolean | null; + /** Installed version string when detected (e.g. "0.20.0"). */ + version: string | null; + /** true when `hermes update --check` reports commits behind origin/main. */ + updateAvailable: boolean | null; + /** Number of commits behind origin/main when reported. */ + behindCommits: number | null; + /** Last check time (ms epoch). */ + checkedAt: number | null; + /** One-shot update job state. */ + status: "idle" | "running" | "success" | "error"; + startedAt: number | null; + finishedAt: number | null; + /** Short human-readable message when the last check/update failed. */ + error: string | null; +} + +/** Latest public Hermes Agent release (changelog for the update dialog). */ +export interface HermesRelease { + /** GitHub release tag, e.g. "v2026.7.7.2". */ + tagName: string; + /** Human release name, e.g. "Hermes Agent v0.18.1 (v2026.7.7.2)". */ + name: string; + version: string; + /** Semantic version of the release (e.g. "0.20.0") for bump detection. */ + semver: string | null; + publishedAt: string | null; + htmlUrl: string; + /** Markdown release body. */ + body: string; +} + +/** One pending commit an update would bring (from git HEAD..origin/main). */ +export interface HermesPendingCommit { + sha: string; + title: string; +} + +/** One Spark's outcome from a batch `update-all` call. */ +export interface HermesBatchUpdateResult { + id: string; + name: string; + ok: boolean; + started: boolean; + skipped?: boolean; + reason?: string; +} + +export interface HermesBatchUpdateResponse { + success: boolean; + results: HermesBatchUpdateResult[]; +} + +/** Per-Spark update preview used by the confirmation dialog. */ +export interface HermesUpdatesResponse { + success: boolean; + /** Which content the dialog should lead with. */ + view: "commits" | "release"; + /** Latest tagged release (may be null on GitHub API failure). */ + release: HermesRelease | null; + releaseError: string | null; + /** Installed hermes version on this Spark (e.g. "0.20.0"), when known. */ + installedVersion: string | null; + /** Pending commits from git (may be null if the repo can't be read). */ + pending: { count: number; headSha: string | null; commits: HermesPendingCommit[] } | null; +} + // ─── Hardware info ─────────────────────────────────────── export interface HardwareInfo { device: string; - cpuModel: string; - cpuCores: number; - totalMemoryGB: number; - gpuChip: string; + cpuModel: string | null; + cpuCores: number | null; + totalMemoryGB: number | null; + gpuChip: string | null; cudaDriver: string | null; storageModel: string | null; } @@ -121,6 +211,8 @@ export interface GpuMetrics { processes?: Array<{ pid: number; name: string; vramMB: number }>; /** NVIDIA clock throttle / thermal slowdown state from nvidia-smi. */ throttle?: GpuThrottle | null; + /** Kernel NVRM NV_ERR_NO_MEMORY count since boot (cached ~60s). */ + nvErrNoMemory?: number; } // ─── CPU metrics ───────────────────────────────────────── @@ -191,7 +283,7 @@ export interface UnifiedMemoryMetrics { // ─── LLM metrics ───────────────────────────────────────── export interface LlmMetrics { available: boolean; - backend: "vllm" | "llama.cpp" | "sglang" | "ds4" | null; + backend: "vllm" | "llama.cpp" | "sglang" | "ds4" | "exl3" | null; modelId: string | null; modelPath: string | null; contextLength: number | null; @@ -201,6 +293,10 @@ export interface LlmMetrics { slotsTotal: number; generationTps: number; prefillTps: number; + /** Live cached-prefill tok/s when the backend splits kinds (ds4, llama.cpp, sglang). */ + cachedPrefillTps?: number | null; + /** Live uncached/computed prefill tok/s when split is available. */ + uncachedPrefillTps?: number | null; /** Cumulative total output (generation) tokens as reported by the LLM server */ totalOutputTokens: number; /** vLLM KV cache usage fraction (0–1). null when backend !== vllm or unreachable. */ @@ -211,6 +307,8 @@ export interface LlmMetrics { requestsWaiting?: number | null; /** vLLM time-to-first-token p95 in seconds. null when unavailable. */ ttftP95Seconds?: number | null; + /** Live recent-window mean TTFT (seconds) from vLLM histogram sum/count deltas. null when unavailable. */ + ttftSeconds?: number | null; /** vLLM cumulative preemption count. null when unavailable. */ preemptionsTotal?: number | null; /** vLLM prefix-cache hit rate (hits/queries, 0–1). null when unavailable. */ @@ -230,6 +328,25 @@ export interface LlmMetrics { error: string | null; } +/** One UTC day of busy tok/s rollups (null avg = no busy samples). */ +export interface LlmDailyDay { + date: string; + decodeMax: number; + decodeAvg: number | null; + prefillMax: number; + prefillAvg: number | null; + cachedPrefillMax: number | null; + cachedPrefillAvg: number | null; + uncachedPrefillMax: number | null; + uncachedPrefillAvg: number | null; +} + +export interface LlmDailyResponse { + sparkId: string; + port: number; + days: LlmDailyDay[]; +} + /** Security posture badge payload from LlmProbe. */ export interface LlmPosture { /** ok = green, warn = amber, danger = red */ @@ -310,6 +427,30 @@ export interface ComfyMetrics { error: string | null; } +export interface TailscaleMetrics { + /** True when `tailscale status --json` was read and had a Self entry. */ + available: boolean; + /** + * The node's OWN view of whether it is talking to the coordination server. + * null when tailscale did not report it. + */ + online: boolean | null; + /** tailscaled's own state: Running | Stopped | NeedsLogin | NoState. */ + backendState: string | null; + hostName: string | null; + dnsName: string | null; + tailscaleIp: string | null; + /** DERP relay region, or null when the node has a direct path. */ + relay: string | null; + /** ISO timestamp; null when key expiry is disabled for this node. */ + keyExpiry: string | null; + keyExpired: boolean; + version: string | null; + /** Tailscale's own health warnings — these explain a false `online`. */ + health: string[]; + error: string | null; +} + // ─── Full metrics snapshot ──────────────────────────────── export interface SparkMetrics { gpu: GpuMetrics | null; @@ -322,12 +463,16 @@ export interface SparkMetrics { llm: LlmMetrics[]; /** ComfyUI probe result when monitoring is enabled; null when off or not yet polled. */ comfy?: ComfyMetrics | null; + /** Tailnet probe result when monitoring is enabled; null when off or not yet polled. */ + tailscale?: TailscaleMetrics | null; } // ─── Spark snapshot (server pushes this) ────────────────── export interface SparkSnapshot { id: string; name: string; + /** Unit type: spark (DGX Spark) or host (dedicated GPU Linux box). */ + kind?: "spark" | "host"; online: boolean; /** Uptime in seconds, or null when offline */ uptime: number | null; @@ -357,6 +502,10 @@ export interface SparkSnapshot { comfyMonitoring?: boolean; /** ComfyUI HTTP port (default 8188) */ comfyPort?: number; + /** Whether tailnet presence is probed (opt-in; all roles) */ + tailscaleMonitoring?: boolean; + /** Hermes Agent update monitoring state (present in every snapshot). */ + hermes?: HermesStatus; hardware: HardwareInfo; metrics: SparkMetrics; } @@ -373,6 +522,8 @@ export interface Settings { pollIntervalMs: number; defaultLlmPort: number; autoHideOffline: boolean; + /** Hide worker-role Sparks from Overview cards and the tab bar. */ + hideWorkers: boolean; temperatureUnit: "celsius" | "fahrenheit"; /** Persist prompts / HTTP traces / GPU samples on decode benchmark runs. */ benchDebugTraces: boolean; @@ -397,11 +548,26 @@ export interface ApiError { } // ─── LLM decode benchmark ──────────────────────────────── +/** Output-shape label for decode bench prompts (not guided decoding). */ +export type DecodeBenchPromptType = "structured" | "prose" | "code" | "json"; + +/** On-demand remote LLM endpoint for decode/prefill benches. */ +export interface LlmBenchTarget { + host: string; + port: number; + tls: boolean; +} + export interface DecodeBenchConfig { port: number; modelId: string | null; concurrencies: number[]; maxTokens: number; + /** Output-shape label only — not guided decoding / JSON schema. */ + promptType?: DecodeBenchPromptType; + /** On-demand remote host (Tailscale HTTPS, etc.). */ + host?: string; + tls?: boolean; } export interface DecodeBenchStreamResult { @@ -414,6 +580,8 @@ export interface DecodeBenchStreamResult { decodeTps: number; decodeTokens: number; completionTokens: number; + prefillTps: number; + prefillTokens: number; totalMs: number; error: string | null; /** Exact prompt used for this stream (debug). */ @@ -462,6 +630,11 @@ export interface DecodeBenchLevelResult { medianTtftMs: number; /** Client: total post-first-token tokens / concurrent decode window */ aggregateDecodeTps: number; + meanPrefillTps: number; + medianPrefillTps: number; + /** Sum prompt tokens / concurrent TTFT window (min start → max first token) */ + aggregatePrefillTps: number; + totalPrefillTokens: number; totalDecodeTokens: number; totalCompletionTokens: number; durationMs: number; @@ -507,6 +680,8 @@ export interface DecodeBenchDefaults { defaultMaxTokens: number; minMaxTokens: number; maxMaxTokens: number; + promptTypes: DecodeBenchPromptType[]; + defaultPromptType: DecodeBenchPromptType; } export interface DecodeBenchListResponse { @@ -522,6 +697,73 @@ export interface StartDecodeBenchRequest { concurrencies: number[]; maxTokens?: number; modelId?: string | null; + /** Output type: structured (default), prose, code, json. Prompt only. */ + promptType?: DecodeBenchPromptType; + /** On-demand remote LLM host (hostname or URL). Skips this Spark's LAN/SSH path. */ + host?: string; + tls?: boolean; +} + +// ─── LLM prefill benchmark ─────────────────────────────── +export interface PrefillBenchConfig { + port: number; + modelId: string | null; + contextSizes: number[]; + host?: string; + tls?: boolean; +} + +export interface PrefillBenchSizeResult { + targetTokens: number; + promptTokens: number; + promptChars: number; + prefillTps: number; + ttftMs: number; + ttftContentMs: number | null; + completionTokens: number; + durationMs: number; + model: string | null; + error: string | null; +} + +export interface PrefillBenchProgress { + currentContext: number | null; + completedLevels: number; + totalLevels: number; + message: string; +} + +export interface PrefillBenchJob { + benchId: string; + sparkId: string; + status: "running" | "completed" | "failed" | "cancelled"; + startedAt: number; + completedAt: number | null; + config: PrefillBenchConfig; + progress: PrefillBenchProgress; + results: PrefillBenchSizeResult[]; + error: string | null; + durationMs: number; +} + +export interface PrefillBenchDefaults { + allowedContextSizes: number[]; + defaultContextSizes: number[]; +} + +export interface PrefillBenchListResponse { + active: PrefillBenchJob | null; + last: PrefillBenchJob | null; + history: PrefillBenchJob[]; + defaults: PrefillBenchDefaults; +} + +export interface StartPrefillBenchRequest { + port?: number; + contextSizes: number[]; + modelId?: string | null; + host?: string; + tls?: boolean; } // ─── LLM Prompt Showcase ───────────────────────────────── diff --git a/src/components/AddSparkDialog.tsx b/src/components/AddSparkDialog.tsx index 49d5ce6b..917767f2 100644 --- a/src/components/AddSparkDialog.tsx +++ b/src/components/AddSparkDialog.tsx @@ -23,6 +23,7 @@ function useEscape(onClose: () => void) { const defaultConfig: Omit = { name: "", + kind: "spark", lanIp: "", cx7Ip: "", isLocal: false, @@ -138,11 +139,23 @@ export function AddSparkDialog({ open, onClose, onAdded, defaultLlmPort = 8888 } aria-labelledby="add-spark-title" >
    - Add Spark + Add Spark/GPU Host
    +
    + + +
    +
    -
    - - update({ cx7Ip: e.target.value || null })} - className="w-full rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs text-text outline-none focus:border-accent" - placeholder="10.0.0.1" - /> -
    + {config.kind !== "host" && ( +
    + + update({ cx7Ip: e.target.value || null })} + className="w-full rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs text-text outline-none focus:border-accent" + placeholder="10.0.0.1" + /> +
    + )}
    @@ -228,6 +243,14 @@ export function AddSparkDialog({ open, onClose, onAdded, defaultLlmPort = 8888 } + {config.ssh.auth === "key" && ( +

    + SSH runs on the sparkDash host (not your browser). In Docker, mount a private + key at /root/.ssh/id_ed25519 (see docker-compose.yml) or set SSH_IDENTITY_FILE. + IPs are from that host's network. Mark this machine as “This host” so it + skips SSH. +

    + )}
    {config.ssh.auth === "pass" && ( diff --git a/src/components/EditSparkDialog.tsx b/src/components/EditSparkDialog.tsx index 87ccdf27..2a02121f 100644 --- a/src/components/EditSparkDialog.tsx +++ b/src/components/EditSparkDialog.tsx @@ -179,7 +179,9 @@ export function EditSparkDialog({ (config.ssh?.host || config.lanIp) !== (savedConfig.ssh?.host || savedConfig.lanIp) || config.ssh?.user !== savedConfig.ssh?.user || config.ssh?.auth !== savedConfig.ssh?.auth || + (config.kind ?? "spark") !== (savedConfig.kind ?? "spark") || Boolean(config.comfyMonitoring) !== Boolean(savedConfig.comfyMonitoring) || + Boolean(config.tailscaleMonitoring) !== Boolean(savedConfig.tailscaleMonitoring) || (config.comfyPort ?? 8188) !== (savedConfig.comfyPort ?? 8188); const result = formDirty @@ -234,6 +236,7 @@ export function EditSparkDialog({ const patch: Partial = { name: config.name, + kind: config.kind ?? "spark", lanIp: config.lanIp, cx7Ip: config.cx7Ip, macAddress: config.macAddress || null, @@ -249,6 +252,8 @@ export function EditSparkDialog({ const n = Number(config.comfyPort); return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : 8188; })(), + hermesMonitoring: Boolean(config.hermesMonitoring), + tailscaleMonitoring: Boolean(config.tailscaleMonitoring), ssh: { host: config.ssh.host || config.lanIp, user: config.ssh.user, @@ -303,6 +308,18 @@ export function EditSparkDialog({ {config && !loading && (
    +
    + + +
    +
    -
    - - update({ cx7Ip: e.target.value || null })} - className="w-full rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs text-text outline-none focus:border-accent" - /> -
    + {config.kind !== "host" && ( +
    + + update({ cx7Ip: e.target.value || null })} + className="w-full rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs text-text outline-none focus:border-accent" + /> +
    + )}
    + +

    + Checks for updates in the background (10 min) and adds an "Update Hermes" button + that runs{" "} + hermes update on this + machine via SSH. +

    + +
    + +
    + {role === "worker" && (
    @@ -537,6 +598,12 @@ export function EditSparkDialog({ + {config.ssh.auth === "key" && ( +

    + SSH runs on the sparkDash host. Docker: mount a key at /root/.ssh/id_ed25519 + (or SSH_IDENTITY_FILE). IPs are from that host, not your laptop. +

    + )}
    {config.ssh.auth === "pass" && ( diff --git a/src/components/OverviewPage/OverviewPage.tsx b/src/components/OverviewPage/OverviewPage.tsx index bbfec237..41dcb36f 100644 --- a/src/components/OverviewPage/OverviewPage.tsx +++ b/src/components/OverviewPage/OverviewPage.tsx @@ -1,14 +1,15 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { SparkSnapshot } from "../../api/types"; -import { resolveSparkRole } from "../../api/sparkRole"; -import { shutdownAllSparks, wakeAllSparks } from "../../api/client"; +import { isWorkerSpark, resolveSparkRole } from "../../api/sparkRole"; +import { shutdownAllSparks, updateAllHermes, wakeAllSparks } from "../../api/client"; import { ConfirmShutdownDialog } from "../ConfirmShutdownDialog"; import { MetricBar } from "../ui/MetricBar"; -import { ActivityIcon, PowerOffIcon, PowerOnIcon } from "../ui/icons"; +import { ActivityIcon, PowerOffIcon, PowerOnIcon, RotateIcon } from "../ui/icons"; interface OverviewPageProps { sparks: SparkSnapshot[]; hideOffline?: boolean; + hideWorkers?: boolean; temperatureUnit?: "celsius" | "fahrenheit"; onSelectSpark?: (id: string) => void; } @@ -209,13 +210,52 @@ function SparkCard({ color={vramBarColor} caption={vramTotal > 0 ? `${fmtStorage(vramUsed, false)} / ${fmtStorage(vramTotal, true)}` : "—"} /> + {spark.kind === "host" && (() => { + // Non-Spark hosts: system RAM is separate from discrete VRAM. + const ram = spark.metrics.ram; + const rUsed = ram?.used ?? 0; + const rTotal = ram?.total ?? 0; + const rPct = rTotal > 0 ? Math.round((rUsed / rTotal) * 100) : 0; + const ramBarColor = rPct > 85 ? "bg-danger" : rPct > 60 ? "bg-warning" : "bg-accent"; + return ( + 0 ? `${fmtStorage(rUsed, false)} / ${fmtStorage(rTotal, true)}` : "—"} + /> + ); + })()} 0 + ? "GPU" + : "Temperature" + } value={displayTemp} max={temperatureUnit === "fahrenheit" ? 212 : 100} color={tempBarColor} caption={tempLabel} /> + {(spark.metrics.cpu?.temperature ?? 0) > 0 && (() => { + const cpuRaw = spark.metrics.cpu?.temperature ?? 0; + const cpuDisplay = + temperatureUnit === "fahrenheit" ? celsiusToFahrenheit(cpuRaw) : cpuRaw; + const cpuLabel = + temperatureUnit === "fahrenheit" ? `${cpuDisplay}°F` : `${cpuDisplay}°C`; + const cpuBarColor = + cpuRaw > 95 ? "bg-danger" : cpuRaw > 85 ? "bg-warning" : cpuRaw > 50 ? "bg-accent" : "bg-success"; + return ( + + ); + })()} {gpu?.throttle?.thermal && (
    {(() => { + const role = resolveSparkRole(spark); + if (role === "worker") return null; const llmArr = spark.metrics.llm; const llm = Array.isArray(llmArr) ? llmArr.find((l) => l.available) : null; if (!llm) return null; return ( -
    - - {llm.generationTps.toFixed(0)} - - tok/s +
    +
    + + {llm.generationTps.toFixed(0)} + + tok/s +
    +
    + + {llm.prefillTps.toFixed(0)} + + prefill +
    ); })()} @@ -328,13 +380,91 @@ function SparkCard({ ); } -export function OverviewPage({ sparks, hideOffline = false, temperatureUnit = "celsius", onSelectSpark }: OverviewPageProps) { - const visibleSparks = hideOffline ? sparks.filter((s) => s.online) : sparks; +export function OverviewPage({ + sparks, + hideOffline = false, + hideWorkers = false, + temperatureUnit = "celsius", + onSelectSpark, +}: OverviewPageProps) { + const withoutWorkers = hideWorkers ? sparks.filter((s) => !isWorkerSpark(s)) : sparks; + const visibleSparks = hideOffline ? withoutWorkers.filter((s) => s.online) : withoutWorkers; + const hiddenWorkerCount = hideWorkers ? sparks.filter(isWorkerSpark).length : 0; const [batchLoading, setBatchLoading] = useState(false); const [batchMsg, setBatchMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null); const [shutdownOpen, setShutdownOpen] = useState(false); + /** Spark ids we started a batch Hermes update on; drives the live progress bar. */ + const [batchRun, setBatchRun] = useState(null); const onlineShutdownCount = sparks.filter((s) => s.online).length; + const hermesMonitoredCount = sparks.filter((s) => s.hermes?.monitoring).length; + const hermesPendingUpdateCount = sparks.filter((s) => s.hermes?.updateAvailable === true).length; + + // Live batch progress — counted from WS snapshots, not from the one-shot HTTP response. + const batchProg = (() => { + if (!batchRun || batchRun.length === 0) return null; + let done = 0; + let failed = 0; + for (const id of batchRun) { + const h = sparks.find((s) => s.id === id)?.hermes; + if (!h) continue; + if (h.status === "error") { + done += 1; + failed += 1; + } else if (h.status === "success" || h.finishedAt != null) { + done += 1; + } + } + return { total: batchRun.length, done, failed }; + })(); + + // Once every started update has settled (success/error), dismiss the progress bar. + useEffect(() => { + if (!batchRun || batchRun.length === 0) return; + const settled = batchRun.reduce((n, id) => { + const h = sparks.find((s) => s.id === id)?.hermes; + if (!h) return n; + return n + (h.status === "success" || h.status === "error" || h.finishedAt != null ? 1 : 0); + }, 0); + if (settled === batchRun.length) { + const t = setTimeout(() => setBatchRun(null), 6000); + return () => clearTimeout(t); + } + }, [batchRun, sparks]); + + async function handleUpdateAllHermes() { + if (hermesMonitoredCount === 0) return; + setBatchLoading(true); + setBatchMsg(null); + try { + const res = await updateAllHermes(); + const started = res.results.filter((r) => r.started); + const skipped = res.results.filter((r) => r.skipped).length; + const failed = res.results.filter((r) => !r.ok && !r.skipped).length; + const parts = [`${started.length} update${started.length === 1 ? "" : "s"} started`]; + if (skipped) parts.push(`${skipped} skipped`); + if (failed) parts.push(`${failed} failed`); + setBatchMsg({ + text: parts.join(", "), + tone: failed === 0 ? "ok" : "err", + }); + // Merge with any in-flight batch instead of replacing (server may skip + // already-running jobs, which must not clear a live progress bar). + setBatchRun((prev) => { + const ids = started.map((r) => r.id); + if (ids.length === 0) return prev; + return [...new Set([...(prev ?? []), ...ids])]; + }); + } catch (err: unknown) { + setBatchMsg({ + text: err instanceof Error ? err.message : "Batch hermes update failed", + tone: "err", + }); + } finally { + setBatchLoading(false); + setTimeout(() => setBatchMsg(null), 6000); + } + } async function handleShutdownAll() { if (onlineShutdownCount === 0) return; @@ -386,20 +516,25 @@ export function OverviewPage({ sparks, hideOffline = false, temperatureUnit = "c } if (visibleSparks.length === 0) { - const allOffline = hideOffline && sparks.length > 0; + const allWorkersHidden = hideWorkers && sparks.length > 0 && withoutWorkers.length === 0; + const allOffline = hideOffline && withoutWorkers.length > 0; + const title = allWorkersHidden + ? "Worker nodes are hidden" + : allOffline + ? "All Sparks are offline" + : "No Sparks registered"; + const detail = allWorkersHidden + ? "Hide worker nodes is on in Settings. Turn it off to show Worker-role Sparks again." + : allOffline + ? "Auto-hide is enabled and no Sparks are currently online." + : "Click the + tab to add a DGX Spark unit."; return (
    -

    - {allOffline ? "All Sparks are offline" : "No Sparks registered"} -

    -

    - {allOffline - ? "Auto-hide is enabled and no Sparks are currently online." - : "Click the + tab to add a DGX Spark unit."} -

    +

    {title}

    +

    {detail}

    ); } @@ -415,14 +550,68 @@ export function OverviewPage({ sparks, hideOffline = false, temperatureUnit = "c > Overview -
    +
    {batchMsg && ( {batchMsg.text} )} + {batchProg && ( +
    + + + Updating Hermes — {batchProg.done}/{batchProg.total} + {batchProg.failed > 0 && ( + ({batchProg.failed} failed) + )} + + +
    +
    0 ? "bg-danger" : "bg-accent" + }`} + style={{ + width: `${batchProg.total > 0 ? Math.round((batchProg.done / batchProg.total) * 100) : 0}%`, + }} + /> +
    +
    + )} {sparks.length > 0 && ( -
    +
    + {hermesMonitoredCount > 0 && ( + + )}
    + {/* Hide worker nodes */} +
    + +
    + {/* Benchmark debug traces */}
    @@ -394,6 +458,39 @@ export function BenchmarkDialog({ {showConfig && (
    +
    +
    +

    Type

    +

    + {DECODE_BENCH_TYPE_META.find((t) => t.id === promptType)?.hint} + {" · temp 0, thinking off"} +

    +
    +
    + {DECODE_BENCH_TYPE_META.map((t) => { + const on = promptType === t.id; + return ( + + ); + })} +
    +
    +

    Concurrency

    @@ -424,7 +521,10 @@ export function BenchmarkDialog({ -

    Default 500 · range 64–2048

    +

    + Default 400 · temp 0, thinking off + {promptType === "structured" ? " · count 1→200" : ""} +

    Running + {job.config?.promptType + ? ` · ${decodeBenchTypeLabel(job.config.promptType)}` + : ""} {job.progress.currentConcurrency != null ? ` · ×${job.progress.currentConcurrency}` : ""} @@ -491,8 +594,8 @@ export function BenchmarkDialog({ {statusLabel(job.status)} - {job.config.maxTokens} tok · {job.config.concurrencies.join(", ")}{" "} - conc + {decodeBenchTypeLabel(job.config.promptType)} · {job.config.maxTokens} tok ·{" "} + {job.config.concurrencies.join(", ")} conc {job.durationMs != null ? ` · ${formatDuration(job.durationMs)}` : ""}
    @@ -516,8 +619,8 @@ export function BenchmarkDialog({ {job.results.length > 0 && (

    - Aggregate — total tok/s across all concurrent streams.{" "} - Stream — per-stream average. + Aggregate — total decode tok/s across all concurrent streams.{" "} + Stream — per-stream average decode.

    )}
    diff --git a/src/components/SparkPage/CpuPanel.tsx b/src/components/SparkPage/CpuPanel.tsx deleted file mode 100644 index 9eb2922c..00000000 --- a/src/components/SparkPage/CpuPanel.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import type { CpuMetrics, RamMetrics, UnifiedMemoryMetrics } from "../../api/types"; -import { Sparkline } from "../ui/Sparkline"; -import { Panel } from "../ui/Panel"; -import { CpuIcon, MemoryIcon } from "../ui/icons"; -import { MetricBar } from "../ui/MetricBar"; -import { useMetricsHistoryTail } from "../../hooks/metricsStore"; - -interface CpuPanelProps { - cpu: CpuMetrics | null; - ram: RamMetrics | null; - sparkId: string; - unifiedMemory: UnifiedMemoryMetrics | null; -} - -function formatMb(mb: number): string { - if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; - return `${Math.round(mb)} MB`; -} - -function MetricRow({ - label, - spark, - value, - color = "var(--color-accent)", -}: { - label: string; - spark: React.ReactNode; - value: React.ReactNode; - color?: string; -}) { - return ( -
    - {label} -
    - {spark} - {value} -
    -
    - ); -} - -export function CpuPanel({ cpu, ram, sparkId, unifiedMemory }: CpuPanelProps) { - const usageHistory = useMetricsHistoryTail(sparkId, "cpu.usage"); - - const usage = cpu?.usage ?? 0; - const draw = cpu?.draw ?? 0; - const tdp = cpu?.tdp ?? 0; - - const ramUsed = ram?.used ?? 0; - const ramTotal = ram?.total ?? 0; - const ramPct = ram?.percentage ?? 0; - const ramAvail = ramTotal > 0 ? ramTotal - ramUsed : 0; - - return ( - } className="panel-cpu" bodyClassName="space-y-3" accent> - } - value={{usage}%} - /> -
    - Power - - {draw}W / {tdp}W - -
    - - {(ramTotal > 0 || ram) && ( -
    -
    - - RAM -
    - 0 ? `${formatMb(ramUsed)} / ${formatMb(ramTotal)} · ${ramPct}%` : "—"} - /> - {ramAvail > 0 && ( -
    - Available - {formatMb(ramAvail)} -
    - )} - {unifiedMemory?.oomRisk && unifiedMemory.oomRisk !== "low" && ( -
    - OOM Risk - - {unifiedMemory.oomRisk} - -
    - )} -
    - )} -
    - ); -} \ No newline at end of file diff --git a/src/components/SparkPage/GpuPanel.tsx b/src/components/SparkPage/GpuPanel.tsx index 9c5b5acc..d1927c6e 100644 --- a/src/components/SparkPage/GpuPanel.tsx +++ b/src/components/SparkPage/GpuPanel.tsx @@ -1,4 +1,4 @@ -import type { GpuMetrics } from "../../api/types"; +import type { CpuMetrics, GpuMetrics } from "../../api/types"; import { Sparkline } from "../ui/Sparkline"; import { Panel } from "../ui/Panel"; import { ActivityIcon } from "../ui/icons"; @@ -7,8 +7,11 @@ import { useMetricsHistoryTail } from "../../hooks/metricsStore"; interface GpuPanelProps { gpu: GpuMetrics | null; + /** When set and temperature > 0, show a CPU temp row (DGX Spark pages). */ + cpu?: CpuMetrics | null; sparkId: string; temperatureUnit: "celsius" | "fahrenheit"; + className?: string; } function celsiusToFahrenheit(c: number): number { @@ -42,9 +45,10 @@ function MetricRow({ ); } -export function GpuPanel({ gpu, sparkId, temperatureUnit }: GpuPanelProps) { +export function GpuPanel({ gpu, cpu, sparkId, temperatureUnit, className }: GpuPanelProps) { const tempHistory = useMetricsHistoryTail(sparkId, "gpu.temp"); const usageHistory = useMetricsHistoryTail(sparkId, "gpu.usage"); + const cpuTempHistory = useMetricsHistoryTail(sparkId, "cpu.temp"); const temperature = gpu?.temperature ?? 0; const displayTemp = temperatureUnit === "fahrenheit" ? celsiusToFahrenheit(temperature) : temperature; @@ -57,19 +61,32 @@ export function GpuPanel({ gpu, sparkId, temperatureUnit }: GpuPanelProps) { const vramTotal = gpu?.vram?.total ?? 0; const vramPct = gpu?.vram?.percentage ?? 0; + const cpuTemperature = cpu?.temperature ?? 0; + const cpuDisplayTemp = + temperatureUnit === "fahrenheit" ? celsiusToFahrenheit(cpuTemperature) : cpuTemperature; + const cpuTempLabel = + temperatureUnit === "fahrenheit" ? `${cpuDisplayTemp}°F` : `${cpuDisplayTemp}°C`; + const tempColor = temperature > 85 ? "var(--color-danger)" : temperature > 65 ? "var(--color-warning)" : "var(--color-accent)"; + // GB10 junction bands (warn 85 / crit 95) — idle CPU sits ~70°C, so GPU 65/85 would pin amber. + const cpuTempColor = + cpuTemperature > 95 + ? "var(--color-danger)" + : cpuTemperature > 85 + ? "var(--color-warning)" + : "var(--color-accent)"; return ( } - className="panel-gpu" + className={`panel-gpu ${className ?? ""}`} bodyClassName="space-y-3" > } value={{tempLabel}} /> + {cpuTemperature > 0 && ( + } + value={{cpuTempLabel}} + /> + )}
    GPU Power @@ -177,17 +202,29 @@ export function GpuPanel({ gpu, sparkId, temperatureUnit }: GpuPanelProps) {
    )} + {(gpu?.nvErrNoMemory ?? 0) > 0 && ( +
    + NV_ERR_NO_MEMORY + + {gpu?.nvErrNoMemory} + +
    + )} + {/* Top GPU processes by VRAM usage */} {gpu && gpu.processes && gpu.processes.length > 0 && (
    Processes
    {gpu.processes.map((proc) => ( -
    -
    - +
    +
    + {proc.name} - + {proc.pid}
    diff --git a/src/components/SparkPage/HermesUpdateDialog.tsx b/src/components/SparkPage/HermesUpdateDialog.tsx new file mode 100644 index 00000000..b6064887 --- /dev/null +++ b/src/components/SparkPage/HermesUpdateDialog.tsx @@ -0,0 +1,534 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import type { ReactNode } from "react"; +import { fetchHermesUpdates, updateHermes } from "../../api/client"; +import type { HermesUpdatesResponse } from "../../api/types"; +import { useModalPresence } from "../../hooks/useModalPresence"; +import { + closeHermesUpdateDialog, + useHermesUpdateDialog, +} from "../../hooks/useHermesUpdateDialog"; +import { ExternalLinkIcon, RotateIcon } from "../ui/icons"; + +function useEscape(enabled: boolean, onClose: () => void) { + useEffect(() => { + if (!enabled) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [enabled, onClose]); +} + +// ─── Changelog rendering (dependency-free markdown subset) ───────── +// Renders only the structure GitHub auto-generates for release bodies: +// headings, lists, code fences, blockquotes, paragraphs with bold/italic, +// inline code and links. Everything else is escaped text — never raw HTML. + +const INLINE_RE = + /(`[^`\n]+`)|(\*\*([^*\n]+)\*\*)|(\*([^*\n]+)\*)|(\[([^\]\n]*)\]\((https?:\/\/[^)\s]+)\))|(https?:\/\/[^\s<>()"'\]\[]+)/g; + +function parseInline(text: string, keyBase: string): ReactNode[] { + const nodes: ReactNode[] = []; + let last = 0; + let m: RegExpExecArray | null; + let k = 0; + const re = new RegExp(INLINE_RE.source, "g"); + while ((m = re.exec(text)) !== null) { + if (m.index > last) nodes.push(text.slice(last, m.index)); + const [full, code, , bold, , italic, , linkText, linkUrl, bareUrl] = m; + if (code) { + nodes.push( + + {code.slice(1, -1)} + + ); + } else if (bold) { + nodes.push({bold}); + } else if (italic) { + nodes.push({italic}); + } else if (linkUrl) { + nodes.push( + + {linkText || linkUrl} + + ); + } else if (bareUrl) { + nodes.push( + + {bareUrl} + + ); + } + k++; + last = m.index + full.length; + } + if (last < text.length) nodes.push(text.slice(last)); + return nodes; +} + +function ChangelogBody({ body }: { body: string }) { + const blocks = useMemo(() => { + const out: ReactNode[] = []; + const lines = body.replace(/\r\n?/g, "\n").slice(0, 40000).split("\n"); + let codeLines: string[] | null = null; + let listType: "ul" | "ol" | null = null; + let items: ReactNode[] = []; + let para: string[] | null = null; + let key = 0; + let sentinel = 0; + + const flushPara = () => { + if (para && para.length > 0) { + out.push( +

    + {parseInline(para.join(" "), `p${key}-${sentinel}`)} +

    + ); + para = null; + } + }; + const flushList = () => { + if (items.length > 0) { + out.push( + listType === "ol" ? ( +
      + {items} +
    + ) : ( +
      + {items} +
    + ) + ); + items = []; + } + listType = null; + }; + + for (const raw of lines) { + key++; + if (key > 600) break; + const line = raw.trim(); + + // Fenced code block + if (/^```/.test(line)) { + flushPara(); + flushList(); + if (codeLines !== null) { + out.push( +
    +              
    +                {codeLines.join("\n")}
    +              
    +            
    + ); + codeLines = null; + } else { + codeLines = []; + } + continue; + } + if (codeLines !== null) { + codeLines.push(line); + continue; + } + + if (!line) { + flushPara(); + flushList(); + continue; + } + + // Headings + const h = /^(#{1,4})\s+(.*)$/.exec(line); + if (h) { + flushPara(); + flushList(); + const content = parseInline(h[2], `h${key}`); + if (h[1].length <= 2) { + out.push( +

    + {content} +

    + ); + } else if (h[1].length === 3) { + out.push( +

    + {content} +

    + ); + } else { + out.push( +

    + {content} +

    + ); + } + continue; + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) { + flushPara(); + flushList(); + out.push(
    ); + continue; + } + + // Blockquote + const bq = /^>\s?(.*)$/.exec(line); + if (bq) { + flushPara(); + flushList(); + out.push( +
    + {parseInline(bq[1], `bq${key}`)} +
    + ); + continue; + } + + // Unordered list (incl. task lists) + const ul = /^[-*•]\s+(.*)$/.exec(line); + if (ul) { + flushPara(); + if (listType !== "ul") { + flushList(); + listType = "ul"; + } + const marker = /^\[([ xX])\]\s+/.exec(ul[1]); + const content = marker + ? (marker[1] === "x" || marker[1] === "X" ? "☑ " : "☐ ") + ul[1].slice(marker[0].length) + : ul[1]; + items.push(
  • {parseInline(content, `li${key}`)}
  • ); + continue; + } + + // Ordered list + const ol = /^\d+[.)]\s+(.*)$/.exec(line); + if (ol) { + flushPara(); + if (listType !== "ol") { + flushList(); + listType = "ol"; + } + items.push(
  • {parseInline(ol[1], `li${key}`)}
  • ); + continue; + } + + // Plain paragraph line + flushList(); + if (!para) para = []; + para.push(line); + } + flushPara(); + flushList(); + if (codeLines !== null) { + out.push( +
    +          
    +            {codeLines.join("\n")}
    +          
    +        
    + ); + } + return out; + }, [body]); + + return
    {blocks}
    ; +} + +// ─── Dialog ────────────────────────────────────────────────────────── + +function PendingCommitsList({ upd }: { upd: HermesUpdatesResponse }) { + const pending = upd.pending; + const commits = pending?.commits ?? []; + return ( +
    +
      + {commits.map((c) => ( +
    • + + {c.sha.slice(0, 8)} + + {c.title} +
    • + ))} +
    + {pending && pending.count > commits.length && ( +

    + …and {pending.count - commits.length} more (showing first {commits.length}). +

    + )} +
    + ); +} + +export function HermesUpdateDialog() { + const target = useHermesUpdateDialog(); + const open = target !== null; + const { mounted, visible } = useModalPresence(open); + const [upd, setUpd] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [updating, setUpdating] = useState(false); + + useEscape(open, closeHermesUpdateDialog); + + // Prevent background scroll while open + useEffect(() => { + if (!mounted) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [mounted]); + + // Fetch the update preview whenever the dialog (re)opens + useEffect(() => { + if (!open || !target) { + setUpd(null); + setLoading(false); + setError(null); + setUpdating(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + fetchHermesUpdates(target.sparkId) + .then((res) => { + if (!cancelled) setUpd(res); + }) + .catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : String(err)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, target]); + + const handleUpdateNow = async () => { + if (!target || updating) return; + setUpdating(true); + setError(null); + try { + await updateHermes(target.sparkId); + closeHermesUpdateDialog(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + setUpdating(false); + } + }; + + if (!mounted) return null; + + const targetName = upd?.release?.name || upd?.release?.tagName || "latest release"; + + return createPortal( +
    { + if (e.target === e.currentTarget) closeHermesUpdateDialog(); + }} + > +
    +
    +
    + + Update Hermes Agent +
    +

    + {target?.sparkName} + {target?.currentVersion ? ` · installed v${target.currentVersion}` : ""} +

    +
    + +
    + {loading && ( +
    + + Loading release notes… +
    + )} + + {error && !loading && ( +
    +

    + Couldn't load the changelog. You can still update. +

    +

    {error}

    +
    + + + Open releases page + + +
    +
    + )} + + {upd && !loading && upd.view === "commits" && upd.pending?.commits?.length ? ( +
    +
    + + {upd.pending.count} commit{upd.pending.count === 1 ? "" : "s"} behind main + + {upd.installedVersion && ( + (installed v{upd.installedVersion}) + )} + {upd.release && upd.release.semver && ( + + View release notes (v{upd.release.semver}) + + + )} +
    +

    + No tagged release covers these commits — updating pulls only these changes on + top of v{upd.installedVersion ?? "your install"}; the full release changelog + doesn't apply here. +

    + +
    + ) : upd && !loading && upd.release ? ( +
    + {upd.pending?.commits?.length ? ( +

    + This update also includes {upd.pending.count} commit + {upd.pending.count === 1 ? "" : "s"} on top of the release below. +

    + ) : null} +
    + + {targetName} + + {target?.currentVersion && ( + + (installed v{target.currentVersion}) + + )} + {upd.release.publishedAt && ( + + {new Date(upd.release.publishedAt).toLocaleDateString()} + + )} + + View on GitHub + + +
    +
    + {upd.release.body ? ( + + ) : ( +

    No release notes provided for this version.

    + )} +
    +
    + ) : upd && !loading ? ( +
    +

    + Couldn't determine what this update contains. You can still update. +

    +

    + {upd.releaseError ?? "No release or commit information available."} +

    +
    + ) : null} +
    + +
    +

    + Runs hermes update on{" "} + {target?.sparkName} via SSH. +

    +
    + + +
    +
    +
    +
    , + document.body + ); +} diff --git a/src/components/SparkPage/LlmDailyChart.tsx b/src/components/SparkPage/LlmDailyChart.tsx new file mode 100644 index 00000000..0b0437f4 --- /dev/null +++ b/src/components/SparkPage/LlmDailyChart.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { fetchLlmDaily } from "../../api/client"; +import type { LlmDailyDay } from "../../api/types"; + +const CHART_W = 196; +const CHART_H = 36; +const POLL_MS = 60_000; + +function fmt(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "—"; + return n >= 100 ? n.toFixed(0) : n.toFixed(1); +} + +export function LlmDailyChart({ + sparkId, + llmPort, +}: { + sparkId: string; + llmPort: number; +}) { + const [days, setDays] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = () => { + fetchLlmDaily(sparkId, llmPort, 14) + .then((res) => { + if (!cancelled) setDays(res.days || []); + }) + .catch(() => { + if (!cancelled) setDays([]); + }); + }; + load(); + const t = setInterval(load, POLL_MS); + return () => { + cancelled = true; + clearInterval(t); + }; + }, [sparkId, llmPort]); + + if (!days || days.length === 0) return null; + + const hasSplit = days.some((d) => d.uncachedPrefillMax != null); + const decodeVals = days.map((d) => d.decodeMax || 0); + const prefillVals = days.map((d) => + hasSplit ? d.uncachedPrefillMax || 0 : d.prefillMax || 0 + ); + const max = Math.max(1, ...decodeVals, ...prefillVals); + const n = days.length; + const gap = 1.5; + const slot = CHART_W / n; + const barW = Math.max(1.5, (slot - gap) / 2); + + const busy = days.some( + (d) => + (d.decodeMax || 0) > 0 || + (d.prefillMax || 0) > 0 || + (d.uncachedPrefillMax || 0) > 0 + ); + + return ( +
    +
    + + Daily peak tok/s + + + {hasSplit ? "decode · uncached prefill" : "decode · prefill"} · 14d + +
    + {!busy ? ( +

    No busy samples in the last 14 days.

    + ) : ( + + {days.map((d, i) => { + const x0 = i * slot; + const decH = ((d.decodeMax || 0) / max) * (CHART_H - 2); + const pref = hasSplit ? d.uncachedPrefillMax || 0 : d.prefillMax || 0; + const prefH = (pref / max) * (CHART_H - 2); + const title = [ + d.date, + `decode peak ${fmt(d.decodeMax)} (avg ${fmt(d.decodeAvg)})`, + hasSplit + ? `uncached prefill peak ${fmt(d.uncachedPrefillMax)} (avg ${fmt(d.uncachedPrefillAvg)})` + : `prefill peak ${fmt(d.prefillMax)} (avg ${fmt(d.prefillAvg)})`, + hasSplit + ? `cached prefill peak ${fmt(d.cachedPrefillMax)} (avg ${fmt(d.cachedPrefillAvg)})` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + {title} + + + + ); + })} + + )} +
    + ); +} diff --git a/src/components/SparkPage/LlmPanel.tsx b/src/components/SparkPage/LlmPanel.tsx index 22011bc5..f6230b29 100644 --- a/src/components/SparkPage/LlmPanel.tsx +++ b/src/components/SparkPage/LlmPanel.tsx @@ -1,11 +1,19 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import type { LlmMetrics } from "../../api/types"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import type { LlmMetrics, LlmBenchTarget } from "../../api/types"; import { setLlmApiKey, updateLlmPort, updateLlmPorts } from "../../api/client"; import { Sparkline } from "../ui/Sparkline"; import { Panel } from "../ui/Panel"; import { BotIcon, GearIcon, InfoIcon } from "../ui/icons"; -import { useMetricsHistoryTail } from "../../hooks/metricsStore"; +import { + useMetricsHistory, + useMetricsHistoryTail, + avgPositive, +} from "../../hooks/metricsStore"; import { BenchmarkDialog } from "./BenchmarkDialog"; +import { PrefillBenchDialog } from "./PrefillBenchDialog"; +import { LlmDailyChart } from "./LlmDailyChart"; +import { parseLlmTargetInput } from "../../shared/llmTarget.js"; +import { LlmTrendChart } from "./LlmTrendChart"; interface LlmPanelProps { llm: LlmMetrics | null; @@ -36,6 +44,227 @@ const VLLM_METRIC_INFO = { "Lifetime speculative / MTP acceptance rate (accepted draft tokens ÷ drafted tokens). Higher means speculative decoding is paying off; — when speculation is off or unused.", } as const; +const LAUNCHER_BTN = + "rounded border border-border bg-surface-elevated px-3 py-1.5 text-xs font-medium text-text transition-colors hover:border-accent hover:bg-accent-soft"; +const REMOTE_STORAGE_KEY = "sparkdash.remote-bench-target"; + +function readStoredRemote(): { host: string; port: string; tls: boolean } { + try { + const raw = localStorage.getItem(REMOTE_STORAGE_KEY); + if (!raw) return { host: "", port: "443", tls: true }; + const v = JSON.parse(raw) as { host?: string; port?: number; tls?: boolean }; + return { + host: typeof v.host === "string" ? v.host : "", + port: v.port != null ? String(v.port) : "443", + tls: v.tls !== false, + }; + } catch { + return { host: "", port: "443", tls: true }; + } +} + +/** Decode / prefill / Showcase launchers — shown even when the live probe is empty + * (remote loopback-bound servers can still be benched via SSH tunnel). */ +function LlmLaunchers({ + sparkId, + llmPort, + modelId, + onDecode, + onPrefill, + onRemoteDecode, + onRemotePrefill, +}: { + sparkId: string; + llmPort: number; + modelId?: string | null; + onDecode: () => void; + onPrefill: () => void; + onRemoteDecode: (target: LlmBenchTarget) => void; + onRemotePrefill: (target: LlmBenchTarget) => void; +}) { + const [remoteOpen, setRemoteOpen] = useState(false); + const [hostDraft, setHostDraft] = useState(() => readStoredRemote().host); + const [portDraft, setPortDraft] = useState(() => readStoredRemote().port); + const [tls, setTls] = useState(() => readStoredRemote().tls); + const [remoteError, setRemoteError] = useState(null); + + const persist = (t: LlmBenchTarget) => { + try { + localStorage.setItem(REMOTE_STORAGE_KEY, JSON.stringify(t)); + } catch { + /* ignore */ + } + }; + + const applyHostBlur = () => { + if (!hostDraft.trim()) return; + try { + const p = parseLlmTargetInput(hostDraft, portDraft, tls); + setHostDraft(p.host); + setPortDraft(String(p.port)); + setTls(p.tls); + setRemoteError(null); + } catch { + /* leave as typed until Run */ + } + }; + + const launchRemote = (kind: "decode" | "prefill") => { + try { + const p = parseLlmTargetInput(hostDraft, portDraft, tls); + persist(p); + setHostDraft(p.host); + setPortDraft(String(p.port)); + setTls(p.tls); + setRemoteError(null); + if (kind === "decode") onRemoteDecode(p); + else onRemotePrefill(p); + } catch (err: unknown) { + setRemoteError(err instanceof Error ? err.message : String(err)); + } + }; + + return ( +
    +
    + + +
    +
    + + +
    + {remoteOpen && ( +
    +

    + On-demand endpoint. Paste a URL or type host + port — nothing is probed until you run. +

    + +
    + + +
    + {remoteError &&

    {remoteError}

    } +
    + + +
    +
    + )} + +
    + ); +} + /** Backend badge — neutral surfaces with a single accent dot. No blue/purple. */ function BackendBadge({ backend }: { backend: string | null }) { if (!backend) return No backend; @@ -45,6 +274,7 @@ function BackendBadge({ backend }: { backend: string | null }) { "llama.cpp": "llama.cpp", sglang: "sgLang", ds4: "ds4", + exl3: "EXL3", }; return ( @@ -156,6 +386,19 @@ export function LlmPanel({ }: LlmPanelProps) { // Tail keyed by port so multi-port LLM sparklines stay distinct (8b). const genHistory = useMetricsHistoryTail(sparkId, `llm:${llmPort}.tps`); + const prefillHistory = useMetricsHistoryTail(sparkId, `llm:${llmPort}.prefill`); + const cachedPrefillHistory = useMetricsHistoryTail(sparkId, `llm:${llmPort}.prefillCached`); + const uncachedPrefillHistory = useMetricsHistoryTail(sparkId, `llm:${llmPort}.prefillUncached`); + + // Full series (~1 h) for running averages over busy (>0) samples only. + const genFull = useMetricsHistory(sparkId, `llm:${llmPort}.tps`); + const prefillFull = useMetricsHistory(sparkId, `llm:${llmPort}.prefill`); + const cachedFull = useMetricsHistory(sparkId, `llm:${llmPort}.prefillCached`); + const uncachedFull = useMetricsHistory(sparkId, `llm:${llmPort}.prefillUncached`); + const genAvg = useMemo(() => avgPositive(genFull), [genFull]); + const prefillAvg = useMemo(() => avgPositive(prefillFull), [prefillFull]); + const cachedPrefillAvg = useMemo(() => avgPositive(cachedFull), [cachedFull]); + const uncachedPrefillAvg = useMemo(() => avgPositive(uncachedFull), [uncachedFull]); const [showSettings, setShowSettings] = useState(false); const [portDraft, setPortDraft] = useState(String(llmPort)); const [apiKeyDraft, setApiKeyDraft] = useState(""); @@ -164,6 +407,24 @@ export function LlmPanel({ const [saveError, setSaveError] = useState(null); const [engineInfoOpen, setEngineInfoOpen] = useState(false); const [benchOpen, setBenchOpen] = useState(false); + const [prefillBenchOpen, setPrefillBenchOpen] = useState(false); + const [remoteTarget, setRemoteTarget] = useState(null); + const openRemoteDecode = useCallback((target: LlmBenchTarget) => { + setRemoteTarget(target); + setBenchOpen(true); + }, []); + const openRemotePrefill = useCallback((target: LlmBenchTarget) => { + setRemoteTarget(target); + setPrefillBenchOpen(true); + }, []); + const openLocalDecode = useCallback(() => { + setRemoteTarget(null); + setBenchOpen(true); + }, []); + const openLocalPrefill = useCallback(() => { + setRemoteTarget(null); + setPrefillBenchOpen(true); + }, []); /** Which vLLM metric info tip is open (kvCache | requests | ttftP95 | preempts). */ const [metricInfoId, setMetricInfoId] = useState(null); const engineInfoTimer = useRef | null>(null); @@ -181,6 +442,10 @@ export function LlmPanel({ }, [clearEngineInfoTimer]); const generationTps = llm?.generationTps ?? 0; + const prefillTps = llm?.prefillTps ?? 0; + const showPrefillSplit = llm?.cachedPrefillTps != null || llm?.uncachedPrefillTps != null; + const cachedPrefillTps = llm?.cachedPrefillTps ?? 0; + const uncachedPrefillTps = llm?.uncachedPrefillTps ?? 0; const available = llm?.available ?? false; // Keep draft in sync when server pushes a different port (other tab / reload) @@ -292,7 +557,7 @@ export function LlmPanel({ {showSettings ? (

    - HTTP port of the LLM server on this Spark (vLLM / llama.cpp / sglang / ds4 / OpenAI-compatible gateway). + HTTP port of the LLM server on this Spark (vLLM / llama.cpp / sglang / ds4 / EXL3 / OpenAI-compatible gateway).

    -
    - -
    + +
    ) : (
    @@ -443,11 +699,82 @@ export function LlmPanel({ Generation tok/s
    - - {generationTps.toFixed(1)} - +
    +
    + {generationTps.toFixed(1)} +
    + {genAvg != null && ( +
    + avg {genAvg >= 100 ? genAvg.toFixed(0) : genAvg.toFixed(1)} +
    + )} +
    +
    +
    +
    + Prefill tok/s +
    + +
    +
    + {prefillTps.toFixed(1)} +
    + {prefillAvg != null && ( +
    + avg {prefillAvg >= 100 ? prefillAvg.toFixed(0) : prefillAvg.toFixed(1)} +
    + )} +
    + {showPrefillSplit && ( + <> +
    + Cached prefill tok/s +
    + +
    +
    + {cachedPrefillTps.toFixed(1)} +
    + {cachedPrefillAvg != null && ( +
    + avg {cachedPrefillAvg >= 100 ? cachedPrefillAvg.toFixed(0) : cachedPrefillAvg.toFixed(1)} +
    + )} +
    +
    +
    +
    + Uncached prefill tok/s +
    + +
    +
    + {uncachedPrefillTps.toFixed(1)} +
    + {uncachedPrefillAvg != null && ( +
    + avg {uncachedPrefillAvg >= 100 ? uncachedPrefillAvg.toFixed(0) : uncachedPrefillAvg.toFixed(1)} +
    + )} +
    +
    +
    + + )} + + +
    @@ -655,32 +982,15 @@ export function LlmPanel({
    )} -
    - - -
    +
    )} @@ -689,8 +999,18 @@ export function LlmPanel({ onClose={() => setBenchOpen(false)} sparkId={sparkId} llmPort={llmPort} - modelId={llm?.modelId ?? null} + modelId={remoteTarget ? null : llm?.modelId ?? null} + remoteTarget={remoteTarget} + /> + setPrefillBenchOpen(false)} + sparkId={sparkId} + llmPort={llmPort} + modelId={remoteTarget ? null : llm?.modelId ?? null} + contextLength={remoteTarget ? null : llm?.contextLength ?? null} + remoteTarget={remoteTarget} /> ); -} \ No newline at end of file +} diff --git a/src/components/SparkPage/LlmTrendChart.tsx b/src/components/SparkPage/LlmTrendChart.tsx new file mode 100644 index 00000000..dbc19598 --- /dev/null +++ b/src/components/SparkPage/LlmTrendChart.tsx @@ -0,0 +1,171 @@ +import { useMemo } from "react"; +import { HISTORY_MAX, useMetricsHistory, avgPositive } from "../../hooks/metricsStore"; + +const VIEW_W = 300; +const VIEW_H = 64; +const PAD = 2; +/** + * Fixed display window: 30 minutes of 2 s samples. The x-axis is anchored to + * this constant — never the current sample count — so the line grows into the + * chart left-to-right and then scrolls, instead of re-stretching (rewriting + * history) on every tick. Averages below still span full HISTORY_MAX retention. + */ +const DISPLAY_WINDOW = 900; + +function fmt(n: number | null): string { + if (n == null || !Number.isFinite(n)) return "—"; + return n >= 100 ? n.toFixed(0) : n.toFixed(1); +} + +/** + * Polyline points for one series, normalised to the shared max. x maps onto a + * FIXED window: the newest sample sits at the right edge once the window is + * full; while filling, points occupy only the left fraction and the line grows. + */ +function buildPoints(raw: readonly number[], max: number): string { + // Only the newest DISPLAY_WINDOW samples are drawn; older ones still feed + // the averages below. Once full, the window scrolls (newest at right edge). + const data = raw.length > DISPLAY_WINDOW ? raw.slice(-DISPLAY_WINDOW) : raw; + if (data.length < 2) return ""; + const span = max || 1; + const pts = data.map((v, i) => { + const x = (i / (DISPLAY_WINDOW - 1)) * VIEW_W; + const y = VIEW_H - PAD - (Math.min(v, max) / span) * (VIEW_H - PAD * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + return pts.join(" "); +} + +function areaPath(points: string): string { + const seg = points.split(" "); + const first = seg[0]?.split(",")[0] ?? "0"; + const last = seg[seg.length - 1]?.split(",")[0] ?? first; + return `M${first},${VIEW_H} L${points} L${last},${VIEW_H} Z`; +} + +/** Human label: chart shows the last window; averages span full retention. */ +function fmtSpan(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + const h = seconds / 3600; + return `${h % 1 === 0 ? h : h.toFixed(1)}h`; +} + +function historyLabel(): string { + return `chart ~${fmtSpan(DISPLAY_WINDOW * 2)} · avgs ~${fmtSpan(HISTORY_MAX * 2)} · 2s samples`; +} + +/** Newest DISPLAY_WINDOW samples — the slice the chart draws. */ +function windowed(data: readonly number[]): readonly number[] { + return data.length > DISPLAY_WINDOW ? data.slice(-DISPLAY_WINDOW) : data; +} + +/** + * tok/s trend chart for one LLM port. The x-axis is a FIXED 30-minute window: + * the line grows left-to-right while filling, then scrolls — history already + * drawn never re-stretches, so the chart can't "rewrite" its own past. The + * averages below span the full retention (VITE_HISTORY_HOURS, default 8 h). + * + * TTFT is deliberately NOT drawn here: vLLM reports it only while serving, so + * the series is sparse and not tick-aligned — overlaying it on this chart would + * misplace it in time. It is also near-redundant with the prefill spikes it + * tracks. The busy-sample TTFT average badge is the useful signal and reads the + * sparse series directly (no x-axis involved). + */ +export function LlmTrendChart({ + sparkId, + llmPort, +}: { + sparkId: string; + llmPort: number; +}) { + const gen = useMetricsHistory(sparkId, `llm:${llmPort}.tps`); + const prefill = useMetricsHistory(sparkId, `llm:${llmPort}.prefill`); + const ttft = useMetricsHistory(sparkId, `llm:${llmPort}.ttft`); + + const genAvg = useMemo(() => avgPositive(gen), [gen]); + const prefillAvg = useMemo(() => avgPositive(prefill), [prefill]); + const ttftAvg = useMemo(() => avgPositive(ttft), [ttft]); + + // Chart draws only the newest hour; averages above use the full series. + const genWin = useMemo(() => windowed(gen), [gen]); + const prefillWin = useMemo(() => windowed(prefill), [prefill]); + + // Normalise each series to its OWN max: prefill (thousands) and generation + // (tens) differ by ~100x, so a shared scale would flatten gen into the floor. + // Max is over the drawn window so old spikes can't squash recent detail. + const genMax = useMemo(() => Math.max(1, ...genWin), [genWin]); + const prefillMax = useMemo(() => Math.max(1, ...prefillWin), [prefillWin]); + const genPts = useMemo(() => buildPoints(genWin, genMax), [genWin, genMax]); + const prefillPts = useMemo(() => buildPoints(prefillWin, prefillMax), [prefillWin, prefillMax]); + + const hasData = genWin.length > 1 || prefillWin.length > 1; + + return ( +
    +
    + + tok/s history + + {historyLabel()} +
    + {!hasData ? ( +

    No samples yet.

    + ) : ( + + {prefillPts && ( + <> + + + + )} + {genPts && ( + <> + + + + )} + + )} +
    + + Gen avg{" "} + {fmt(genAvg)} + + + Prefill avg{" "} + {fmt(prefillAvg)} + + + TTFT avg{" "} + + {ttftAvg != null ? `${ttftAvg.toFixed(3)}s` : "—"} + + + avg over busy samples only +
    +
    + ); +} diff --git a/src/components/SparkPage/PrefillBenchDialog.tsx b/src/components/SparkPage/PrefillBenchDialog.tsx new file mode 100644 index 00000000..7b5c9529 --- /dev/null +++ b/src/components/SparkPage/PrefillBenchDialog.tsx @@ -0,0 +1,606 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + cancelPrefillBench, + clearPrefillBenchHistory, + getPrefillBench, + listPrefillBench, + startPrefillBench, +} from "../../api/client"; +import type { PrefillBenchJob, LlmBenchTarget } from "../../api/types"; +import { useModalPresence } from "../../hooks/useModalPresence"; +import { + PREFILL_CONTEXT_SIZES, + PREFILL_DEFAULT_CONTEXT_SIZES, + formatContextSize, +} from "../../shared/prefillBench.js"; +import { formatLlmBaseUrl } from "../../shared/llmTarget.js"; + +interface PrefillBenchDialogProps { + open: boolean; + onClose: () => void; + sparkId: string; + llmPort: number; + modelId: string | null; + contextLength: number | null; + remoteTarget?: LlmBenchTarget | null; +} + +function useEscape(onClose: () => void, enabled: boolean) { + useEffect(() => { + if (!enabled) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose, enabled]); +} + +function useBodyScrollLock(locked: boolean) { + useEffect(() => { + if (!locked) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [locked]); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)} s`; + const m = Math.floor(s / 60); + const rem = s - m * 60; + return `${m}m ${rem.toFixed(0)}s`; +} + +function statusLabel(status: PrefillBenchJob["status"]): string { + switch (status) { + case "running": + return "Running"; + case "completed": + return "Completed"; + case "failed": + return "Failed"; + case "cancelled": + return "Cancelled"; + default: + return status; + } +} + +function formatTtft(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${Math.round(ms)}ms`; +} + +function defaultSelected(contextLength: number | null): number[] { + const cap = + contextLength != null && contextLength > 0 ? contextLength : Number.POSITIVE_INFINITY; + const fitted = PREFILL_DEFAULT_CONTEXT_SIZES.filter((n: number) => n <= cap); + if (fitted.length) return [...fitted]; + const allowed = PREFILL_CONTEXT_SIZES.filter((n: number) => n <= cap); + return allowed.length ? [allowed[allowed.length - 1]] : [PREFILL_CONTEXT_SIZES[0]]; +} + +function buildShareText(job: PrefillBenchJob, modelId: string | null): string { + const name = modelId || job.config?.modelId || "unknown model"; + const head = `${name} | prefill tok/s results:`; + const lines = job.results.map((r) => { + const label = formatContextSize(r.targetTokens); + if (r.error && r.prefillTps <= 0) { + return `${label} failed${r.error ? ` — ${r.error}` : ""}`; + } + const actual = + r.promptTokens > 0 ? `${r.promptTokens} tok` : `${r.targetTokens} tok`; + return `${label} ${r.prefillTps.toFixed(1)} tok/s · TTFT ${formatTtft(r.ttftMs)} · ${actual}`; + }); + return [head, "", ...lines].join("\n"); +} + +function ResultRow({ r }: { r: PrefillBenchJob["results"][number] }) { + return ( +
    +
    + {formatContextSize(r.targetTokens)} +
    + + TTFT {formatTtft(r.ttftMs)} + + + · + + + {r.promptTokens > 0 ? r.promptTokens.toLocaleString() : "—"}{" "} + tokens + +
    +
    + +
    +
    + Prefill + + {r.prefillTps.toFixed(1)} + tok/s + +
    +
    + TTFT + + {formatTtft(r.ttftMs)} + +
    +
    +
    + ); +} + +export function PrefillBenchDialog({ + open, + onClose, + sparkId, + llmPort, + modelId, + contextLength, + remoteTarget = null, +}: PrefillBenchDialogProps) { + const [selected, setSelected] = useState(() => defaultSelected(contextLength)); + const [job, setJob] = useState(null); + const [error, setError] = useState(null); + const [starting, setStarting] = useState(false); + const [loadingLast, setLoadingLast] = useState(false); + const [copied, setCopied] = useState(false); + const pollRef = useRef | null>(null); + const copyResetRef = useRef | null>(null); + const benchPort = remoteTarget?.port ?? llmPort; + + const stopPoll = useCallback(() => { + if (pollRef.current != null) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const isRunning = job?.status === "running"; + const { mounted, visible } = useModalPresence(open); + + useEscape(onClose, open && !starting); + useBodyScrollLock(mounted); + + const startPolling = useCallback( + (benchId: string) => { + stopPoll(); + pollRef.current = setInterval(() => { + void getPrefillBench(sparkId, benchId) + .then((j) => { + setJob(j); + setError(null); + if (j.status !== "running") stopPoll(); + }) + .catch((err: Error) => { + void listPrefillBench(sparkId, benchPort) + .then((data) => { + if (data.active) { + setJob(data.active); + setError(null); + if (data.active.benchId !== benchId) { + startPolling(data.active.benchId); + } else if (data.active.status !== "running") { + stopPoll(); + } + return; + } + const recovered = + data.history?.find((j) => j.benchId === benchId) || + (data.last?.benchId === benchId ? data.last : null); + if (recovered) { + setJob(recovered); + setError(null); + stopPoll(); + return; + } + setError( + err.message === "Benchmark not found" + ? "Benchmark interrupted — server restarted during the run" + : err.message + ); + stopPoll(); + }) + .catch(() => { + setError( + err.message === "Benchmark not found" + ? "Benchmark interrupted — server restarted during the run" + : err.message + ); + stopPoll(); + }); + }); + }, 800); + }, + [sparkId, benchPort, stopPoll] + ); + + useEffect(() => { + if (!open) { + stopPoll(); + return; + } + let cancelled = false; + setLoadingLast(true); + setError(null); + void listPrefillBench(sparkId, benchPort) + .then((data) => { + if (cancelled) return; + if (data.active) { + setJob(data.active); + if (Array.isArray(data.active.config?.contextSizes)) { + setSelected( + data.active.config.contextSizes.filter( + (n) => contextLength == null || contextLength <= 0 || n <= contextLength + ) + ); + } + if (data.active.status === "running") startPolling(data.active.benchId); + } else if (data.last) { + setJob(data.last); + if (Array.isArray(data.last.config?.contextSizes)) { + const next = data.last.config.contextSizes.filter( + (n) => contextLength == null || contextLength <= 0 || n <= contextLength + ); + setSelected(next.length ? next : defaultSelected(contextLength)); + } + } else { + setJob(null); + setSelected(defaultSelected(contextLength)); + } + }) + .catch((err: Error) => { + if (!cancelled) setError(err.message); + }) + .finally(() => { + if (!cancelled) setLoadingLast(false); + }); + return () => { + cancelled = true; + }; + }, [open, sparkId, benchPort, contextLength, startPolling, stopPoll]); + + useEffect(() => () => stopPoll(), [stopPoll]); + useEffect( + () => () => { + if (copyResetRef.current != null) clearTimeout(copyResetRef.current); + }, + [] + ); + + const sizeFits = (n: number) => + contextLength == null || contextLength <= 0 || n <= contextLength; + + const toggleSize = (n: number) => { + if (isRunning || starting || !sizeFits(n)) return; + setSelected((prev) => { + if (prev.includes(n)) { + if (prev.length === 1) return prev; + return prev.filter((x) => x !== n).sort((a, b) => a - b); + } + return [...prev, n].sort((a, b) => a - b); + }); + }; + + const handleStart = async () => { + const sizes = selected.filter(sizeFits); + if (sizes.length === 0) { + setError("Select at least one context size that fits this model"); + return; + } + setStarting(true); + setError(null); + setJob(null); + try { + const started = await startPrefillBench(sparkId, { + port: benchPort, + contextSizes: sizes, + modelId: modelId || undefined, + ...(remoteTarget + ? { host: remoteTarget.host, tls: remoteTarget.tls } + : {}), + }); + setJob(started); + startPolling(started.benchId); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setStarting(false); + } + }; + + const handleCancel = async () => { + if (!job || job.status !== "running") return; + try { + const j = await cancelPrefillBench(sparkId, job.benchId); + setJob(j); + startPolling(job.benchId); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const handleNewRun = () => { + stopPoll(); + setJob(null); + setError(null); + }; + + const handleCopyResults = async () => { + if (!job || job.results.length === 0) return; + const text = buildShareText(job, modelId); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + setCopied(true); + if (copyResetRef.current != null) clearTimeout(copyResetRef.current); + copyResetRef.current = setTimeout(() => setCopied(false), 1800); + } catch { + setError("Could not copy results to clipboard"); + } + }; + + const handleClear = async () => { + if (!job || job.status === "running") return; + setError(null); + try { + await clearPrefillBenchHistory(sparkId, benchPort); + stopPoll(); + setJob(null); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + if (!mounted) return null; + + const progressPct = + job && job.progress.totalLevels > 0 + ? Math.round( + ((job.progress.completedLevels + (job.status === "running" ? 0.35 : 0)) / + job.progress.totalLevels) * + 100 + ) + : 0; + + const showConfig = (!job || job.status === "running") && !loadingLast; + const showResults = job && job.status !== "running"; + const ctxHint = + contextLength != null && contextLength > 0 + ? `Model context ${formatContextSize(contextLength)} — larger sizes are disabled.` + : "Unique-prefix prompts; TTFT is time to first token. 128k–300k can take tens of minutes."; + + const dialog = ( +
    + + + +
    + {loadingLast && !job && ( +

    Loading last results…

    + )} + + {showConfig && ( +
    +
    +
    +

    Context size

    +

    {ctxHint}

    +
    +
    + {PREFILL_CONTEXT_SIZES.map((n: number) => { + const on = selected.includes(n); + const fits = sizeFits(n); + return ( + + ); + })} +
    +
    +
    + )} + + {error &&

    {error}

    } + + {job && job.status === "running" && ( +
    +
    +
    + + Running + {job.progress.currentContext != null + ? ` · ${formatContextSize(job.progress.currentContext)}` + : ""} + + + {job.progress.completedLevels}/{job.progress.totalLevels} + {job.durationMs != null ? ` · ${formatDuration(job.durationMs)}` : ""} + +
    +
    +
    +
    + {job.progress.message ? ( +

    {job.progress.message}

    + ) : null} +
    + {job.results.length > 0 && ( +
    +
    Completed sizes
    + {job.results.map((r) => ( + + ))} +
    + )} +
    + )} + + {showResults && ( +
    +
    + + {statusLabel(job.status)} + + + {job.config.contextSizes.map(formatContextSize).join(", ")} + {job.durationMs != null ? ` · ${formatDuration(job.durationMs)}` : ""} + +
    + + {job.error &&

    {job.error}

    } + + {job.results.length > 0 && ( +
    + + {job.results.map((r) => ( + + ))} +
    + )} + + {job.results.length > 0 && ( +

    + Prefill — prompt tokens ÷ time to first token.{" "} + TTFT — request start to first streamed token. Each size + uses a unique prefix so prefix-cache does not inflate later sizes. +

    + )} +
    + )} +
    + +
    + {job?.status === "running" ? ( + + ) : job ? ( + <> + {job.results.length > 0 && ( + + )} + {job.results.length > 0 && ( + + )} + + + + ) : ( + <> + + + + )} +
    +
    +
    + ); + + return createPortal(dialog, document.body); +} diff --git a/src/components/SparkPage/RamPanel.tsx b/src/components/SparkPage/RamPanel.tsx new file mode 100644 index 00000000..c77afbcd --- /dev/null +++ b/src/components/SparkPage/RamPanel.tsx @@ -0,0 +1,97 @@ +import type { CpuMetrics, RamMetrics } from "../../api/types"; +import { Sparkline } from "../ui/Sparkline"; +import { Panel } from "../ui/Panel"; +import { MemoryIcon } from "../ui/icons"; +import { MetricBar } from "../ui/MetricBar"; +import { useMetricsHistoryTail } from "../../hooks/metricsStore"; + +interface RamPanelProps { + ram: RamMetrics | null; + cpu: CpuMetrics | null; + sparkId: string; + temperatureUnit: "celsius" | "fahrenheit"; + className?: string; +} + +function formatMb(mb: number): string { + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${Math.round(mb)} MB`; +} + +function celsiusToFahrenheit(c: number): number { + return Math.round((c * 9) / 5 + 32); +} + +/** + * System RAM panel — shown for non-Spark GPU hosts, where RAM (system memory) + * and VRAM (discrete GPU memory) are separate things. CPU temperature lives + * here for hosts; Spark pages show it on the GPU panel. + */ +export function RamPanel({ ram, cpu, sparkId, temperatureUnit, className }: RamPanelProps) { + const history = useMetricsHistoryTail(sparkId, "ram.percentage"); + const tempHistory = useMetricsHistoryTail(sparkId, "cpu.temp"); + const used = ram?.used ?? 0; + const total = ram?.total ?? 0; + const percentage = ram?.percentage ?? 0; + + const temperature = cpu?.temperature ?? 0; + const displayTemp = + temperatureUnit === "fahrenheit" ? celsiusToFahrenheit(temperature) : temperature; + const tempLabel = temperatureUnit === "fahrenheit" ? `${displayTemp}°F` : `${displayTemp}°C`; + // Generic CPU junction-style bands (not GB10 GPU 65/85 — idle x86 often sits ~50–70). + const tempColor = + temperature > 95 + ? "var(--color-danger)" + : temperature > 85 + ? "var(--color-warning)" + : "var(--color-accent)"; + + return ( + } + className={`panel-ram ${className ?? ""}`} + bodyClassName="space-y-3" + > + {total > 0 ? ( + <> + 0 + ? `${formatMb(used).replace(/ (GB|MB)$/, "")} / ${formatMb(total)}` + : "—" + } + /> + {history.length > 0 && ( +
    + Usage +
    + + {percentage}% +
    +
    + )} + + ) : ( +
    + RAM + +
    + )} + {temperature > 0 && ( +
    + CPU +
    + + + + {tempLabel} +
    +
    + )} +
    + ); +} diff --git a/src/components/SparkPage/SparkActions.tsx b/src/components/SparkPage/SparkActions.tsx new file mode 100644 index 00000000..35efc1db --- /dev/null +++ b/src/components/SparkPage/SparkActions.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import type { SparkSnapshot } from "../../api/types"; +import { shutdownSpark, wakeSpark } from "../../api/client"; +import { ConfirmShutdownDialog } from "../ConfirmShutdownDialog"; +import { openHermesUpdateDialog } from "../../hooks/useHermesUpdateDialog"; +import { EditIcon, PowerOffIcon, PowerOnIcon, RotateIcon } from "../ui/icons"; + +interface SparkActionsProps { + spark: SparkSnapshot; + onEdit?: () => void; + /** Classes for the button-cluster wrapper (controls responsive visibility). */ + className?: string; +} + +/** + * Update Hermes / Shutdown·Wake / Edit action cluster. + * Rendered twice: inline in the SparkHeader (desktop) and as a standalone row + * just above "Resources" on mobile. Owning the shutdown dialog + transient + * power message here keeps the two placements in sync. + */ +export function SparkActions({ spark, onEdit, className }: SparkActionsProps) { + const online = spark.online; + const [powerLoading, setPowerLoading] = useState(false); + const [powerMsg, setPowerMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null); + const [shutdownOpen, setShutdownOpen] = useState(false); + + const hermes = spark.hermes; + const hermesRunning = hermes?.status === "running"; + + function handleHermesUpdate() { + openHermesUpdateDialog({ + sparkId: spark.id, + sparkName: spark.name, + currentVersion: hermes?.version ?? null, + }); + } + + async function handleShutdown() { + setPowerLoading(true); + setPowerMsg(null); + try { + const res = await shutdownSpark(spark.id); + setPowerMsg({ text: res.message || "Shutdown initiated", tone: "ok" }); + } catch (err: unknown) { + setPowerMsg({ + text: err instanceof Error ? err.message : "Shutdown failed", + tone: "err", + }); + } finally { + setPowerLoading(false); + setTimeout(() => setPowerMsg(null), 5000); + } + } + + async function handleWake() { + setPowerLoading(true); + setPowerMsg(null); + try { + const res = await wakeSpark(spark.id); + setPowerMsg({ text: res.message || "Wake packet sent", tone: "ok" }); + } catch (err: unknown) { + setPowerMsg({ + text: err instanceof Error ? err.message : "Wake failed", + tone: "err", + }); + } finally { + setPowerLoading(false); + setTimeout(() => setPowerMsg(null), 5000); + } + } + + return ( + <> +
    + {powerMsg && ( + + {powerMsg.text} + + )} + {hermesRunning && ( + + + Hermes updating… + + )} + {!hermesRunning && hermes?.monitoring && hermes.status === "error" && ( + + Hermes update failed + + )} + {!hermesRunning && hermes?.monitoring && hermes.installed !== false && ( + + )} + {online ? ( + + ) : ( + + )} + {onEdit && ( + + )} +
    + + setShutdownOpen(false)} + onConfirm={handleShutdown} + title={`Shut down ${spark.name}`} + description={`Gracefully shut down ${spark.name}? This will stop all containers and power off the node.`} + confirmLabel="Shut down" + /> + + ); +} \ No newline at end of file diff --git a/src/components/SparkPage/SparkHeader.tsx b/src/components/SparkPage/SparkHeader.tsx index fcbf2f19..f8ebc610 100644 --- a/src/components/SparkPage/SparkHeader.tsx +++ b/src/components/SparkPage/SparkHeader.tsx @@ -1,9 +1,6 @@ -import { useState } from "react"; import type { SparkSnapshot } from "../../api/types"; import { resolveSparkRole } from "../../api/sparkRole"; -import { shutdownSpark, wakeSpark } from "../../api/client"; -import { ConfirmShutdownDialog } from "../ConfirmShutdownDialog"; -import { EditIcon, PowerOffIcon, PowerOnIcon } from "../ui/icons"; +import { SparkActions } from "./SparkActions"; interface SparkHeaderProps { spark: SparkSnapshot; @@ -25,43 +22,7 @@ function formatUptime(seconds: number): string { export function SparkHeader({ spark, onEdit }: SparkHeaderProps) { const { hardware } = spark; const online = spark.online; - const [powerLoading, setPowerLoading] = useState(false); - const [powerMsg, setPowerMsg] = useState<{ text: string; tone: "ok" | "err" } | null>(null); - const [shutdownOpen, setShutdownOpen] = useState(false); - - async function handleShutdown() { - setPowerLoading(true); - setPowerMsg(null); - try { - const res = await shutdownSpark(spark.id); - setPowerMsg({ text: res.message || "Shutdown initiated", tone: "ok" }); - } catch (err: unknown) { - setPowerMsg({ - text: err instanceof Error ? err.message : "Shutdown failed", - tone: "err", - }); - } finally { - setPowerLoading(false); - setTimeout(() => setPowerMsg(null), 5000); - } - } - - async function handleWake() { - setPowerLoading(true); - setPowerMsg(null); - try { - const res = await wakeSpark(spark.id); - setPowerMsg({ text: res.message || "Wake packet sent", tone: "ok" }); - } catch (err: unknown) { - setPowerMsg({ - text: err instanceof Error ? err.message : "Wake failed", - tone: "err", - }); - } finally { - setPowerLoading(false); - setTimeout(() => setPowerMsg(null), 5000); - } - } + const hermes = spark.hermes; return (
    )} + {hermes?.monitoring && hermes.installed && hermes.version && ( + + Hermes + + )} + {hermes?.monitoring && hermes.installed === false && hermes.checkedAt != null && ( + + Hermes not found + + )} + {hermes?.monitoring && + hermes.error && + hermes.status === "idle" && ( + + Update check failed + + )}

    - {hardware.device} · {hardware.gpuChip} + {hardware.gpuChip + ? `${hardware.device} · ${hardware.gpuChip}` + : hardware.device}

    -
    - {powerMsg && ( - - {powerMsg.text} - - )} - {online ? ( - - ) : ( - - )} - {onEdit && ( - - )} -
    - - setShutdownOpen(false)} - onConfirm={handleShutdown} - title={`Shut down ${spark.name}`} - description={`Gracefully shut down ${spark.name}? This will stop all containers and power off the node.`} - confirmLabel="Shut down" + {/* Desktop action cluster (hidden on mobile; mobile renders its own row above Resources) */} +
    ); diff --git a/src/components/SparkPage/SparkPage.tsx b/src/components/SparkPage/SparkPage.tsx index 7d09b8ea..6593ab5e 100644 --- a/src/components/SparkPage/SparkPage.tsx +++ b/src/components/SparkPage/SparkPage.tsx @@ -3,10 +3,12 @@ import type { SparkSnapshot } from "../../api/types"; import { isLlmMonitoringEnabled } from "../../api/sparkRole"; import { updateSpark, refreshSparkMetric, addLlmPort, removeLlmPort } from "../../api/client"; import { SparkHeader } from "./SparkHeader"; +import { SparkActions } from "./SparkActions"; import { GpuPanel } from "./GpuPanel"; -import { CpuPanel } from "./CpuPanel"; +import { RamPanel } from "./RamPanel"; import { StoragePanel } from "./StoragePanel"; import { NetworkPanel } from "./NetworkPanel"; +import { TailscalePanel } from "./TailscalePanel"; import { LlmPanel } from "./LlmPanel"; import { ComfyPanel } from "./ComfyPanel"; import { ChevronDownIcon } from "../ui/icons"; @@ -174,6 +176,7 @@ export function SparkPage({ spark, temperatureUnit, onEdit }: SparkPageProps) { const llmOn = isLlmMonitoringEnabled(spark); const comfyOn = Boolean(spark.comfyMonitoring); + const tailscaleOn = Boolean(spark.tailscaleMonitoring); /** First LLM + Comfy share a row when both are on. */ const primarySideBySide = llmOn && comfyOn; const showServices = llmOn || comfyOn; @@ -211,7 +214,13 @@ export function SparkPage({ spark, temperatureUnit, onEdit }: SparkPageProps) { return (
    -
    + {/* Mobile-only action row (Update Hermes / Shutdown·Wake / Edit) — desktop keeps them in the header. */} + +
    {resourcesOpen && ( <> - - - - + {spark.kind === "host" ? ( + /* Hosts: GPU spans the full left column; RAM → Network → Storage [→ Tailnet] stack in the right column */ + <> + + + + + {tailscaleOn && } + + ) : ( + /* Resources layout: GPU spans the full left column; Storage + Network [+ Tailnet] stack in the right column */ + <> + + + + {tailscaleOn && } + + )} )} {/* diff --git a/src/components/SparkPage/TailscalePanel.tsx b/src/components/SparkPage/TailscalePanel.tsx new file mode 100644 index 00000000..9aaf34ad --- /dev/null +++ b/src/components/SparkPage/TailscalePanel.tsx @@ -0,0 +1,93 @@ +import type { TailscaleMetrics } from "../../api/types"; +import { Panel } from "../ui/Panel"; +import { NetworkIcon } from "../ui/icons"; + +interface TailscalePanelProps { + tailscale: TailscaleMetrics | null; +} + +/** + * Tailnet presence for one unit. The failure mode is "healthy on the LAN, + * invisible off it" — every other panel is LAN-fed and looks fine. + */ +export function TailscalePanel({ tailscale }: TailscalePanelProps) { + const online = tailscale?.online ?? null; + const health = tailscale?.health ?? []; + const available = Boolean(tailscale?.available); + const offTailnet = available && online === false; + + const status = !available + ? { label: "unknown", cls: "text-muted" } + : online === true + ? { label: "online", cls: "text-accent" } + : online === false + ? { label: "OFF TAILNET", cls: "text-danger" } + : { label: "unknown", cls: "text-muted" }; + + return ( + }> +
    + Status + {status.label} + {tailscale?.backendState && ( + {tailscale.backendState} + )} +
    + + {health.length > 0 && ( +
    + {health.map((msg) => ( +

    + {msg} +

    + ))} +
    + )} + + {tailscale?.error && ( +

    + {tailscale.error} +

    + )} + +
    + {tailscale?.tailscaleIp && } + {tailscale?.hostName && } + {tailscale?.relay && } + {tailscale?.keyExpired && } + {tailscale?.version && } + {!available && !tailscale?.error && ( +

    Waiting for first poll…

    + )} +
    +
    + ); +} + +function Row({ + label, + value, + tabular, + danger, +}: { + label: string; + value: string; + tabular?: boolean; + danger?: boolean; +}) { + return ( +
    + {label} + + {value} + +
    + ); +} diff --git a/src/components/SparkTabs.tsx b/src/components/SparkTabs.tsx index 5afe4850..cdee7d8c 100644 --- a/src/components/SparkTabs.tsx +++ b/src/components/SparkTabs.tsx @@ -384,8 +384,8 @@ function AddButton({ onAdd }: { onAdd: () => void }) {
    ); diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index 8a450f7a..783a095c 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -22,16 +22,6 @@ function baseProps(className?: string) { }; } -export function CpuIcon({ className }: IconProps) { - return ( - - - - - - ); -} - export function ActivityIcon({ className }: IconProps) { return ( diff --git a/src/hooks/metricsStore.ts b/src/hooks/metricsStore.ts index 8c98caea..345417c4 100644 --- a/src/hooks/metricsStore.ts +++ b/src/hooks/metricsStore.ts @@ -21,7 +21,12 @@ import type { SparkSnapshot } from "../api/types"; * All listeners are woken on notify; unchanged keys keep the same ref → no render. */ -const HISTORY_MAX = 1800; // 1 h at 2 s poll — the WS interval, not wall-clock guarantees +// History depth, configurable at build time. VITE_HISTORY_HOURS = wall-clock +// hours to retain (default 8 h at the 2 s WS poll ≈ 112 KB per series — the +// browser tab is the only thing that pays for it). +const SAMPLES_PER_HOUR = 1800; // 2 s poll +const HISTORY_HOURS = Number(import.meta.env.VITE_HISTORY_HOURS ?? 8) || 8; +export const HISTORY_MAX = Math.round(SAMPLES_PER_HOUR * HISTORY_HOURS); /** Samples shown in inline sparklines (≈1 min at 2 s poll). Full series stays in HISTORY_MAX. */ export const SPARKLINE_TAIL = 30; @@ -33,6 +38,23 @@ const listeners = new Set<() => void>(); const EMPTY: readonly number[] = Object.freeze([] as number[]); +/** + * Mean of a metric series over samples where the reading is > 0, or null when + * there are no busy samples. Skipping zeros keeps idle/off phases from dragging + * the average toward zero (a prefill that runs at 500 tok/s is "500", not 0.5). + */ +export function avgPositive(values: readonly number[]): number | null { + let sum = 0; + let count = 0; + for (const v of values) { + if (v > 0) { + sum += v; + count += 1; + } + } + return count > 0 ? sum / count : null; +} + function notify() { for (const l of listeners) l(); } @@ -95,6 +117,13 @@ export function ingestSnapshots(sparks: SparkSnapshot[]): void { } if (m.cpu) { pushHistory(`${s.id}:cpu.usage`, m.cpu.usage); + // Skip 0°C so a missing sensor does not draw a fake floor on the sparkline. + if (m.cpu.temperature > 0) { + pushHistory(`${s.id}:cpu.temp`, m.cpu.temperature); + } + } + if (m.ram) { + pushHistory(`${s.id}:ram.percentage`, m.ram.percentage); } if (Array.isArray(m.llm)) { // Zip with snapshot.llmPorts so multi-port LLM series key distinctly. @@ -104,6 +133,20 @@ export function ingestSnapshots(sparks: SparkSnapshot[]): void { const port = ports[i]; const portKey = port != null ? `:${port}` : `:${i}`; pushHistory(`${s.id}:llm${portKey}.tps`, llm.generationTps); + pushHistory(`${s.id}:llm${portKey}.prefill`, llm.prefillTps); + // TTFT is sparse: vLLM reports live TTFT only while serving. It is NOT + // index-aligned with the tick-dense series above — that is fine because + // the ttft series feeds only the busy-sample average badge, never the + // overlaid chart (see LlmTrendChart). + if (llm.ttftSeconds != null) { + pushHistory(`${s.id}:llm${portKey}.ttft`, llm.ttftSeconds); + } + if (llm.cachedPrefillTps != null) { + pushHistory(`${s.id}:llm${portKey}.prefillCached`, llm.cachedPrefillTps); + } + if (llm.uncachedPrefillTps != null) { + pushHistory(`${s.id}:llm${portKey}.prefillUncached`, llm.uncachedPrefillTps); + } } } if (m.comfy?.available) { diff --git a/src/hooks/useHermesUpdateDialog.ts b/src/hooks/useHermesUpdateDialog.ts new file mode 100644 index 00000000..abbd3bd2 --- /dev/null +++ b/src/hooks/useHermesUpdateDialog.ts @@ -0,0 +1,47 @@ +import { useSyncExternalStore } from "react"; + +/** + * Single-slot store controlling the Hermes update confirmation dialog. + * Module-global on purpose: the dialog can be opened from anywhere (Spark + * header button, update-alert toast action) without threading props. + */ + +export interface HermesUpdateTarget { + sparkId: string; + sparkName: string; + /** Currently installed hermes version when known (display context only). */ + currentVersion: string | null; +} + +let target: HermesUpdateTarget | null = null; +const listeners = new Set<() => void>(); + +function emit() { + for (const l of listeners) l(); +} + +export function openHermesUpdateDialog(t: HermesUpdateTarget) { + target = t; + emit(); +} + +export function closeHermesUpdateDialog() { + target = null; + emit(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): HermesUpdateTarget | null { + return target; +} + +/** Subscribe a component to the dialog target (null when closed). */ +export function useHermesUpdateDialog(): HermesUpdateTarget | null { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/src/index.css b/src/index.css index 9c0465f2..8d02d7ba 100644 --- a/src/index.css +++ b/src/index.css @@ -703,7 +703,7 @@ input[type="checkbox"] { /* ─── Online chip (overview welcome row) ──────────────── */ .online-chip { - display: inline-flex; + display: none; /* hidden on mobile; shown at sm via media query below */ align-items: center; gap: 6px; padding: 6px 16px; @@ -715,6 +715,11 @@ input[type="checkbox"] { box-shadow: var(--shadow-card); white-space: nowrap; } +@media (min-width: 640px) { + .online-chip { + display: inline-flex; + } +} .online-chip .dot { width: 7px; height: 7px; @@ -977,6 +982,10 @@ input[type="checkbox"] { box-shadow: var(--shadow-shell); } } +.modal-sheet--wide { + max-width: 40rem; +} + .modal-sheet__header { flex-shrink: 0; padding: 0.9rem 1.1rem 0.75rem; @@ -1192,6 +1201,16 @@ input[type="checkbox"] { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.45rem; } +.bench-type-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; +} +.bench-type-grid .bench-conc-btn { + font-variant-numeric: normal; + font-size: 0.75rem; + padding: 0 0.35rem; +} .bench-conc-btn { min-height: 2.25rem; border-radius: 0.5rem; diff --git a/src/shared/llmPrompts.d.ts b/src/shared/llmPrompts.d.ts new file mode 100644 index 00000000..959c9e47 --- /dev/null +++ b/src/shared/llmPrompts.d.ts @@ -0,0 +1,32 @@ +export const TEXT_PROMPTS: string[]; +export const STRUCTURAL_PROMPTS: string[]; +export const FILL_TO_MAX_SUFFIX: string; +export const DECODE_STRUCTURED_PROMPT: string; +export const DECODE_PROSE_PROMPT: string; +export const DECODE_CODE_PROMPT: string; +export const DECODE_JSON_PROMPT: string; +export const DECODE_BENCH_TYPES: readonly ["structured", "prose", "code", "json"]; +export const DECODE_BENCH_DEFAULT_TYPE: "structured"; +export const DECODE_BENCH_PROMPTS: Record; +export const DECODE_BENCH_TYPE_META: { + id: DecodeBenchPromptType; + label: string; + hint: string; +}[]; + +export type DecodeBenchPromptType = "structured" | "prose" | "code" | "json"; + +export function withFillToMaxInstruction(prompt: string): string; + +export function pickShowcasePrompts( + type: "structural" | "text" | "mixed", + count: number +): string[]; + +export function normalizeDecodeBenchType(type: unknown): DecodeBenchPromptType; +export function decodeBenchPromptForType(type: unknown): string; +export function decodeBenchTypeLabel(type: unknown): string; +export function pickDecodeBenchPrompts( + count: number, + type?: unknown +): string[]; diff --git a/src/shared/llmPrompts.js b/src/shared/llmPrompts.js new file mode 100644 index 00000000..587e88f3 --- /dev/null +++ b/src/shared/llmPrompts.js @@ -0,0 +1,206 @@ +/** + * Shared LLM prompt catalogs (Showcase + Decode bench). + * Structural = code/data formats; Text = prose (no code). + */ + +export const TEXT_PROMPTS = [ + "Write a clear essay explaining unified memory on NVIDIA GB10 Sparks for a technical but non-specialist reader. Keep expanding with examples and analogies.", + "Write a vivid sci-fi scene set in a liquid-cooled server room at 3 a.m. Keep expanding the scene with sensory detail and dialogue.", + "Write a pirate-captain monologue explaining KV-cache pressure and prefill vs decode to the crew. Keep expanding with more shanties and metaphors.", + "Write a nursery-rhyme style poem about thermal throttling and power caps. Add many stanzas and keep going.", + "Write naturalistic dialogue between two ops engineers debugging a stuck vLLM queue. Continue for many turns without wrapping up.", + "Write a courtroom cross-examination where the witness is a tokenizer. Keep adding Q&A exchanges.", + "Write a travel-brochure parody for visiting a liquid-cooled GPU rack. Flowery marketing tone; keep expanding sections.", + "Write a radio weather report for a GPU cluster: temperature fronts across racks, token-storm warnings. Keep broadcasting.", + "Write packaging copy for a fictional energy drink called Prefill Punch aimed at LLM operators. Expand with flavors, warnings, and testimonials.", + "Write chapter 1 of a short story titled \"The Day the Slots Went to Zero,\" then keep expanding the narrative without ending.", + "Write a lecture transcript on TTFT, ITL, and e2e latency for inference operators. Keep teaching with more examples.", + "Write a memoir-style recollection of the first time a cluster OOMed mid-demo. Keep expanding with flashbacks and lessons.", + "Write a sports-commentator style play-by-play of concurrent decode waves hitting a 4-GPU node. Keep calling the action.", + "Write a bedtime story for SREs about a friendly KV cache that grew too large. Keep adding chapters.", + "Write an op-ed arguing that tok/s is overrated without TTFT context. Expand with rebuttals and counter-rebuttals.", + "Write a campfire story told by a retired load balancer about the great token flood of '27. Keep going.", +]; + +export const STRUCTURAL_PROMPTS = [ + "Emit only a JSON array of fake GPU metrics rows. Each object needs host, gpuIndex, utilPct, tempC, powerW, memUsedMb. Invent many rows. No markdown. Keep expanding the array.", + "Emit only an HTML FAQ about CUDA and vLLM. Use

    and

    for many Q&A pairs. No markdown fences. Keep adding sections.", + "Emit a Markdown comparison table: llama.cpp vs vLLM vs SGLang. Columns: feature, llama.cpp, vLLM, SGLang. Fill many rows and keep adding.", + "Stream a fake syslog of cluster events (timestamps, INFO/WARN/ERROR, services). Keep lines coming continuously.", + "Write only valid YAML for a multi-service docker-compose stack with redis, postgres, api, and worker. Expand heavily with env, volumes, and healthchecks.", + "Emit a CSV of invented datacenter PUE readings: date,site,pue,itKw,facilityKw. Many rows. CSV only, no commentary. Keep adding rows.", + "Generate a GraphQL schema as SDL only: types Query, Mutation, User, Job, Metric. Add many fields, enums, and interfaces. Keep expanding.", + "Generate an OpenAPI 3 paths snippet as JSON for /v1/models and /v1/chat/completions. Expand schemas heavily. JSON only.", + "Emit a Markdown cheatsheet: nvidia-smi flags vs what they show. Dense table, many rows. Keep adding.", + "Generate a long TOML config for a fictional inference gateway: listeners, routes, retries, budgets. Keep expanding sections.", + "Emit only SQL: CREATE TABLE + many INSERT statements for gpu_jobs(id, host, model, tokens, ms). Keep inserting.", + "Generate a Mermaid sequenceDiagram (fenced) for client → proxy → vLLM → GPU. Expand with retries, queues, and metrics spans.", + "Emit a long alphabetized Markdown definition list glossary of ML-systems jargon (KV cache, TTFT, ITL, MTP, …). Keep adding terms.", + "Generate only Rust-flavored pseudocode for a lock-free token ring buffer. Keep expanding with more functions and tests.", + "Emit a fake Prometheus text exposition dump for showcase_tokens_total, showcase_ttft_seconds, and related series. Keep adding metrics.", + "Emit only a Python module of dataclasses for SparkHost, GpuSlice, and LlmEndpoint with typed fields, validators, and docstrings. Keep expanding.", + "Write only valid JSON (no markdown). Generate OpenAPI-style paths as JSON: paths{}, components.schemas{}. Invent many endpoints and schemas.", + "List 40 shell one-liners useful for NVIDIA Sparks / DGX. Commands only, one per line, no commentary. Then invent more variants.", +]; + +export const FILL_TO_MAX_SUFFIX = + " Continue generating until you hit the maximum output length; do not stop early—keep expanding with more content."; + +/** Same prompt as glm-5.3-flash-sm120 `tests/bench_decode.py --structured`. */ +export const DECODE_STRUCTURED_PROMPT = + "Count from 1 to 200. Output only the numbers, separated by spaces. No other text."; + +/** Same prompt as glm-5.3-flash-sm120 `tests/bench_decode.py` default (hash-map prose). */ +export const DECODE_PROSE_PROMPT = + "Write a detailed step-by-step explanation of how a hash map works, " + + "including collision handling, resizing, and time complexity. Be thorough."; + +/** High-accept code: repeated identical-shape helpers (not an essay with `def`). */ +export const DECODE_CODE_PROMPT = + "Output only Python source code. No comments, no docstrings, no markdown fences. " + + "Write functions clamp_00 through clamp_49. Each function is exactly:\n" + + "def clamp_NN(x, lo=0, hi=1):\n" + + " if x < lo:\n" + + " return lo\n" + + " if x > hi:\n" + + " return hi\n" + + " return x\n" + + "Change only the function name suffix (00, 01, … 49). One blank line between functions. No other text."; + +/** + * JSON/YAML-ish catalog (Showcase structural #0). Labels an output shape only — + * do not send response_format / grammars / guided JSON with this prompt. + */ +export const DECODE_JSON_PROMPT = STRUCTURAL_PROMPTS[0]; + +/** Decode-bench output types (not guided decoding). Structured is the default. */ +export const DECODE_BENCH_TYPES = ["structured", "prose", "code", "json"]; +export const DECODE_BENCH_DEFAULT_TYPE = "structured"; + +export const DECODE_BENCH_PROMPTS = { + structured: DECODE_STRUCTURED_PROMPT, + prose: DECODE_PROSE_PROMPT, + code: DECODE_CODE_PROMPT, + json: DECODE_JSON_PROMPT, +}; + +export const DECODE_BENCH_TYPE_META = [ + { + id: "structured", + label: "Structured", + hint: "Count 1→200, numbers only — lab structured protocol", + }, + { + id: "prose", + label: "Prose", + hint: "Hash-map explanation — lab default bench prompt", + }, + { + id: "code", + label: "Code", + hint: "clamp_00…clamp_49 Python helpers — code-shaped, no comments", + }, + { + id: "json", + label: "JSON", + hint: "JSON GPU-metrics catalog — output type only, not guided JSON", + }, +]; + +/** + * @param {unknown} type + * @returns {"structured" | "prose" | "code" | "json"} + */ +export function normalizeDecodeBenchType(type) { + const t = String(type || "").trim().toLowerCase(); + return DECODE_BENCH_TYPES.includes(t) ? t : DECODE_BENCH_DEFAULT_TYPE; +} + +/** + * @param {unknown} type + * @returns {string} + */ +export function decodeBenchPromptForType(type) { + return DECODE_BENCH_PROMPTS[normalizeDecodeBenchType(type)]; +} + +/** + * @param {unknown} type + * @returns {string} + */ +export function decodeBenchTypeLabel(type) { + const id = normalizeDecodeBenchType(type); + return DECODE_BENCH_TYPE_META.find((m) => m.id === id)?.label || "Structured"; +} + +/** + * Append a hard fill-to-max instruction unless the prompt already states it. + * Soft phrases like "keep expanding" alone do not skip — models still EOS early. + * @param {string} prompt + */ +export function withFillToMaxInstruction(prompt) { + const p = String(prompt || "").trim(); + if (!p) return p; + if ( + /maximum output length|do not stop early|until you hit the (maximum|output)/i.test( + p + ) + ) { + return p; + } + return `${p}${FILL_TO_MAX_SUFFIX}`; +} + +/** + * Build N prompts for a prompt type. Mixed interleaves structural then text. + * @param {"structural" | "text" | "mixed"} type + * @param {number} count + * @returns {string[]} + */ +export function pickShowcasePrompts(type, count) { + const n = Math.max(1, Math.floor(count)); + if (type === "text") return takeCycled(TEXT_PROMPTS, n); + if (type === "structural") return takeCycled(STRUCTURAL_PROMPTS, n); + + const out = []; + let si = 0; + let ti = 0; + for (let i = 0; i < n; i++) { + if (i % 2 === 0) { + out.push(STRUCTURAL_PROMPTS[si % STRUCTURAL_PROMPTS.length]); + si += 1; + } else { + out.push(TEXT_PROMPTS[ti % TEXT_PROMPTS.length]); + ti += 1; + } + } + return out; +} + +/** + * Decode-bench prompts for a workload type. Concurrent streams get a unique + * suffix so they do not share a prefix-cache block; C1 is the exact prompt. + * @param {number} count + * @param {unknown} [type] + * @returns {string[]} + */ +export function pickDecodeBenchPrompts(count, type) { + const base = decodeBenchPromptForType(type); + const n = Math.max(1, Math.floor(count)); + if (n <= 1) return [base]; + const out = []; + for (let i = 0; i < n; i++) { + out.push(`${base} (stream ${i + 1}/${n})`); + } + return out; +} + +/** + * @param {string[]} pool + * @param {number} n + */ +function takeCycled(pool, n) { + const out = []; + for (let i = 0; i < n; i++) out.push(pool[i % pool.length]); + return out; +} diff --git a/src/shared/llmTarget.d.ts b/src/shared/llmTarget.d.ts new file mode 100644 index 00000000..36e799ec --- /dev/null +++ b/src/shared/llmTarget.d.ts @@ -0,0 +1,11 @@ +export function formatLlmBaseUrl(target: { + host: string; + port: number; + tls?: boolean; +}): string; + +export function parseLlmTargetInput( + hostInput: unknown, + portInput?: unknown, + tlsInput?: unknown +): { host: string; port: number; tls: boolean }; diff --git a/src/shared/llmTarget.js b/src/shared/llmTarget.js new file mode 100644 index 00000000..f0398f5b --- /dev/null +++ b/src/shared/llmTarget.js @@ -0,0 +1,104 @@ +/** + * On-demand LLM HTTP(S) target for Remote benches. + * Shared by the React dialog and the Node bench runners. + */ + +/** + * @param {{ host: string, port: number, tls?: boolean }} target + * @returns {string} + */ +export function formatLlmBaseUrl(target) { + const host = String(target?.host || "").trim(); + const port = Number(target?.port); + const tls = Boolean(target?.tls); + const scheme = tls ? "https" : "http"; + if (!host) return ""; + const omit = (tls && port === 443) || (!tls && port === 80); + if (!Number.isInteger(port) || port < 1 || omit) { + return `${scheme}://${host}`; + } + return `${scheme}://${host}:${port}`; +} + +/** + * Parse a typed host (hostname, host:port, or full URL) plus optional port/tls. + * + * @param {unknown} hostInput + * @param {unknown} [portInput] + * @param {unknown} [tlsInput] true/false; omitted → infer from URL, else HTTPS + * @returns {{ host: string, port: number, tls: boolean }} + */ +export function parseLlmTargetInput(hostInput, portInput, tlsInput) { + const raw = String(hostInput ?? "").trim(); + if (!raw) { + const err = new Error("Enter a host"); + err.status = 400; + throw err; + } + + let host = raw; + /** @type {number | null} */ + let urlPort = null; + /** @type {boolean | null} */ + let urlTls = null; + + const hasScheme = /^https?:\/\//i.test(raw); + const looksLikeUrl = hasScheme || (raw.includes("/") && raw.includes(".")); + if (looksLikeUrl) { + let url; + try { + url = new URL(hasScheme ? raw : `https://${raw}`); + } catch { + const err = new Error("Invalid URL"); + err.status = 400; + throw err; + } + host = url.hostname; + urlTls = url.protocol === "https:"; + if (url.port) { + const n = parseInt(url.port, 10); + if (Number.isInteger(n)) urlPort = n; + } + } else if (raw.includes("/")) { + host = raw.split("/")[0].trim(); + } + + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } + + // host:port without a scheme (not IPv6) + if (!looksLikeUrl && urlPort == null && /^\d{1,5}$/.test(host.split(":").pop() || "")) { + const idx = host.lastIndexOf(":"); + if (idx > 0 && host.indexOf(":") === idx) { + const n = parseInt(host.slice(idx + 1), 10); + if (Number.isInteger(n) && n >= 1 && n <= 65535) { + urlPort = n; + host = host.slice(0, idx); + } + } + } + + host = host.trim(); + if (!host) { + const err = new Error("Enter a host"); + err.status = 400; + throw err; + } + + let tls; + if (urlTls != null) tls = urlTls; + else if (tlsInput === true || tlsInput === "true") tls = true; + else if (tlsInput === false || tlsInput === "false") tls = false; + else tls = true; + + let port = urlPort; + if (port == null && portInput != null && String(portInput).trim() !== "") { + port = Number(portInput); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + port = tls ? 443 : 8888; + } + + return { host, port, tls }; +} diff --git a/src/shared/prefillBench.d.ts b/src/shared/prefillBench.d.ts new file mode 100644 index 00000000..766aa1b6 --- /dev/null +++ b/src/shared/prefillBench.d.ts @@ -0,0 +1,3 @@ +export const PREFILL_CONTEXT_SIZES: number[]; +export const PREFILL_DEFAULT_CONTEXT_SIZES: number[]; +export function formatContextSize(tokens: number): string; diff --git a/src/shared/prefillBench.js b/src/shared/prefillBench.js new file mode 100644 index 00000000..d72f108c --- /dev/null +++ b/src/shared/prefillBench.js @@ -0,0 +1,31 @@ +/** + * Prefill-bench context sizes — shared by the Node runner and the React dialog. + */ + +export const PREFILL_CONTEXT_SIZES = [ + 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 300000, +]; + +export const PREFILL_DEFAULT_CONTEXT_SIZES = [4096, 8192, 16384, 32768]; + +const SIZE_LABELS = new Map([ + [1024, "1k"], + [2048, "2k"], + [4096, "4k"], + [8192, "8k"], + [16384, "16k"], + [32768, "32k"], + [65536, "64k"], + [131072, "128k"], + [262144, "256k"], + [300000, "300k"], +]); + +/** @param {number} tokens */ +export function formatContextSize(tokens) { + const n = Number(tokens); + if (SIZE_LABELS.has(n)) return SIZE_LABELS.get(n); + if (!Number.isFinite(n) || n <= 0) return String(tokens); + if (n >= 1000 && n % 1000 === 0) return `${n / 1000}k`; + return String(n); +}