sm121 web — dashboard designPlan for the
websubcommand: an axum + SSE dashboard that manages the same things the CLI manages (serve/stop/power/logs/health/raw config), with LED status, toggles, live log windows, light/dark themes, and a floating-window UI. Read this before implementing; update the phase list as it ships.
One command that replaces the monitoring + action loop of
health/logs -f/serve/stop/power with a single browser surface.
The CLI stays the source of truth and the fallback; the web layer is a thin
RPC/event layer over the existing command functions — no re-implementation
of serve/stop/power logic.
Non-goals (v1): multi-user auth beyond an optional bearer token, metrics/history (no time-series storage), recipe editing (raw view is read-only; edits happen in the files), mobile layout (desktop-first).
sm121 web [--port 3100] [--bind 127.0.0.1] [--token <secret>] [--open]
--port / --bind: default 127.0.0.1:3100. Binding non-localhost
requires --token (checked as Authorization: Bearer).--open: open the browser (open on macOS, xdg-open otherwise).--config > cwd > global).sm121 web
└─ axum Router ── AppState { config, jobs, log_hub, bus }
├─ / embedded UI (include_str! of src/web/ui.html)
├─ /api/* REST (actions + snapshots + raw content)
├─ /events single multiplexed SSE stream
├─ supervisor tokio task: polls cluster_state::probe every 30 s →
│ broadcasts state snapshot on the bus
└─ jobs serve/stop/power run as tracked tokio tasks that call
the existing docker::serve / stop_cluster / power fns
Key properties:
POST /api/serve calls docker::serve(config, …)
with ServeOpts built from the request body — the same code path the CLI
uses (before_serve hooks, mount clearing, NCCL, image sync all apply).tokio::sync::broadcast<Event> (cap 1024). Event =
State | Job | Log. The single /events SSE endpoint forwards every
event; the UI routes by type. No per-topic server state.cluster_state::probe (what health does:
docker ps per node over SSH) every 30 s. Same SSH load as a manual
health loop (less); fine for ≤ a few nodes. Job-driven state changes
(P2) push immediately, so the 30 s only bounds external changes.docker logs -f SSH session (reusing
ssh_session_follow) whose output is newline-split and pushed to the bus
as Log events, plus a per-attach ring buffer (2000 lines) so new
windows can fetch history via GET /api/logs?lines=N before attaching.
Cap 8 concurrent attaches (ponytail: per-attach SSH sessions; queue if
this ever matters). run_ssh_follow gets a callback-based sibling
(run_ssh_stream(config, node, cmd, on_chunk)) — the existing fn becomes
a wrapper, CLI behavior unchanged.| Method | Path | Purpose |
|---|---|---|
| GET | /api/state |
full snapshot (clusters, nodes, containers, jobs, log-attaches) |
| POST | /api/serve |
{cluster, recipe, solo?, force?, fit?, clear?, mirror?, dry_run?} → {job} |
| POST | /api/stop |
{cluster, recipe, node?} → {job} |
| POST | /api/power |
{action: off\|on\|switch, cluster, from?, to?} → {job} — confirm required (see safety) |
| POST | /api/logs |
attach {node, recipe, rank?} → {attach_id} (max 8) |
| DELETE | /api/logs |
detach {attach_id} |
| GET | /api/logs?node=&recipe=&lines=200 |
ring-buffer history for a fresh window |
| GET | /api/recipes |
recipe names (from all recipe_sources) + deploy target |
| GET | /api/raw/config |
active config file, verbatim |
| GET | /api/raw/recipe/<name> |
recipe.toml, verbatim |
| GET | /api/raw/effective |
merged Config as JSON (post cluster-source merge) |
Errors: { "error": "…" } with 4xx/5xx; a running job for the same
(cluster, recipe) returns 409 on a second serve/stop (single-flight).
/events)Newline-delimited JSON, one event per data::
{"type":"state","ts":1690000000,"snapshot":{…}}
{"type":"job","id":"a1","event":"started|progress|ok|failed","cluster":"qwen38","recipe":"…","message":"…"}
{"type":"log","attach":"l2","node":"dgx1.lan","recipe":"qwen38-27b-nvfp4-unsloth","rank":0,"line":"…"}
AppState.last), so it paints and shows live at once
instead of waiting up to a full poll interval.EventSource auto-reconnects; the connection itself
flips the indicator to live (onopen), replay keeps the data fresh.\n; a chunk without a trailing
newline is buffered (vLLM banner lines are long but single-line).struct Job { id, kind: Serve|Stop|Power, cluster, recipe: Option<String>,
status: Running|Ok|Failed, started: Instant, message: String }
Arc<Mutex<HashMap<String, Job>>> in AppState (lazy: no
external map crate).Running, (b) calls the
existing fn, (c) sets Ok/Failed + emits a Job event.sm121 web dies mid-job, the job state is
lost but the containers/actions are unaffected (docker + SSH are the real
side effects). sm121 health / /api/state is the source of truth.dry_run: true on serve is exposed (a "dry-run preview" toggle) — it
returns the rendered docker run instead of launching; the raw view shows
it.src/web/ui.html — one HTML file (HTML + CSS + vanilla JS), embedded via
include_str! and served at /. No npm, no bundler, no static assets:
CSS transitions/keyframes cover the animation budget; EventSource is
native. A build-toolchain frontend is deliberately not chosen — the UI
is a status board, not an app with a component graph. (Revisit only if the
UI outgrows ~2–3k lines.)
state — the LED grid (default, opens at start).logs {node, recipe, rank} — live stream + history + pause + raw-copy.jobs — in-flight jobs with progress lines. Running containers are
grouped one row per launcher job (docker sm121.cluster +
sm121.recipe label) with the node list — a container is reported by
every cluster sharing its node, so a per-node×cluster listing made the
whole board look busy.recipes — the sm121 report --serve panel (table of tested recipes,
bench history, stars, the /r/... viewer) embedded in an iframe; the
dashboard nests that page at /recipes (report::html_page_at
rewrites the page's own absolute paths, its SSE stream and viewer links
move with it) so there is one implementation of the panel, not two.raw {kind: config|recipe:name|effective} — verbatim content, monospace,
copy button, JSON pretty for effective.ui.html): absolute windows, drag by
titlebar, corner resize, ▢ maximize (fills the window layer minus
the dock strip; a second click restores the exact previous geometry),
minimize → dock chip → restore, close. Every window ever created is
registered in winRegistry, and the dock lists minimized ones from
there — a window kept only in a local variable (recipes/raw/logs used to
be) minimizes into nowhere. Default layout: left column = Topology
window sized to the map's aspect ratio (a fixed-aspect drawing
letterboxes ~190px of empty bands otherwise) + Jobs below it; right
column = Cluster state, full height — no dead space in the layer.light / dark via a data-theme attribute; default prefers-color-scheme,
choice persisted in localStorage. One CSS custom-property set per theme
(--bg, --fg, --led-*, --accent, …) — no second stylesheet./api/state is pending.The AGENTS.md live-traffic warning applies to the UI with extra force — a toggle is one click away:
power off / power switch: two-step confirm — the window shows the
current health of the target cluster first, and requires the user to type
the cluster name to arm. No silent power-off.stop: confirm dialog naming the container(s) that will be killed
(from the current snapshot).serve: occupancy warnings surface in the job window (the CLI
already bails on occupied nodes unless force; the force checkbox is
labeled as such and default-off).| File | Contents |
|---|---|
src/web/mod.rs |
run(config, opts) — router, AppState, supervisor spawn |
src/web/events.rs |
Event enum, bus, /events SSE handler, state snapshot builder |
src/web/jobs.rs |
Job registry, single-flight, action spawning (serve/stop/power) |
src/web/logs.rs |
attach/detach, ring buffer, run_ssh_stream wiring |
src/web/api.rs |
REST handlers + raw-content endpoints |
src/web/ui.html |
the UI (embedded; exempt from the 500-line rule — not Rust) |
src/ssh.rs |
+ run_ssh_stream(config, node, cmd, on_chunk) (~15 lines) |
src/cli.rs / src/main.rs |
Web subcommand + dispatch arm |
axum = { version = "0.8", features = ["sse"] } — the only addition
(futures, serde_json, tokio already present; same-origin → no
tower-http)./api/state, supervisor poll,
/events SSE (state only), UI shell: theme toggle + LED grid in a
window. Ship test: dashboard reflects a live serve/stop done from the
CLI within ~4 s. — DONE 2026-08-22 (commit pending; smoke-tested
against the real fleet: dgx1 up+occupied, dgx2-4 offline; SSE + token
gate + non-loopback refusal verified).bc75836) — jobs are
tracked tokio tasks calling the CLI handlers (handle_serve /
handle_stop / power fns), single-flight 409, job SSE events (started /
ok / failed, with kind), state pushed on job finish. UI: per-cluster
action bar (recipe select, dry-run), stop confirm lists running
containers (new web probe fetches per-node sm121 containers), power
off/switch type-the-name confirm + occupied-block. GET /api/recipes
lists discoverable recipes (deploy + required_nodes). Deviations: job
events are started/ok/failed (progress lines deferred to P3's log
streams); serve/stop jobs run the whole CLI path (including
occupancy / NCCL / venv checks) — a web serve is exactly a CLI serve.a99a642 + 3e0b679, 2026-08-23) — POST/GET/DELETE
/api/logs (8 attaches, 1000-line rings, --tail=200 seed), Event::Log,
Snapshot.attaches (window restore on reload); vanilla window manager
(drag/resize/minimize/dock/z-order); node-click log windows with
container select, pause/copy, 27 Playwright checks. Deviation: serve/stop
job progress lines (the CLI-handler stdout) still not streamed — jobs
stay started/ok/failed; would need per-job tracing spans + a Layer.--open shipped with P1.)
✅ done (3ca1d59 + 887bcac/0950d9d/ec9b0d2, 2026-08-23) —
GET /api/raw/{config,recipe/{name},effective} (effective = merged
Config as JSON, HA token redacted), one raw window with a kind/recipe
selector + copy; skeleton shimmer until first state, attention pulses
(Exited containers, busy buttons, running job dots), all honoring
prefers-reduced-motion. 31 Playwright checks; tests.rs split into
api_tests.rs at the 500-line cap.All phases shipped. The web plan is complete; the only documented deferred item is serve/stop job progress lines (P3 deviation, needs per-job tracing spans).
UI↔API parity audit (4f1837d, 2026-08-23): every server capability
is reachable from the UI — serve flags (solo/force/fit/clear/mirror/node/
keep) via the ⚙ flags expander (body built by the pure, node-probed
serveBody), per-node stop scope in the confirm dialog, visible per-node
logs buttons, and the dock + new-window menu (state/jobs/logs…/raw)
completing the plan's window-kinds table. 36 Playwright checks.
P1 deviations from the plan: Event is a one-variant enum for now (job/log
variants added with P2/P3); per-container rows are deferred to P2/P3 (the
supervisor reuses ClusterState::probe as-is — node reachability +
occupancy only); --open already implemented (trivial).
?lines= slice; run_ssh_stream newline
chunking (fake stream).GET /api/state 200 + JSON shape; POST /api/serve
{dry_run: true} → job ok with rendered command; GET
/api/raw/effective reflects a cluster_sources merge; token enforced
when --bind 0.0.0.0.cargo test
ui_playwright_checks boots the real server (two offline fake clusters)
and drives the embedded ui.html in real Chromium via
src/web/ui.playwright.mjs (19 checks: load/render, theme toggle +
persistence, chip switching, down-cluster action bar, type-the-name
power gate, no-DOM-churn on identical state, serve/stop confirm dialogs,
zero page-JS errors). One-time browser setup:
deno run -A src/web/pw-install.mjs. Skips cleanly when deno or the
browser is absent; negative-verified (a broken name gate →
FAIL wrong name keeps confirm hidden). Gotchas learned: under deno
waitUntil: "load" never fires (use domcontentloaded); the runner
must use tokio::process — a blocking std::process::output()
deadlocks the current-thread runtime that also serves the UI.
ui_js_syntax_and_snapshot_logic (node) covers script syntax +
sameSnapshot without a browser.src/web/ui.html) — confirmed.
A build-toolchain frontend is only worth it if the UI grows a real
component graph (revisit at ~3k lines).3100 (next to the 30000 serving ports).