dockersnap Dashboard — Design¶
A web UI for managing dockersnap instances, snapshots, and workload plugins. Internal-tool quality; not a public product.
1. Goals¶
- One pane of glass for the everyday flows: create / snapshot / revert / clone / delete instances, inspect workload health, copy a kubeconfig.
- Live progress for long operations (deploy, clone, revert) — the CLI already streams NDJSON; the UI consumes the same stream.
- Ships with the daemon — no separate service to deploy. The dockersnap binary
serves
/ui/*from an embedded asset bundle. - Useful with zero configuration. Same auth surface as the API
(
DOCKERSNAP_TOKENif set, otherwise open).
2. Non-goals¶
- Multi-tenancy / RBAC. Single-user tool; the API token already gates access at the daemon boundary.
- Multi-daemon switching. Out of scope for v1. The page targets the daemon that served it.
- Mobile / tablet layouts. Desktop-only target; responsive down to ~1024px is fine, beyond that is wasted effort.
- Logs aggregation. Daemon logs live in journalctl; we don't replicate that. We may surface plugin-level NDJSON logs (the ones the runner already re-emits) in a future stage, but not v1.
- Metrics / charts. No time-series store exists yet. Adding charts means adding a TSDB; defer until there's a clear need.
- Frontend test infrastructure. Backend tests cover the API; the SPA is thin enough that visual regressions are caught by use. Add Playwright only when a regression actually slips through.
- i18n, theming, custom branding. English, two color modes (light + dark via Tailwind class strategy), done.
3. Stack¶
(Versions verified against current upstream docs — Preact 10, Tailwind 4, TanStack Query 5, wouter 3.)
| Layer | Choice | Why |
|---|---|---|
| Framework | Preact 10 via @preact/preset-vite |
React API surface at ~3 KB runtime. The preset auto-aliases react/react-dom → preact/compat so React-ecosystem libraries (lucide, react-hook-form) drop in unchanged |
| Build | Vite | Fast HMR, first-class TS, builds to dist/ we feed to go:embed |
| Language | TypeScript | API response types generated from the daemon's Go structs (a small task ui:gen-types target later — simplest is a hand-written api.ts for now since the API surface is small) |
| Routing | wouter 3 | 2 KB; supports nest for nested route trees and useSearchParams for the ?tab=… pattern below — both used directly |
| Data fetching | TanStack Query 5 | Polls list/status, deduplicates fetches, refetches on focus. We use mutation.variables + submittedAt for optimistic UI rather than manual setQueryData writes (the v5 idiomatic path) |
| Streaming | Custom useStreamingMutation hook on fetch + ReadableStream |
TQ mutations don't natively consume streams, but the hook is ~40 lines: parse NDJSON line-by-line, push events to local state, invalidate queries on terminal complete/error event |
| Styling | Tailwind v4 via @tailwindcss/vite |
Tokens declared in CSS via @theme { --color-… }, no tailwind.config.js. Dark mode uses the dark: variant; we override the default prefers-color-scheme selector to a class strategy with @variant dark (.dark &) so a manual toggle works |
| Icons | lucide-react | Tree-shakable, works with Preact via the react → preact/compat alias |
| Forms | react-hook-form | The Create modal has the only non-trivial form (dynamic config rows derived from plugin schema) — keeps validation + state simple |
Total expected bundle target: ~150 KB gzipped after Vite production build.
Why not server-rendered / HTMX?¶
The interactive bits (live deploy progress, snapshot revert with picker,
config-key form rows) get awkward fast under server templates. SPA + Query
gives us optimistic UI for free and natural NDJSON consumption. The "single
binary" property is preserved by go:embed, so we don't lose deploy
simplicity.
3b. Design Language¶
The dockersnap CLI is the primary interface; the dashboard is its extension, not its replacement. That shapes the visual language:
- Terminal-adjacent, not marketing-site. No hero blocks, no
gradients, no rounded-xl pill buttons. Sharp corners up to
rounded-sm, flat surfaces, generous use of monospaced type for identifiers (instance names, plugin names, snapshot labels, sockets, dataset paths). - Density over whitespace. This tool gets used by people who already
ran
dockersnap ls. Show them the same data in fewer pixels — narrow rows, single-pixel borders, table-driven instance list. - Status as small dots, not pills. A 6-px circle in the row is enough;
the column header says "status." Color carries semantics:
running= emerald,stopped= amber,error= red, plus a neutral grey for inactive/unknown. One accent color (cyan-500) for interactive elements; no rainbow. - Numeric values monospaced and right-aligned. Snapshot count, uptime, bytes — comparison at a glance.
- Single top bar, no sidebar. Four destinations (Overview, Instances, Plugins, plus a settings dropdown for token/theme); a sidebar is overkill and steals the horizontal space we'd rather spend on tables.
- Empty states do work. "No instances yet —
dockersnap create foo" with the actual command shown verbatim, copy-on-click. Don't ship a cartoon octopus. - Dark mode is the default, light is the alternate. Operator tools live in dark terminals; matching that is a small respect for the user's visual context.
Concretely: a Linear / Datadog-density vibe rather than a Vercel / Stripe landing-page vibe.
3b.1 Palette¶
Direction: Amber CRT meets Linear. Warm-grey chrome that pulls away from the cold default-AI-dashboard greys, with a single amber accent that nods at terminal heritage without going phosphor-green cosplay. Status colors stay on their own conceptual territory so a button never gets confused for a state indicator.
Drop this block into dashboard/src/index.css next to @import "tailwindcss".
@theme {
/* ── Mode-invariant: accent + status semantics ────────────────────── */
--color-accent: oklch(0.78 0.160 75); /* amber */
--color-accent-hover: oklch(0.84 0.160 75);
--color-accent-muted: oklch(0.78 0.160 75 / 0.18); /* nav active state */
--color-accent-fg: oklch(0.18 0.020 60); /* text on amber */
--color-focus-ring: oklch(0.78 0.160 75 / 0.45); /* halo, not fill */
--color-selection: oklch(0.78 0.160 75 / 0.30); /* text highlight */
--color-status-running: oklch(0.72 0.130 155); /* sage emerald */
--color-status-stopped: oklch(0.62 0.020 240); /* slate blue */
--color-status-error: oklch(0.62 0.180 25); /* terracotta */
--color-status-error-hover: oklch(0.55 0.180 25);
--color-status-unknown: oklch(0.55 0.005 60); /* warm grey */
}
/* ── Dark mode (default) ──────────────────────────────────────────────── */
:root {
--color-bg: oklch(0.180 0.005 60);
--color-surface: oklch(0.220 0.005 60);
--color-surface-subtle: oklch(0.205 0.005 60);
--color-surface-hover: oklch(0.255 0.005 60);
--color-border: oklch(0.300 0.005 60);
--color-border-strong: oklch(0.420 0.005 60);
--color-text: oklch(0.960 0.005 60);
--color-text-secondary: oklch(0.780 0.005 60);
--color-text-muted: oklch(0.580 0.005 60);
--color-code-bg: oklch(0.140 0.005 60);
--color-status-running-bg: oklch(0.32 0.05 155);
--color-status-stopped-bg: oklch(0.30 0.02 240);
--color-status-error-bg: oklch(0.32 0.07 25);
--color-status-unknown-bg: oklch(0.28 0.005 60);
}
/* ── Light mode (opt-in via .light on <html>) ─────────────────────────── */
.light {
--color-bg: oklch(0.985 0.003 60);
--color-surface: oklch(1.000 0.000 0);
--color-surface-subtle: oklch(0.975 0.003 60);
--color-surface-hover: oklch(0.960 0.003 60);
--color-border: oklch(0.900 0.003 60);
--color-border-strong: oklch(0.780 0.003 60);
--color-text: oklch(0.200 0.010 60);
--color-text-secondary: oklch(0.450 0.005 60);
--color-text-muted: oklch(0.620 0.005 60);
--color-code-bg: oklch(0.960 0.003 60);
--color-status-running-bg: oklch(0.92 0.04 155);
--color-status-stopped-bg: oklch(0.92 0.02 240);
--color-status-error-bg: oklch(0.93 0.05 25);
--color-status-unknown-bg: oklch(0.94 0.003 60);
}
/* Dark is the default; .light is the variant we toggle. */
@variant light (.light &);
Per-token rationale (terse):
| Token | Why this hue / lightness |
|---|---|
accent (amber, h=75) |
Distinct from the Tailwind-blue tell. 75° lands between yellow and orange — warm-alive without playful. Carries terminal heritage (amber CRTs); same value works in both modes. |
status-running (sage, h=155, c=0.13) |
Cooler than default emerald, desaturated. Reads "all systems nominal," not Christmas. Lives in green space while accent lives in amber space — dot beside button never competes. |
status-stopped (slate-blue, h=240) |
Opposite hemisphere of the accent so "stopped" can never be misread as "interactive." Cool, low chroma — idle, deliberate, not broken. |
status-error (terracotta, h=25) |
Muted red-orange instead of stock red-500. Serious without the form-validation plastic look. Low chroma to live alongside the warm chrome. |
status-unknown (warm grey) |
Same hue family as the chrome. Quieter than stopped — stopped is a state, unknown is the absence of one. |
bg / surface / border (warm grey, h=60, c=0.005) |
Hue 60° matches the accent's family. Micro-warmth separates this palette from generic neutral-900 / zinc-950 dashboards. Linear does this with reds; we do it with yellows. |
surface-subtle |
Striped tables and read-only inline panels — one tick distinct from surface without reaching surface-hover (reserved for interaction). |
status-*-bg |
When we need the status as a badge fill rather than a dot. Same hue, much higher lightness, low chroma so the color sits behind text without shouting. |
accent-muted |
Active-nav underline — accent without the button-volume. |
focus-ring / selection |
Accent at low alpha so focus halos and text highlights feel like the rest of the palette, not bolted on. |
3b.2 Typography¶
Two faces, both self-hosted under dashboard/public/fonts/ so the
dashboard works offline-first and no external CDN can break the page.
@theme {
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Menlo", "Consolas", monospace;
}
- Geist for UI text. Free (OFL), characterful without screaming
"Vercel" the moment you read it; clean enough to disappear at small
sizes (we run a lot of
text-xs/text-smin tables). Pairs cleanly with JetBrains Mono. - JetBrains Mono for everything
font-mono: instance names, dataset paths, snapshot labels, sockets, plugin names, command samples in empty states, code blocks. The most CRT-adjacent of the open mono faces; has good0/O/l/1disambiguation, which matters when an operator is reading IDs at a glance.
Identifier rule: anything the user could type in the CLI ships in
font-mono — against bg-code-bg when it's a standalone block, plain
when it's inline. Numeric columns get tabular-nums for at-a-glance
comparison.
4. Information Architecture¶
/ui
├── / Overview (default)
├── /instances Instance list (filterable)
├── /instances/:name Instance detail (tabbed)
│ ├── ?tab=overview Status, subnet, MetalLB IP, socket, created
│ ├── ?tab=snapshots Snapshot table + actions (revert, delete)
│ ├── ?tab=access Plugin access bundle: env, files, endpoints
│ ├── ?tab=workload Plugin describe + health
│ └── ?tab=ports Forwarded ports table
└── /plugins Plugin list + per-plugin schema view
4.1 Overview¶
- Daemon block: version, uptime, total/running instance counts, healthy
vs unhealthy workload counts (already at
/api/v1/health). - Instance summary: top-5 instances by recency, with status pill + quick links.
- Plugin summary: count + reload button.
4.2 Instance list¶
Table columns: name (link) · status pill · workload plugin · subnet · snapshots · clone-of · created. Filters: status, has-workload, search-by-name. Bulk actions deferred (single-user assumption — bulk delete is a footgun).
4.3 Instance detail (tabbed)¶
- Overview tab: card layout — identity, network, dataset, lifecycle buttons (start / stop / restart / delete).
- Snapshots tab: table (label · created · tags · clones), buttons: Snapshot now (modal), Revert (picker w/ force checkbox + warning), Clone from snapshot (modal). Delete-snapshot is a future endpoint.
- Access tab: read-only view of the plugin's access response, with copy buttons per file (kubeconfig especially) and per env var. Show resolved endpoint URLs.
- Workload tab: describe response (plugin metadata, free-form JSON
rendered as a tree); health response with the diagnostic checks list and
a Refresh button (
?fresh=true). - Ports tab: forwarded ports table; Refresh button hits
/ports/refresh.
4.4 Plugins page¶
Table: name · status · version · contract · description. Click a plugin → schema view: ConfigOptions table (name · type · default · description), binary digest, schema digest, last-loaded timestamp. Reload button.
4.5 Modals¶
- Create instance: name field, optional plugin dropdown (from
/api/v1/plugins), config rows that switch UI based on the schema's ConfigOption types (string → input, bool → checkbox, int → number, path → file picker hint, list → repeating input). YAML textarea as an escape hatch (matches--config-file). Streams progress. - Snapshot: label + tag rows.
- Revert: snapshot picker, force checkbox with red warning that newer snapshots will be destroyed. Streams progress.
- Clone: source snapshot picker + new instance name. Streams progress.
- Delete: confirmation with the instance name typed in (CLI parked Theme C will mirror this).
4.6 Live progress UX¶
When a long-running mutation starts, the modal switches to a live-progress
panel: each NDJSON event becomes a row (→ running, ✓ done, ✗ error).
Stream EOF + complete event → success, dismiss button. Error event →
keep the panel open, show the error, Close / Retry buttons.
5. Daemon Integration¶
//go:embed all:dashboard/dist
var dashboardFS embed.FS
func mountDashboard(r chi.Router) {
sub, _ := fs.Sub(dashboardFS, "dashboard/dist")
r.Handle("/ui/*", http.StripPrefix("/ui/", spaHandler(sub)))
r.Get("/", redirectTo("/ui/"))
}
spaHandler serves files when they exist on disk; falls back to index.html
for unknown paths so client-side routing works on refresh.
CORS: the SPA is same-origin, so no extra config. The token middleware
already covers /api/v1/*; the dashboard reads it from a cookie set by a
new POST /api/v1/login (just verifies and stores the existing token —
no new auth model). For tokenless setups (default), /login is a no-op.
6. Build / Dev Loop¶
dashboard/is a sibling ofcmd/,internal/, etc. Its ownpackage.json,vite.config.ts, etc.task ui:dev— runsviteagainst the daemon atlocalhost:9847(Vite proxies/api/*to the daemon).task ui:build— runsvite build, outputdashboard/dist/. Theembeddirective picks it up nextgo build.task build(existing) gains a dependency onui:buildso the daemon binary always ships the latest dashboard. Dev iteration usesui:devagainst a running daemon — no rebuild of Go for UI changes.
7. Stages¶
Each stage stands alone — you can stop after any of them and have something useful.
Stage 1 — Foundation (~1 day)¶
- Scaffold
dashboard/withnpm init preact(gives Vite + TS + the@preact/preset-vitepreset that auto-aliases react→preact/compat). - Add Tailwind v4:
npm i -D tailwindcss @tailwindcss/vite, registertailwindcss()invite.config.ts, single@import "tailwindcss"insrc/index.css. Configure class-based dark mode:@variant dark (.dark &)in the same file. - Add design tokens via
@theme { --color-accent: oklch(0.74 0.13 220); … }(the cyan accent + status colors from §3b). - Wire
wouterwith<Router base="/ui">so the dashboard lives under/ui/*regardless of where the daemon serves it. - Embed
dashboard/dist/in the daemon, serve at/ui/*with the index.html fallback for client-side routes. - Add
task ui:dev(Vite dev server with/apiproxied to the daemon) andtask ui:build; threadui:buildintotask build. - Skeleton layout: top bar (logo wordmark + nav links + token-state pill +
theme toggle),
<Outlet />area, no content yet.
Done when: task build && bin/dockersnap serve shows the empty
skeleton at http://localhost:9847/ui/, theme toggle persists in
localStorage, refreshing on /ui/instances/foo doesn't 404.
Stage 2 — Read-only views (~2 days)¶
- Overview page (daemon health card + instance summary).
- Instance list with filters + status pills.
- Instance detail Overview tab.
- TanStack Query polls instance list every 5s; pauses when the tab is hidden.
Done when: the entire CLI's list + status flow is reachable in
the UI; no mutations.
Stage 3 — Mutations with live progress (~2 days)¶
- Create / delete / snapshot / revert / clone modals.
useStreamingMutationhook: kicks offfetch(url, { headers: { Accept: 'application/x-ndjson' }, body }), readsresponse.bodyviagetReader()+TextDecoderStream, splits on\n, exposesevents: ProgressEvent[]andstatus: 'pending' | 'done' | 'error'to the modal. On terminalcompleteevent,queryClient.invalidateQueries({ queryKey: ['instances'] }).- Optimistic UI uses TQ v5's
mutation.variables+submittedAtrather than manualsetQueryDatacalls — the modal renders the new instance in the list-skeleton row while the stream runs, and the real entry replaces it on invalidation. deletemodal requires the user to type the instance name to confirm (mirrors the parked CLI Theme C behavior; same UX in both surfaces).
Done when: every CLI lifecycle verb works from the UI with live progress; killing the stream mid-deploy leaves the daemon in a clean state (the existing rollback paths handle this).
Stage 4 — Workload tabs (~half day)¶
- Access tab (env + files + endpoints, copy buttons).
- Workload describe + health tabs.
- Ports tab (with refresh button).
Done when: full per-instance plugin view is in the UI.
Stage 5 — Plugins page (~half day)¶
- Plugin list + per-plugin schema view.
- Reload button.
Done when: dockersnap plugin {list,describe,reload} are all reachable.
Stage 6 — Polish (~1 day)¶
- Empty states for every list (instances, snapshots, ports, plugins).
- Error toasts (TanStack Query
onErrorglobal handler). - Keyboard shortcuts:
ccreate,/focus search,?shortcut overlay. - Persistent filters (URL query params, not localStorage — shareable links).
- Browser title sync (
dockersnap — <instance>).
Done when: the dashboard feels like something you'd actually want to leave open in a tab.
Future (not stages — opportunistic)¶
- Plugin logs viewer. The runner already re-emits plugin NDJSON logs
into the daemon's slog. Add
GET /api/v1/instances/{name}/logs?since=..that tails them; UI streams it. - Multi-daemon switcher. Top-bar dropdown of saved
DOCKERSNAP_REMOTEendpoints. Worth doing only if the user actually accumulates daemons. - Snapshot diff/restore-only-files. Future ZFS feature, depends on
internal/zfsexposing a diff command first.
8. Open Questions¶
- Token bootstrap UX. When
api.tokenis set, the SPA needs to know it. Lean toward a top-bar field with token persisted in localStorage - sent as the existing
Authorization: Bearer …header on every request — one less endpoint, no cookie/session state, matches the CLI'sDOCKERSNAP_TOKENenv-var pattern. Revisit if multi-user shows up. - Type generation. The Go API responses (
state.Instance,client.PluginInfo, etc.) need TS counterparts. Two cheap options: (a) hand-writedashboard/src/types/api.tsto match — surface is small (~10 types); (b) addtask ui:gen-typesrunningtygoagainst internal/state + internal/client. Start with (a); switch to (b) when the surface grows or types drift. - Where to source plugin schemas in forms. The Create modal needs to
render type-appropriate inputs from
ConfigOption.Type. Decide whether to hardcode the type → component map in the SPA, or make the daemon emit hint fields (ui_widget: "textarea") per option. Lean toward the former for v1 — keeps the contract minimal. - Optimistic UI vs server truth on streamed mutations. TQ v5's
recommendation is to lean on
mutation.variables+ the pending state rather than write to the cache. For NDJSON streams that's still the best fit: the modal is the source of truth for the in-flight op, and invalidation on terminal event refetches the canonical state. Document this as the pattern; don't mix insetQueryDatawrites.