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)
- Production URLs unchanged.
- iOS build number 3 (was 2).
CURRENT_PROJECT_VERSIONbumped in all four build configurations.
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
SettingsFormthe/settingspage uses, inmode="device". Save / Clear Override / Cancel. - "All devices" + per-device counters now sit in a right-aligned
CountBadgepill 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 ifisSecure.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/jsonor content looks like JSON. Token highlighter (regex-based, no parser, no XSS risk) — purple keys, green strings, yellow numbers, orangetrue/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.setfor an entry now carries{ expireIn: RETENTION_DAYS * 24h }, default 7 days. Deno KV auto-prunes; no cron, no manual cleanup. Override viaRETENTION_DAYSenv on Deno Deploy. before=<iso>added toGET /api/devices/:name/entries(wassince=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.
- TTL retention — every
- Client-side:
- Initial-load history — on mount, fetches
/api/entries?limit=200and 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 reconnect —
latestLiveTimestampReftracks the most recent live-source entry; when the socket reopens after a drop, the dashboard fetchessince=<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|urlso 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/entriesfor the active device (or every device if no filter), behind aconfirm().
- Initial-load history — on mount, fetches
Shared UI plumbing
- Extracted
SettingsForm+ListEditor+ExternalProxyEditorfrom the/settingspage intoapp/components/SettingsForm.tsxso 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
expireInis being passed on everypersistEntrycall (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
Settingsmodal in the sidebar shares the sameSettingsFormas/settings. They're feature-equivalent for per-device editing; the dedicated/settingspage exists primarily for global-default editing now.
Where to start next session
- iOS app delivery — build 3 has been deployed via
xcodebuild+devicectlto 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. - 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.
- History scrollback in domain-group mode — see follow-ups above.
- 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
/ingestand/dashboardif this ever becomes more than a personal tool.