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, and marks the body embed) so there is one implementation
of the panel, not two. The hosted copy drops its own <h1> branding
(the window bar names it) and shows its live chip only when the stream is
broken — the panel and the dashboard are two independent SSE streams
(/recipes/events refreshes report rows, /events the fleet), so the
chip is a fallback, not a duplicate.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. Layout persists: geometry,
minimized/closed/maximized and which optional windows were open are saved
to localStorage["sm121-wins"] (saveWinState on drag/resize release and
on every state change; restoring suppresses writes during the multi-step
restore). Log windows are not in there — they come back from the server's
attach registry. Maximized = frameless fill: no
border/radius/shadow and no titlebar — the window is the layer (flush
to the dock strip, which is a sibling below it and stays usable), while its
title and controls move into the header (#maxinfo: ⛶ <title>, then
❐ restore / – minimize / × close for that window); exactly one window can be
maximized. #maxinfo needs an explicit [hidden] { display: none } guard —
its display: flex otherwise defeats the hidden attribute and the chrome
sits in the toolbar forever (assert visibility, not the .hidden
property, when testing this). Window geometry is px, so fitToLayer() clamps windows back
into the layer whenever the dock appears/disappears or the page resizes
(run at the end of renderDock — at boot the dock is :empty, i.e.
hidden, and the layer is one dock-height taller while windows are
created). 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).