forked from unom/punktfunk
feat(web): the console can say what just happened, and hand a phone the way in
**Recent activity.** The console could describe the present — a status snapshot — but never the recent past. A client that connected and left while you were on another page left no trace anywhere you could look, and the host's own log is a developer artifact rather than a narrative. The event stream was already open for cache invalidation, so a feed costs one ring buffer next to it: every frame is recorded, labelled per kind, and rendered newest-first on the dashboard. Deliberately in-memory and bounded to 200. It starts empty on a page load and fills as things happen, which is the honest shape for a live tail — an audit trail would need the host to keep one, and pretending otherwise would be worse than not having it. **Connect a device.** The console knew the host's address and identity all along and never offered either in a form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on a couch. There is a card now with the address and a `punktfunk://connect/<uniqueid>` deep link, both copyable — the link is the shipped client grammar (clients/shared/deeplink-vectors.json), so an installed client opens straight onto this host. No QR: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. **Installable.** A web manifest and the theme/apple meta tags, so the console can live on a phone's home screen — which is where it is used from as often as from a desk. No service worker on purpose: an offline shell for a console whose every screen is live host state would only ever show stale numbers convincingly. The manifest is reachable without a session (install needs it, and it says nothing the login page doesn't); /api stays gated, verified. Verified in a browser: three host-emitted events appear in the feed with the right labels, the deep link renders and copies as `punktfunk://connect/abc123`, and the manifest serves 200 as application/manifest+json while /api/v1/host still answers 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+68
-2
@@ -20,7 +20,7 @@
|
||||
// that (nitro-entry/bun-https.mjs) so we don't sever our own stream.
|
||||
// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything.
|
||||
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
|
||||
import { getGetDisplayStateQueryKey } from "@/api/gen/display/display";
|
||||
import { getGetStatusQueryKey } from "@/api/gen/host/host";
|
||||
@@ -104,6 +104,55 @@ function resyncAll(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ refetchType: "all" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The activity log.
|
||||
//
|
||||
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
|
||||
// host been doing?" — a question the console could not answer at all. Nothing else records this:
|
||||
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
|
||||
// narrative. So keep a small in-memory ring alongside the cache work.
|
||||
//
|
||||
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
|
||||
// an audit trail, and a page load starts fresh from whatever the ring replays.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** One thing that happened, as the feed renders it. */
|
||||
export interface ActivityEntry {
|
||||
/** The host's monotonic sequence number — stable, and a good React key. */
|
||||
seq: number;
|
||||
/** Unix ms, from the host's clock (never the browser's). */
|
||||
ts_ms: number;
|
||||
kind: string;
|
||||
/** The event payload, shape depending on `kind` (see the EventKind schema). */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ACTIVITY_MAX = 200;
|
||||
let activity: ActivityEntry[] = [];
|
||||
const activityListeners = new Set<() => void>();
|
||||
|
||||
function pushActivity(entry: ActivityEntry): void {
|
||||
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
|
||||
if (activity.some((e) => e.seq === entry.seq)) return;
|
||||
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
|
||||
for (const l of activityListeners) l();
|
||||
}
|
||||
|
||||
/** The activity tail, newest first. Re-renders as frames arrive. */
|
||||
export function useActivity(): ActivityEntry[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
activityListeners.add(cb);
|
||||
return () => activityListeners.delete(cb);
|
||||
},
|
||||
() => activity,
|
||||
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
|
||||
() => EMPTY_ACTIVITY,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ACTIVITY: ActivityEntry[] = [];
|
||||
|
||||
/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */
|
||||
const KINDS = [
|
||||
"client.connected",
|
||||
@@ -153,7 +202,9 @@ function attach(): void {
|
||||
if (source) return;
|
||||
source = new EventSource("/api/v1/events");
|
||||
for (const kind of KINDS) {
|
||||
source.addEventListener(kind, () => {
|
||||
source.addEventListener(kind, (ev) => {
|
||||
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
|
||||
recordActivity(kind, ev);
|
||||
if (!client) return;
|
||||
// The installed set changed — but the runner is probably still restarting, so keep
|
||||
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
|
||||
@@ -170,6 +221,21 @@ function attach(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
|
||||
function recordActivity(kind: string, ev: Event): void {
|
||||
const raw = (ev as MessageEvent<string>).data;
|
||||
if (typeof raw !== "string") return;
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
|
||||
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
|
||||
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
|
||||
pushActivity({ seq, ts_ms: ts, kind, data });
|
||||
} catch {
|
||||
// A frame we cannot parse is not worth breaking the stream over.
|
||||
}
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
refs -= 1;
|
||||
if (refs > 0) return;
|
||||
|
||||
Reference in New Issue
Block a user