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:
2026-08-01 00:20:10 +02:00
co-authored by Claude Opus 5
parent b8f603c8a1
commit dc57aa653c
10 changed files with 363 additions and 2 deletions
+27
View File
@@ -77,9 +77,36 @@
"stream_packet_size": "Paketgröße",
"stream_min_fec": "FEC-Minimum",
"stream_bitrate": "Bitrate",
"activity_title": "Letzte Aktivität",
"activity_empty": "Noch nichts — Ereignisse erscheinen hier, sobald sie auf dem Host passieren.",
"activity_client_connected": "Verbunden",
"activity_client_disconnected": "Getrennt",
"activity_session_started": "Sitzung gestartet",
"activity_session_ended": "Sitzung beendet",
"activity_stream_started": "Stream gestartet",
"activity_stream_stopped": "Stream gestoppt",
"activity_game_running": "Spiel läuft",
"activity_game_exited": "Spiel beendet",
"activity_pairing_pending": "Kopplung angefragt",
"activity_pairing_completed": "Gekoppelt",
"activity_pairing_denied": "Kopplung abgelehnt",
"activity_display_created": "Anzeige erstellt",
"activity_display_released": "Anzeige freigegeben",
"activity_library_changed": "Bibliothek geändert",
"activity_update_available": "Update verfügbar",
"activity_update_applied": "Update angewendet",
"activity_plugins_changed": "Plugins geändert",
"activity_store_changed": "Store geändert",
"activity_host_started": "Host gestartet",
"activity_host_stopping": "Host wird beendet",
"action_stop_session": "Sitzung beenden",
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
"connect_link": "Deep-Link",
"connect_copy": "Kopieren",
"host_identity": "Identität",
"host_hostname": "Hostname",
"host_os": "Betriebssystem",
+27
View File
@@ -77,9 +77,36 @@
"stream_packet_size": "Packet size",
"stream_min_fec": "FEC floor",
"stream_bitrate": "Bitrate",
"activity_title": "Recent activity",
"activity_empty": "Nothing yet — events show up here as they happen on the host.",
"activity_client_connected": "Connected",
"activity_client_disconnected": "Disconnected",
"activity_session_started": "Session started",
"activity_session_ended": "Session ended",
"activity_stream_started": "Stream started",
"activity_stream_stopped": "Stream stopped",
"activity_game_running": "Game running",
"activity_game_exited": "Game exited",
"activity_pairing_pending": "Pairing requested",
"activity_pairing_completed": "Paired",
"activity_pairing_denied": "Pairing denied",
"activity_display_created": "Display created",
"activity_display_released": "Display released",
"activity_library_changed": "Library changed",
"activity_update_available": "Update available",
"activity_update_applied": "Update applied",
"activity_plugins_changed": "Plugins changed",
"activity_store_changed": "Store changed",
"activity_host_started": "Host started",
"activity_host_stopping": "Host stopping",
"action_stop_session": "Stop session",
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
"connect_link": "Deep link",
"connect_copy": "Copy",
"host_identity": "Identity",
"host_hostname": "Hostname",
"host_os": "Operating system",
+19
View File
@@ -0,0 +1,19 @@
{
"name": "Punktfunk",
"short_name": "Punktfunk",
"description": "Management console for a punktfunk streaming host.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0f",
"theme_color": "#6c5bf3",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any"
}
]
}
+3
View File
@@ -147,6 +147,9 @@ export function isPublicPath(pathname: string): boolean {
if (pathname.startsWith("/_auth/")) return true;
if (pathname.startsWith("/assets/")) return true;
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
// visitor cannot already see from the login page (name, colours, the brand mark).
if (pathname === "/manifest.webmanifest") return true;
return false;
}
+68 -2
View File
@@ -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;
+8
View File
@@ -26,11 +26,19 @@ export const Route = createRootRouteWithContext<RouterContext>()({
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "color-scheme", content: "dark light" },
{ name: "theme-color", content: "#6c5bf3" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-title", content: "Punktfunk" },
{ title: "Punktfunk" },
],
links: [
{ rel: "stylesheet", href: appCss },
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
// Installable on a phone — this console is used from a couch as often as from a desk,
// and a home-screen launcher beats retyping a LAN IP. Standalone display, no service
// worker: an offline shell for a console whose every screen is live host state would
// only ever show stale numbers convincingly.
{ rel: "manifest", href: "/manifest.webmanifest" },
],
}),
component: RootComponent,
+127
View File
@@ -0,0 +1,127 @@
import { Activity as ActivityIcon } from "lucide-react";
import type { FC } from "react";
import { type ActivityEntry, useActivity } from "@/api/events";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtDateTime } from "@/lib/format";
import { m } from "@/paraglide/messages";
/**
* What this host has been doing — the event stream, rendered.
*
* 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.
* The stream was already open for cache invalidation, so this costs one ring buffer.
*
* In-memory and bounded, so it starts empty on a page load and fills as things happen. That is the
* honest shape for a live tail — pretending to be a durable log would need the host to keep one.
*/
export const ActivityCard: FC = () => {
const entries = useActivity();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ActivityIcon className="size-4" />
{m.activity_title()}
</CardTitle>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.activity_empty()}</p>
) : (
<ul className="flex flex-col divide-y">
{entries.map((e) => (
<li
key={e.seq}
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0"
>
<Badge variant={toneFor(e.kind)}>{kindLabel(e.kind)}</Badge>
<span className="min-w-0 flex-1 truncate text-sm">
{describe(e)}
</span>
<time
dateTime={new Date(e.ts_ms).toISOString()}
className="shrink-0 text-xs tabular-nums text-muted-foreground"
>
{fmtDateTime(e.ts_ms)}
</time>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
};
/** The subject of an event, in one line — whatever the payload actually names. */
function describe(e: ActivityEntry): string {
const d = e.data;
const client = pick(d.client, "name") ?? pick(d.session, "client");
const stream = d.stream as Record<string, unknown> | undefined;
const parts = [
client,
typeof stream?.app === "string" ? stream.app : undefined,
typeof d.reason === "string" ? d.reason : undefined,
typeof d.game === "string" ? d.game : undefined,
].filter((x): x is string => typeof x === "string" && x.length > 0);
// An event whose payload names nothing (host.started, library.changed) is still worth a row —
// the kind badge carries the whole meaning, so leave the line blank rather than inventing text.
return parts.join(" · ");
}
/** Read a string field off a nested ref object, tolerating anything unexpected. */
function pick(obj: unknown, key: string): string | undefined {
if (!obj || typeof obj !== "object") return undefined;
const v = (obj as Record<string, unknown>)[key];
if (typeof v === "string") return v;
// `SessionRef.client` is itself a ClientRef.
if (v && typeof v === "object") {
const name = (v as Record<string, unknown>).name;
return typeof name === "string" ? name : undefined;
}
return undefined;
}
/** Colour by what the event means, not by its domain — good news green, losses muted, denials red. */
function toneFor(
kind: string,
): "success" | "destructive" | "secondary" | "outline" {
if (kind === "pairing.denied") return "destructive";
if (kind.endsWith(".connected") || kind.endsWith(".started"))
return "success";
if (kind === "pairing.completed") return "success";
if (kind.endsWith(".disconnected") || kind.endsWith(".ended"))
return "outline";
if (kind.endsWith(".stopped") || kind.endsWith(".exited")) return "outline";
return "secondary";
}
/** Translated label per kind, falling back to the raw kind so a new host event still shows. */
const KIND_LABEL: Record<string, () => string> = {
"client.connected": () => m.activity_client_connected(),
"client.disconnected": () => m.activity_client_disconnected(),
"session.started": () => m.activity_session_started(),
"session.ended": () => m.activity_session_ended(),
"stream.started": () => m.activity_stream_started(),
"stream.stopped": () => m.activity_stream_stopped(),
"game.running": () => m.activity_game_running(),
"game.exited": () => m.activity_game_exited(),
"pairing.pending": () => m.activity_pairing_pending(),
"pairing.completed": () => m.activity_pairing_completed(),
"pairing.denied": () => m.activity_pairing_denied(),
"display.created": () => m.activity_display_created(),
"display.released": () => m.activity_display_released(),
"library.changed": () => m.activity_library_changed(),
"update.available": () => m.activity_update_available(),
"update.applied": () => m.activity_update_applied(),
"plugins.changed": () => m.activity_plugins_changed(),
"store.changed": () => m.activity_store_changed(),
"host.started": () => m.activity_host_started(),
"host.stopping": () => m.activity_host_stopping(),
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind]?.() ?? kind;
}
+4
View File
@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fmtNumber } from "@/lib/format";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ActivityCard } from "./Activity";
import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
@@ -181,6 +182,9 @@ export const DashboardView: FC<{
)}
</CardContent>
</Card>
{/* Below the session card: the past, under the present. */}
<ActivityCard />
</div>
)}
</QueryState>
+78
View File
@@ -0,0 +1,78 @@
import { Check, Copy, Smartphone } from "lucide-react";
import { type FC, useState } from "react";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* "Get a device onto this host" — the address to type, and the deep link that skips typing it.
*
* The console knew the host's identity and local address 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. `punktfunk://connect/<unique_id>` is the shipped client grammar
* (clients/shared/deeplink-vectors.json — the Rust, Swift and Kotlin parsers all test against it),
* so a client that is already installed opens straight onto this host.
*
* No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none.
* The link is short enough to send over any chat app, which is what people actually do.
*/
export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => {
const deepLink = `punktfunk://connect/${host.uniqueid}`;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.connect_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.connect_help()}
</p>
<CopyRow label={m.connect_address()} value={host.local_ip} />
<CopyRow label={m.connect_link()} value={deepLink} />
</CardContent>
</Card>
);
};
/** One labelled, monospaced value with a copy button — the point of the card. */
const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
// Revert the affordance rather than leaving a permanent tick, which would stop reading as
// feedback the second time you press it.
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied (insecure origin, or the user said no) — the value is on screen and
// selectable, so there is nothing worth interrupting them about.
}
};
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs">
{value}
</code>
<Button
variant="outline"
size="icon"
aria-label={m.connect_copy()}
onClick={copy}
>
{copied ? (
<Check className="size-4 text-[var(--success)]" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
};
+2
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { ConnectCard } from "./ConnectCard";
export const HostView: FC<{
host: Loadable<HostInfo>;
@@ -27,6 +28,7 @@ export const HostView: FC<{
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
{conflicts}
{h && <ConnectCard host={h} />}
<QueryState
isLoading={host.isLoading}