diff --git a/web/messages/de.json b/web/messages/de.json index 014437ee..3a51bf70 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index 4e063544..6b095454 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest new file mode 100644 index 00000000..413c86bc --- /dev/null +++ b/web/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index c0c8bdb8..163a165c 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -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; } diff --git a/web/src/api/events.ts b/web/src/api/events.ts index b22a3dce..ab500e3c 100644 --- a/web/src/api/events.ts +++ b/web/src/api/events.ts @@ -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; +} + +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).data; + if (typeof raw !== "string") return; + try { + const data = JSON.parse(raw) as Record; + 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; diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 2c62560a..df63434a 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -26,11 +26,19 @@ export const Route = createRootRouteWithContext()({ { 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, diff --git a/web/src/sections/Dashboard/Activity.tsx b/web/src/sections/Dashboard/Activity.tsx new file mode 100644 index 00000000..fa759dd5 --- /dev/null +++ b/web/src/sections/Dashboard/Activity.tsx @@ -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 ( + + + + + {m.activity_title()} + + + + {entries.length === 0 ? ( +

{m.activity_empty()}

+ ) : ( +
    + {entries.map((e) => ( +
  • + {kindLabel(e.kind)} + + {describe(e)} + + +
  • + ))} +
+ )} +
+
+ ); +}; + +/** 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 | 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)[key]; + if (typeof v === "string") return v; + // `SessionRef.client` is itself a ClientRef. + if (v && typeof v === "object") { + const name = (v as Record).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> = { + "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; +} diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index b0ce0996..81d3dd9b 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -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<{ )} + + {/* Below the session card: the past, under the present. */} + )} diff --git a/web/src/sections/Host/ConnectCard.tsx b/web/src/sections/Host/ConnectCard.tsx new file mode 100644 index 00000000..f3938bfe --- /dev/null +++ b/web/src/sections/Host/ConnectCard.tsx @@ -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/` 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 ( + + + + + {m.connect_title()} + + + +

+ {m.connect_help()} +

+ + +
+
+ ); +}; + +/** 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 ( +
+

{label}

+
+ + {value} + + +
+
+ ); +}; diff --git a/web/src/sections/Host/view.tsx b/web/src/sections/Host/view.tsx index 855a00c7..a239b72a 100644 --- a/web/src/sections/Host/view.tsx +++ b/web/src/sections/Host/view.tsx @@ -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; @@ -27,6 +28,7 @@ export const HostView: FC<{

{m.nav_host()}

{conflicts} + {h && }