The Logs page died permanently every time the host restarted — which the console's own update flow does. The host's log ring restarts at seq 1 while the page's cursor stays where it got to, and `GET /logs?after=8000` against a fresh ring is not an error, it is an empty page forever: no error, no dropped badge, stale lines on screen, and nothing short of a full reload to get out. A restart always breaks the poll first, so a failed poll now triggers a re-read from the start of the ring, and a page whose newest entry is older than what we hold is recognised as the sequence having restarted. Follow mode also stopped following at exactly the wrong moment. The autoscroll effect was keyed on the rendered row count, which pins at the 1000-row DOM cap — so once the log got busy enough to matter, the effect never re-ran again. It is keyed on the newest rendered seq now. And pausing now actually pauses: stopping the interval left React Query's focus/reconnect refetches landing, which evicted the very lines the operator had paused on. The rest: - A plugin could white-screen the whole console by registering `icon: "constructor"`. The icon map is a plain object, so the inherited key resolved to `Object`, which is truthy — the fallback never fired and React was handed `Object` as a component, from inside the app shell. - Saving a display arrangement deleted the saved position of every device that was not connected at that moment: the host replaces the whole map, and we only ever sent the displays we could see. - Flipping DDC, PnP or dedicated-game-sessions committed whatever unsaved edits the Custom block was holding, then cleared the "unsaved" badge so there was no trace of it. Those three apply on top of the SAVED policy now. - Saving the Custom block put the streamed-screen pin back to whatever it was when the form was seeded, undoing a change made in the picker below it. - "End now" on a running game calls the host's only stop, which ends EVERY live session; on a grace row with no app id it ended every waiting game. Both say so first now, when there is more than one to lose. - Edit and Delete were offered on library entries owned by a provider plugin, which the host refuses with 409 — silently. They are attributed instead. - An install whose first poll failed never polled again, and one whose host restarted spun forever with no way to dismiss it. - Submitting a second pairing PIN showed the previous attempt's "PIN sent" before a digit was typed, and the paired list it points you at never refreshed. - The streamed-screen picker claimed an env pin during every slow load, and rows that cannot be picked now look that way instead of silently eating the click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
punktfunk web — management console
The browser UI for the punktfunk host's management REST API (crates/punktfunk-host/src/mgmt.rs,
OpenAPI at api/openapi.json). It shows live status, host capabilities, paired
clients, the pairing-PIN flow, and session controls.
Stack: TanStack Start (full SSR) on Bun via Nitro v2 (bun preset) · React
Query through orval codegen from the OpenAPI spec · @unom/ui
— the shared punktfunk/unom design system the marketing site + docs are built on (Tailwind v4,
animated components on the violet brand over dark chrome) ·
Paraglide i18n (en/de). Package manager + runtime: Bun.
The @unom registry mapping lives in .npmrc; the auth token comes from
~/.npmrc (or a CI secret).
Develop
# from web/ — Bun is the toolchain (https://bun.sh)
bun install # runs `prepare` → codegen (orval + paraglide)
bun run dev # http://localhost:47992
# The dev server proxies /api → https://127.0.0.1:47990 (the host's mgmt API; it serves HTTPS
# with the host's self-signed identity cert — the dev proxy uses `secure: false`).
# Point it elsewhere: PUNKTFUNK_MGMT_URL=https://<host>:47990 bun run dev
Start a host with the management API up:
# from the repo root — `serve` brings up the native punktfunk/1 plane + the mgmt API (the console
# only needs the mgmt API; add --gamestream too if you also want the Moonlight surface):
WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
cargo run -rp punktfunk-host -- serve
# loopback :47990, no token (a token is mandatory for non-loopback binds).
If the host runs with --mgmt-token, set it under Settings → API token (stored in
localStorage, sent as Authorization: Bearer … by the orval fetcher).
Build & run (Nitro + Bun)
The console runs on bun (Bun.serve is a Bun API — node can't run it): Nitro's bun preset
plus a custom entry (nitro-entry/bun-https.mjs) that calls Bun.serve({ tls }), so it serves
HTTPS (HTTP/1.1 over TLS) with the host's own identity cert (the cert native clients already
pin). One trust anchor across the data plane, the mgmt API, and this console. (No HTTP/2 — Bun.serve
has no h2 server — and no HTTP/3, which a browser won't speak against this self-signed, no-SAN host
cert; a browser-trusted, SAN-matching cert + a fronting server would be needed, out of scope for a
LAN console.)
bun run build # → .output/ (Nitro `bun` preset + our Bun.serve TLS entry)
PORT=47992 HOST=0.0.0.0 \
PUNKTFUNK_UI_PASSWORD=… PUNKTFUNK_MGMT_TOKEN=… \
PUNKTFUNK_MGMT_URL=https://127.0.0.1:47990 \
PUNKTFUNK_UI_TLS_CERT=~/.config/punktfunk/cert.pem \
PUNKTFUNK_UI_TLS_KEY=~/.config/punktfunk/key.pem PUNKTFUNK_UI_SECURE=1 \
bun run start # = bun run .output/server/index.mjs
# PUNKTFUNK_UI_TLS_* unset ⇒ plain HTTP (local dev); both set ⇒ HTTPS (HTTP/1.1 over TLS).
# The host's self-signed mgmt cert is accepted only for the proxy's loopback hop, scoped in code
# (Bun per-request TLS: server/routes/api/[...].ts) — no process-wide NODE_TLS_REJECT_UNAUTHORIZED.
# See .env.example.
bun run lint # tsc --noEmit
The built Nitro bun server SSR-renders the app and is the only thing exposed on the LAN.
Run it on the same box as the host; it serves the console over HTTPS on :47992 (or $PORT).
Auth (backend-for-frontend)
Single-user, login-gated. Config via env (see .env.example):
- The console requires a login (
PUNKTFUNK_UI_PASSWORD). On success the server sets a sealed session cookie (h3useSession, AES-GCM).server/middleware/auth.tsgates every request — pages redirect to/login,/apireturns 401 — and fails closed (503) ifPUNKTFUNK_UI_PASSWORDis unset, so a misconfigured LAN server admits no one. - The bearer-token admin surface of the management API is loopback-only — the host honors a
bearer token only from a loopback peer, so the admin API is never LAN-exposed. The web server
holds
PUNKTFUNK_MGMT_TOKENserver-side and injects it when proxying/api/**→PUNKTFUNK_MGMT_URL(loopback;server/routes/api/[...].ts). The token never reaches the browser; the browser only ever holds the session cookie. (The host also binds the read-only surface — host status + the game library — to the LAN so paired native clients can fetch it directly over mTLS; that path uses client certs, not the token, and never touches this console.)
So: browser ──password──▶ web server (session cookie) ──mgmt token, server-side──▶ mgmt API.
Run the host with a matching token: cargo run -rp punktfunk-host -- serve +
PUNKTFUNK_MGMT_TOKEN=… (or --mgmt-token …). vite dev has no gate (localhost-only) and
proxies straight to the loopback mgmt API.
Toolchain notes (load-bearing): TanStack Start's
start-plugin-corepeer-requires Vite ≥ 7 — on Vite 6 the build's prerender/post-build hook silently doesn't run.@vitejs/plugin-reactmust match Vite (v5 ↔ Vite 7, v6 ↔ Vite 8); it's required even for dev (TanStack Start's dev mode needs the React Refresh runtime, else a blank screen). Nitro is the server target — without itvite buildonly emits client+SSR bundles, no deployable server. The Nitrobunpreset makes.output/server/index.mjsBun-runnable.
Codegen
Generated code is not committed (gitignored) — reproduced from sources:
bun run codegen— regenerate the API client (orval) + i18n runtime (paraglide). Runs onbun install(prepare) and beforedev/build(pre*for orval; the Vite plugin compiles paraglide on dev/build).- After a management-API change, regenerate the spec on the Rust side first:
cargo run -p punktfunk-host -- openapi > api/openapi.json, thenbun run api:gen.
Layout
src/
routes/ file-based routes (index=dashboard, host, clients, pairing, settings)
components/
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
query-state.tsx loading/error wrapper (incl. 401 → "set a token")
api/
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
gen/ GENERATED react-query hooks + models (orval)
lib/i18n.ts reactive Paraglide locale hook
paraglide/ GENERATED i18n runtime (paraglide)
messages/{en,de}.json translation sources