2026-05-02

End-of-day snapshot. The dashboard went from "live tail or nothing" to a full Charles-style network viewer with grouping, filters, color-coded detail, per-device live status, per-device settings, and scrollable history backed by Deno KV. iOS build bumped to 3.

Status: working as expected (deployed live)

Live VPN status protocol

iOS LogStreamer now sends {type:"status",vpn:"connected"|"disconnected"} on socket open and every time vpnManager.isConnected changes (pushed through the existing 250 ms poll loop in NetworkMonitorViewModel). ws-server tracks deviceState per name in memory and broadcasts a new {type:"device-status",device,online,vpn} envelope to every dashboard whenever it changes — flagging both the WebSocket presence (online) and the VPN tunnel (vpn). On dashboard connect, the server snapshots every known device so the sidebar populates without waiting for the next push.

Dashboard sidebar

  • Per-device rows render two color-coded status pills: Online/Offline (WebSocket-connected) and Connected/Disconnected (VPN tunnel). Both flip in real time as device-status messages arrive.
  • Each row gains a ⋯ menu with a Settings item that opens a per-device override modal — same SettingsForm the /settings page uses, in mode="device". Save / Clear Override / Cancel.
  • "All devices" + per-device counters now sit in a right-aligned CountBadge pill component.

Dashboard request list (mirrors the iOS Network tab)

  • Sticky filters bar: search by URL/host/path, status-category chips (All / 2xx / 3xx / 4xx / 5xx), method dropdown, Grouped/Flat toggle, ↓ Newest / ↑ Oldest sort, Clear ▾ dropdown.
  • Default view groups entries by host. Groups are collapsed by default — each shows host + count + error-count badge in a header row, tap to expand.
  • Counter shows M of N (X live · Y history) so you can see at a glance what window you're scrolling.

Dashboard detail panel

  • Summary header with color-coded badges:
    • MethodBadge — GET=green, POST=blue, PUT=yellow, PATCH=purple, DELETE=red, HEAD/OPTIONS=gray.
    • StatusBadge — 2xx=green, 3xx=blue, 4xx=yellow, 5xx=red.
    • SecureBadge — 🔒 TLS pill if isSecure.
    • DurationBadge — color-graded by speed (<100 ms green / <500 ms yellow / <2 s orange / ≥2 s red).
    • SizeBadge — formatted bytes/KB/MB.
  • Request / Response tabs replace the flat list. Header-count + body hint next to each tab so you know which side has interesting payload before clicking.
  • HeaderTable: striped two-column layout, keys in monospaced purple, values in monospaced default.
  • BodyView: pretty-prints JSON when content-type matches application/json or content looks like JSON. Token highlighter (regex-based, no parser, no XSS risk) — purple keys, green strings, yellow numbers, orange true/false/null. Copy-to-clipboard button top-right of each body block; flashes "✓ Copied".

Historical traffic — KV-backed scrollback

The dashboard isn't live-only any more. ws-server already persisted every entry to Deno KV; we now surface it.

  • Server-side:
    • TTL retention — every kv.set for an entry now carries { expireIn: RETENTION_DAYS * 24h }, default 7 days. Deno KV auto-prunes; no cron, no manual cleanup. Override via RETENTION_DAYS env on Deno Deploy.
    • before=<iso> added to GET /api/devices/:name/entries (was since= only).
    • GET /api/entries?since=&before=&limit= — new aggregated endpoint that K-way merges across all devices by descending timestamp. Returns {device, timestamp, entry}[].
    • DELETE /api/devices/:name/entries — wipes persisted entries for one device (used by the dashboard's "Clear feed + server history" action). Iterates in 500-key atomic batches to stay under KV's per-batch limit.
  • Client-side:
    • Initial-load history — on mount, fetches /api/entries?limit=200 and seeds the feed.
    • Per-device reseed — picking a device in the sidebar fetches that device's last 200 from /api/devices/:name/entries, replacing existing history-source entries for it.
    • "↓ Load earlier" footer — paginates older entries via before=<oldest-loaded-ts>. Disables to "— end of history —" when the server returns fewer than the page size.
    • Auto-backfill on WebSocket reconnectlatestLiveTimestampRef tracks the most recent live-source entry; when the socket reopens after a drop, the dashboard fetches since=<that-ts> from REST and silently fills the gap as history-source entries. No more invisible holes when the dashboard tab background-suspends or the network blips.
    • Live/history dotted divider — thin row labeled ↑ live · history ↓ between the live tail and the seeded history. Renders only when both sides have at least one entry.
    • Dedup — entries dedupe on device|timestamp|method|url so a history fetch overlapping with live entries doesn't double-render.
    • Clear ▾ menu — split into "Clear feed (this view only)" and "Clear feed + server history". The latter calls DELETE /api/devices/:name/entries for the active device (or every device if no filter), behind a confirm().

Shared UI plumbing

  • Extracted SettingsForm + ListEditor + ExternalProxyEditor from the /settings page into app/components/SettingsForm.tsx so both the dedicated page and the per-device modal in the live feed share one source of truth. SettingsClient now imports.

Configuration verified this session

  • iPad (B193DBAA-3334-5381-B514-5E18BFC11682) — primary test device. Streaming pointed at production server; sidebar shows Online + Connected when VPN is on, Online + Disconnected when VPN is off.
  • Dashboard tested in Safari on macOS — domain groups collapsed by default, expand on tap, filters chain (device → search → method → category) match the iOS Network tab semantics.
  • KV TTL verified by checking expireIn is being passed on every persistEntry call (server logs).

Known follow-ups

  • No "live history" merge inside domain groups. When groupByDomain=true, the live/history divider can't sit cleanly inside a single group (entries from the same host across both sources stack). For now the divider only renders in flat mode — domain mode just shows an integrated count. Could revisit if it becomes confusing.
  • No per-host TTL. Global TTL only. If we ever want "always keep auth.* errors but expire image fetches after a day", we'd need a tagging scheme on entries.
  • Aggregate query is bounded by device count × per-device limit — fine for our scale (≤10 devices), would need a smarter query path if it ever scales.
  • The Settings modal in the sidebar shares the same SettingsForm as /settings. They're feature-equivalent for per-device editing; the dedicated /settings page exists primarily for global-default editing now.

Where to start next session

  1. iOS app delivery — build 3 has been deployed via xcodebuild + devicectl to the iPad but not pushed to TestFlight or signed for distribution. If the project moves toward shipping, the version stamp + provisioning + entitlements review is the next chunk.
  2. Body-size cap on the live broadcast. Currently ws-server only truncates KV writes (64 KiB cap). The live WebSocket relays full bodies. If a device captures a huge response, every connected dashboard receives it. Worth bounding in the iOS streamer with a configurable cap.
  3. History scrollback in domain-group mode — see follow-ups above.
  4. Authentication. Still none. Personal-network use only — anyone with the production URLs can connect a fake device or read the dashboard. Add a pre-shared token to /ingest and /dashboard if this ever becomes more than a personal tool.