diff --git a/web/messages/de.json b/web/messages/de.json index a5890991..0f3000b5 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -17,6 +17,7 @@ "status_streaming": "Aktiv", "status_idle": "Inaktiv", "status_session": "Sitzung", + "status_sessions_active": "{count} aktiv", "status_no_session": "Keine aktive Sitzung", "status_paired_count": "Gekoppelte Geräte", "status_pin_pending": "Kopplungs-PIN ausstehend", @@ -63,6 +64,7 @@ "display_keep_alive": "Nach Trennung aktiv halten", "display_keep_alive_off": "Aus", "display_keep_alive_keep": "Behalten für", + "display_keep_alive_forever": "Dauerhaft", "display_keep_alive_seconds": "Sekunden", "display_topology": "Topologie", "display_topology_auto": "Automatisch", @@ -137,8 +139,8 @@ "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", "pairing_pin_label": "PIN", "pairing_submit": "PIN bestätigen", - "pairing_success": "Erfolgreich gekoppelt.", - "pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.", + "pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.", + "pairing_failed": "PIN konnte nicht übermittelt werden — prüfe, ob sie stimmt und der Client noch auf die Kopplung wartet.", "pairing_native_title": "Gerät koppeln", "pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner Punktfunk-App ein, um dieses Gerät zu koppeln.", "pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um Punktfunk-Geräte zu koppeln.", @@ -172,6 +174,7 @@ "library_field_portrait": "Portrait-Bild-URL", "library_field_hero": "Hero-Bild-URL", "library_field_header": "Header-Bild-URL", + "library_field_logo": "Logo-Bild-URL", "library_field_command": "Startbefehl", "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", "library_save": "Speichern", @@ -200,6 +203,7 @@ "login_error": "Falsches Passwort.", "login_signing_in": "Anmeldung läuft…", "action_logout": "Abmelden", + "settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.", "nav_stats": "Leistung", "nav_logs": "Logs", "logs_title": "Logs", @@ -233,6 +237,8 @@ "stats_fps_new": "Neue fps", "stats_fps_repeat": "Wiederholte fps", "stats_mbps": "Mb/s", + "stats_bitrate_target": "Ziel-Mb/s", + "stats_health_gamestream_note": "Paketverlust, Sende-Drops und FEC-Wiederherstellung sind empfängerseitige Werte, die der GameStream-Pfad nicht erfasst — hier werden nur Frame-Drops angezeigt.", "stats_p99": "p99", "stats_p50": "p50", "stats_frames_dropped": "Verworfene Frames", diff --git a/web/messages/en.json b/web/messages/en.json index 4840b827..45c8e2d3 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -17,6 +17,7 @@ "status_streaming": "Streaming", "status_idle": "Idle", "status_session": "Session", + "status_sessions_active": "{count} active", "status_no_session": "No active session", "status_paired_count": "Paired clients", "status_pin_pending": "Pairing PIN pending", @@ -63,6 +64,7 @@ "display_keep_alive": "Keep alive after disconnect", "display_keep_alive_off": "Off", "display_keep_alive_keep": "Keep for", + "display_keep_alive_forever": "Forever", "display_keep_alive_seconds": "seconds", "display_topology": "Topology", "display_topology_auto": "Automatic", @@ -137,8 +139,8 @@ "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", "pairing_pin_label": "PIN", "pairing_submit": "Submit PIN", - "pairing_success": "Paired successfully.", - "pairing_failed": "Pairing failed — check the PIN and try again.", + "pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.", + "pairing_failed": "Couldn't submit the PIN — check it's correct and that the client is still waiting to pair.", "pairing_native_title": "Pair a device", "pairing_native_desc": "Show a one-time PIN here, then enter it in your Punktfunk app to pair this device.", "pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair Punktfunk devices.", @@ -172,6 +174,7 @@ "library_field_portrait": "Portrait art URL", "library_field_hero": "Hero art URL", "library_field_header": "Header art URL", + "library_field_logo": "Logo art URL", "library_field_command": "Launch command", "library_field_command_help": "Optional. The command the host runs to launch this title.", "library_save": "Save", @@ -200,6 +203,7 @@ "login_error": "Wrong password.", "login_signing_in": "Signing in…", "action_logout": "Sign out", + "settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.", "nav_stats": "Performance", "nav_logs": "Logs", "logs_title": "Logs", @@ -233,6 +237,8 @@ "stats_fps_new": "New fps", "stats_fps_repeat": "Repeat fps", "stats_mbps": "Mb/s", + "stats_bitrate_target": "Target Mb/s", + "stats_health_gamestream_note": "Packet loss, send-drops, and FEC recovery are receiver-side metrics the GameStream path doesn't instrument — only frame drops are shown here.", "stats_p99": "p99", "stats_p50": "p50", "stats_frames_dropped": "Frames dropped", diff --git a/web/server/routes/api/[...].ts b/web/server/routes/api/[...].ts index 2b716f1f..e4b5aed4 100644 --- a/web/server/routes/api/[...].ts +++ b/web/server/routes/api/[...].ts @@ -4,6 +4,7 @@ // request, then proxy. The management API itself binds loopback only — this proxy is the // ONLY path to it from the LAN, and it's authenticated. import { + createError, defineEventHandler, getRequestURL, proxyRequest, @@ -46,5 +47,19 @@ export default defineEventHandler((event) => { // Don't forward the session cookie to the management API. cookie: "", }, + onResponse: (_event, response) => { + // This handler only runs AFTER the gate (middleware/auth.ts) confirmed a valid session, so + // a 401 HERE is the management API rejecting OUR host token — a server/deploy misconfig, not + // an expired user session. Forwarding it would make the browser bounce a logged-in user to + // /login, where re-auth succeeds but the next call 401s again → a redirect loop. Surface it + // as a 502 (upstream failure) so the console shows an error instead of looping. + if (response.status === 401) { + throw createError({ + statusCode: 502, + statusMessage: + "management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)", + }); + } + }, }); }); diff --git a/web/src/api/fetcher.ts b/web/src/api/fetcher.ts index a53bcf57..5e6b9699 100644 --- a/web/src/api/fetcher.ts +++ b/web/src/api/fetcher.ts @@ -43,7 +43,10 @@ export async function apiFetch( function redirectToLogin(): void { if (typeof window === "undefined") return; if (window.location.pathname === "/login") return; - const next = encodeURIComponent(window.location.pathname); + // Keep the full path (query + hash too), so re-login returns to the exact view. + const next = encodeURIComponent( + window.location.pathname + window.location.search + window.location.hash, + ); window.location.href = `/login?next=${next}`; } diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index 766ca490..a8b0319f 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -1,21 +1,61 @@ -// Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and -// `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the -// locale changes (Paraglide's localStorage strategy persists the choice across reloads). +// Thin reactive layer over Paraglide, made SSR-safe. +// +// The problem it solves: Paraglide's client strategies (localStorage / preferredLanguage) resolve +// the locale synchronously at load, so a `de` user's FIRST client render used German while the +// server render (which has no localStorage / no request-locale wiring) used the base locale `en` — +// a hydration mismatch plus a flash of English on every full page load. +// +// The fix: take over the READ side via `overwriteGetLocale`, returning a single `rendered` locale +// that starts at `baseLocale`. So the server render AND the client's first (hydration) render agree +// — no mismatch. After hydration, `adoptStoredLocale()` (called once from the root) switches to the +// user's persisted/browser choice: one clean transition instead of a mismatch. Paraglide's strategy +// still PERSISTS the choice (`setLocale` writes localStorage); we only own the read side. import { useSyncExternalStore } from "react"; -import { getLocale, locales, setLocale } from "@/paraglide/runtime"; +import { + baseLocale, + isLocale, + locales, + localStorageKey, + overwriteGetLocale, + setLocale, +} from "@/paraglide/runtime"; /** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */ export type Locale = (typeof locales)[number]; +// The locale every `m.*()` renders in. Never mutated on the server (so SSR is always `baseLocale`, +// identical across concurrent requests); mutated only in the browser, via `changeLocale`. +let rendered: Locale = baseLocale as Locale; +overwriteGetLocale(() => rendered); + const listeners = new Set<() => void>(); -/** Switch locale and notify subscribers (Paraglide also persists it per its strategy). */ +/** Switch locale and notify subscribers. Persists via Paraglide's strategy (localStorage). */ export function changeLocale(locale: Locale) { + rendered = locale; // `reload: false` keeps the SPA mounted; we re-render via the store below. setLocale(locale, { reload: false }); + if (typeof document !== "undefined") document.documentElement.lang = locale; for (const l of listeners) l(); } +/** + * Adopt the user's persisted (localStorage) or browser (navigator) locale — call ONCE from a root + * effect, AFTER hydration, so the switch never races the initial render (which must match SSR). + */ +export function adoptStoredLocale() { + if (typeof window === "undefined") return; + let target: Locale = baseLocale as Locale; + const stored = window.localStorage?.getItem(localStorageKey); + if (stored && isLocale(stored)) { + target = stored; + } else { + const nav = navigator.language?.slice(0, 2); + if (nav && isLocale(nav)) target = nav; + } + if (target !== rendered) changeLocale(target); +} + function subscribe(cb: () => void) { listeners.add(cb); return () => listeners.delete(cb); @@ -23,7 +63,11 @@ function subscribe(cb: () => void) { /** Current locale, reactive — components using `m.*` should read this so they re-render. */ export function useLocale(): Locale { - return useSyncExternalStore(subscribe, getLocale, () => "en" as Locale); + return useSyncExternalStore( + subscribe, + () => rendered, + () => baseLocale as Locale, + ); } export { locales }; diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 58b80678..2fdb4c84 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -10,7 +10,9 @@ import { } from "@tanstack/react-router"; import "@fontsource-variable/geist"; import { Toaster } from "@unom/ui/toast"; +import { useEffect } from "react"; import { AppShell } from "@/components/app-shell"; +import { adoptStoredLocale } from "@/lib/i18n"; import appCss from "@/styles.css?url"; export interface RouterContext { @@ -34,6 +36,11 @@ export const Route = createRootRouteWithContext()({ }); function RootComponent() { + // Adopt the persisted/browser locale AFTER hydration — the initial render stays at the base + // locale to match SSR (see lib/i18n.ts), so this is the single, mismatch-free locale switch. + useEffect(() => { + adoptStoredLocale(); + }, []); // The login screen renders bare (no sidebar); everything else gets the app shell. const isLogin = useRouterState({ select: (s) => s.location.pathname === "/login", diff --git a/web/src/sections/Dashboard/view.tsx b/web/src/sections/Dashboard/view.tsx index e40f7041..78bfcf90 100644 --- a/web/src/sections/Dashboard/view.tsx +++ b/web/src/sections/Dashboard/view.tsx @@ -66,6 +66,11 @@ export const DashboardView: FC<{ {m.status_session()} + {s.active_sessions > 1 && ( + + {m.status_sessions_active({ count: s.active_sessions })} + + )}
+ {ka.mode === "duration" && (
{ return (
- {kept.length > 0 && ( -
- -
- )} - {displays.length === 0 ? ( -

{m.display_none_live()}

- ) : ( -
    - {displays.map((d) => ( - doRelease(d.slot)} - /> - ))} -
- )} + {/* Wrap in QueryState (like the settings card) so a failed/in-flight `/display/state` + fetch surfaces as loading/error instead of masquerading as "no live displays". */} + + {kept.length > 0 && ( +
+ +
+ )} + {displays.length === 0 ? ( +

{m.display_none_live()}

+ ) : ( +
    + {displays.map((d) => ( + doRelease(d.slot)} + /> + ))} +
+ )} +
); @@ -777,7 +808,15 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => const onSave = () => saveLayout.mutate( { data: { positions: cur } }, - { onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) }, + { + onSuccess: () => { + qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }); + // The layout save also rewrites the POLICY (switches `layout` to manual and locks + // in the current Custom fields), so refresh the settings card too — otherwise its + // preset ring / effective badges show pre-arrange values until a manual reload. + qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); + }, + }, ); return ( diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index ea2779a6..fb01a356 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -20,6 +20,7 @@ interface FormState { portrait: string; hero: string; header: string; + logo: string; command: string; } @@ -28,6 +29,7 @@ const emptyForm: FormState = { portrait: "", hero: "", header: "", + logo: "", command: "", }; @@ -37,11 +39,14 @@ function formFrom(entry: GameEntry): FormState { portrait: entry.art.portrait ?? "", hero: entry.art.hero ?? "", header: entry.art.header ?? "", + logo: entry.art.logo ?? "", command: entry.launch?.kind === "command" ? entry.launch.value : "", }; } -/** Map the form to the API body — only attach `launch` when a command was given. */ +/** Map the form to the API body — only attach `launch` when a command was given. `update_custom` + * REPLACES the whole `art`, so every field the form knows must round-trip (else editing a game with + * a `logo` would silently drop it). */ function toInput(f: FormState): CustomInput { const trim = (s: string) => { const t = s.trim(); @@ -54,6 +59,7 @@ function toInput(f: FormState): CustomInput { portrait: trim(f.portrait), hero: trim(f.hero), header: trim(f.header), + logo: trim(f.logo), }, launch: command ? { kind: "command", value: command } : null, }; @@ -177,6 +183,16 @@ export const GameForm: FC<{ } />
+
+ + setForm((f) => ({ ...f, logo: e.target.value }))} + /> +
void }> = ({ library={library} onEdit={onEdit} onDelete={onDelete} - isDeleting={remove.isPending} + // The custom id whose delete is in flight (if any), so only that card's button disables. + deletingId={remove.isPending ? (remove.variables?.id ?? null) : null} /> ); }; @@ -48,8 +49,9 @@ export const LibraryGrid: FC<{ library: Loadable; onEdit: (entry: GameEntry) => void; onDelete: (entry: GameEntry) => void; - isDeleting: boolean; -}> = ({ library, onEdit, onDelete, isDeleting }) => { + /** Custom id of the card whose delete is in flight, or null — only that card disables. */ + deletingId: string | null; +}> = ({ library, onEdit, onDelete, deletingId }) => { const games = library.data ?? []; return ( onEdit(game)} onDelete={() => onDelete(game)} - deleting={isDeleting} + deleting={deletingId === customId(game)} /> ))} diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 5f20af08..6db28b2b 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -48,7 +48,14 @@ export const LogsSection: FC = () => { const data = query.data; useEffect(() => { if (!data || data.entries.length === 0) return; - setEntries((prev) => [...prev, ...data.entries].slice(-KEEP)); + setEntries((prev) => { + // Only append entries newer than what we already hold — dedup by the monotonic `seq`. + // Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from + // appending the same page twice (duplicate rows + duplicate React keys). + const lastSeq = prev.at(-1)?.seq ?? -1; + const fresh = data.entries.filter((e) => e.seq > lastSeq); + return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev; + }); setDropped((d) => d || data.dropped); setCursor(data.next); }, [data]); @@ -123,9 +130,7 @@ export const LogsCard: FC<{ className="max-w-xs" />
- {dropped && ( - {m.logs_dropped()} - )} + {dropped && {m.logs_dropped()}} + {/* A 204 means the PIN was DELIVERED to the waiting handshake, not that pairing + succeeded — the ceremony verifies it out-of-band. So report "sent", not + "paired", and let the operator confirm via the Paired devices list. */} {isSuccess && ( -

- - {m.pairing_success()} +

+ + {m.pairing_pin_sent()}

)} {isError && ( diff --git a/web/src/sections/Pairing/NativePairingCard.tsx b/web/src/sections/Pairing/NativePairingCard.tsx index bf23b1c1..4c1b0829 100644 --- a/web/src/sections/Pairing/NativePairingCard.tsx +++ b/web/src/sections/Pairing/NativePairingCard.tsx @@ -1,9 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { KeyRound, Smartphone, Timer } from "lucide-react"; -import type { FC } from "react"; +import { type FC, useEffect, useRef } from "react"; import type { NativePairStatus } from "@/api/gen/model/nativePairStatus"; import { getGetNativePairingQueryKey, + getListNativeClientsQueryKey, useArmNativePairing, useDisarmNativePairing, useGetNativePairing, @@ -32,6 +33,23 @@ export const NativePairingSection: FC = () => { const arm = useArmNativePairing(); const disarm = useDisarmNativePairing(); + // A device pairs via the QUIC PIN ceremony, NOT through approve/deny, so nothing else + // invalidates the paired-devices list on the happy path — it would stay stale until remount. + // The status poll's `paired_clients` count is the pairing signal: when it rises, refresh the + // list so the newly paired device appears immediately. + const pairedCount = native.data?.paired_clients; + const prevPairedCount = useRef(pairedCount); + useEffect(() => { + if ( + prevPairedCount.current !== undefined && + pairedCount !== undefined && + pairedCount !== prevPairedCount.current + ) { + qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }); + } + prevPairedCount.current = pairedCount; + }, [pairedCount, qc]); + const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); const onArm = () => diff --git a/web/src/sections/Pairing/PairedDevices.tsx b/web/src/sections/Pairing/PairedDevices.tsx index cd966471..61821381 100644 --- a/web/src/sections/Pairing/PairedDevices.tsx +++ b/web/src/sections/Pairing/PairedDevices.tsx @@ -86,6 +86,17 @@ export const PairedDevicesSection: FC = () => { } }; + // The fingerprint of the row whose unpair is in flight (if any) — so only THAT row's button + // disables, not every row's. + const pendingFingerprint = + (unpairNative.isPending + ? unpairNative.variables?.fingerprint + : undefined) ?? + (unpairMoonlight.isPending + ? unpairMoonlight.variables?.fingerprint + : undefined) ?? + null; + return ( { moonlight.refetch(); }} onUnpair={onUnpair} - isUnpairing={unpairNative.isPending || unpairMoonlight.isPending} + pendingFingerprint={pendingFingerprint} /> ); }; @@ -108,8 +119,9 @@ export const PairedDevices: FC<{ error: unknown; refetch: () => void; onUnpair: (protocol: PairedProtocol, fingerprint: string) => void; - isUnpairing: boolean; -}> = ({ rows, isLoading, error, refetch, onUnpair, isUnpairing }) => ( + /** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */ + pendingFingerprint: string | null; +}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => (

{m.pairing_native_devices()}

@@ -152,7 +164,7 @@ export const PairedDevices: FC<{ variant="ghost" size="icon" aria-label={m.action_unpair()} - disabled={isUnpairing} + disabled={pendingFingerprint === r.fingerprint} onClick={() => onUnpair(r.protocol, r.fingerprint)} > diff --git a/web/src/sections/Pairing/PendingDevices.tsx b/web/src/sections/Pairing/PendingDevices.tsx index 655ed26a..0ec15637 100644 --- a/web/src/sections/Pairing/PendingDevices.tsx +++ b/web/src/sections/Pairing/PendingDevices.tsx @@ -42,12 +42,18 @@ export const PendingDevicesSection: FC = () => { }; const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh }); + // The id of the row whose approve/deny is in flight — only that row's buttons disable. + const pendingId = + (approve.isPending ? approve.variables?.id : undefined) ?? + (deny.isPending ? deny.variables?.id : undefined) ?? + null; + return ( ); }; @@ -61,8 +67,9 @@ export const PendingDevices: FC<{ pending: Loadable; onApprove: (id: number, currentName: string) => void; onDeny: (id: number) => void; - busy: boolean; -}> = ({ pending, onApprove, onDeny, busy }) => { + /** Id of the row whose approve/deny is in flight, or null — only that row disables. */ + pendingId: number | null; +}> = ({ pending, onApprove, onDeny, pendingId }) => { const rows = pending.data ?? []; // Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow // a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other @@ -104,7 +111,7 @@ export const PendingDevices: FC<{
)} diff --git a/web/src/sections/Stats/LiveCard.tsx b/web/src/sections/Stats/LiveCard.tsx index 78d9dc95..a9706f8b 100644 --- a/web/src/sections/Stats/LiveCard.tsx +++ b/web/src/sections/Stats/LiveCard.tsx @@ -1,9 +1,11 @@ import type { FC } from "react"; +import { ApiError } from "@/api/fetcher"; import type { Capture } from "@/api/gen/model/capture"; import { useStatsCaptureLive, useStatsCaptureStatus, } from "@/api/gen/stats/stats"; +import { QueryState } from "@/components/query-state"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Loadable } from "@/lib/query"; import { m } from "@/paraglide/messages"; @@ -28,29 +30,37 @@ export const LiveSection: FC = () => { /** Live graphs while a capture is armed: latency stack + throughput. */ export const LiveCard: FC<{ live: Loadable }> = ({ live }) => { const samples = live.data?.samples ?? []; + // A 404 is the expected transient right after arming (the capture isn't there yet) — treat it as + // "waiting". Surface any OTHER error (500, network drop) instead of silently showing "waiting". + const error = + live.error instanceof ApiError && live.error.status === 404 + ? null + : live.error; return ( {m.stats_live_title()} - {samples.length === 0 ? ( -

- {m.stats_live_waiting()} -

- ) : ( - <> - - - - - - - - )} + + {samples.length === 0 ? ( +

+ {m.stats_live_waiting()} +

+ ) : ( + <> + + + + + + + + )} +
); diff --git a/web/src/sections/Stats/charts.tsx b/web/src/sections/Stats/charts.tsx index fca07f60..7f840ce4 100644 --- a/web/src/sections/Stats/charts.tsx +++ b/web/src/sections/Stats/charts.tsx @@ -2,7 +2,7 @@ // CLIENT-ONLY (behind 's mounted guard): recharts' ResponsiveContainer // measures its parent via ResizeObserver, which has no width during SSR and would // otherwise render a 0×0 (or warn). The charts adapt to whatever stages a sample -// carries — native (capture/submit/encode/send) and gamestream +// carries — native (queue/capture/submit/encode/send) and gamestream // (capture/encode/packetize/send) both stack sensibly. import { type ReactElement, useEffect, useState } from "react"; import { @@ -37,6 +37,9 @@ const legendStyle = { fontSize: 12 } as const; // Known stages get a stable hue; anything else falls back to the palette by // appearance order, so an unexpected stage name still renders a distinct band. const STAGE_COLORS: Record = { + // `queue` (native pipeline's first stage) needs its own hue — without it, it fell back to + // PALETTE[0], which is capture's exact purple, making the bottom two bands indistinguishable. + queue: "#64748b", capture: "#6c5bf3", submit: "#22a2f2", encode: "#f2a922", @@ -146,13 +149,15 @@ export function LatencyChart({ ); } -/** New vs repeat fps (left axis) + tx goodput Mb/s (right axis). */ +/** New vs repeat fps (left axis) + tx goodput Mb/s vs the configured target (right axis). */ export function ThroughputChart({ samples }: { samples: StatsSample[] }) { const rows = samples.map((s) => ({ t: Math.round(s.t_ms / 1000), fps: s.fps, repeat: s.repeat_fps, mbps: s.mbps, + // The configured encoder target (kbps → Mb/s) so goodput can be read against it. + target: s.bitrate_kbps / 1000, })); return ( @@ -203,13 +208,31 @@ export function ThroughputChart({ samples }: { samples: StatsSample[] }) { dot={false} isAnimationActive={false} /> + ); } -/** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. */ -export function HealthChart({ samples }: { samples: StatsSample[] }) { +/** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. On the + * GameStream path only `frames` is instrumented (the receiver-side counters stay 0), so a `kind` + * of "gamestream" surfaces a note instead of letting three flat-zero lines read as "no loss". */ +export function HealthChart({ + samples, + kind, +}: { + samples: StatsSample[]; + kind?: string; +}) { const rows = samples.map((s) => ({ t: Math.round(s.t_ms / 1000), frames: s.frames_dropped, @@ -218,51 +241,61 @@ export function HealthChart({ samples }: { samples: StatsSample[] }) { fec: s.fec_recovered, })); return ( - - - - - - - - - - - - - + <> + {kind === "gamestream" && ( +

+ {m.stats_health_gamestream_note()} +

+ )} + + + + + + + + + + + + + + ); } diff --git a/web/src/stories/Library.stories.tsx b/web/src/stories/Library.stories.tsx index 5bcaa728..391ff75e 100644 --- a/web/src/stories/Library.stories.tsx +++ b/web/src/stories/Library.stories.tsx @@ -10,6 +10,7 @@ const emptyForm = { portrait: "", hero: "", header: "", + logo: "", command: "", }; @@ -29,7 +30,7 @@ export const Populated: Story = { library={{ data: library, ...idle }} onEdit={noop} onDelete={noop} - isDeleting={false} + deletingId={null} /> ), }; @@ -40,7 +41,7 @@ export const Empty: Story = { library={{ data: [], ...idle }} onEdit={noop} onDelete={noop} - isDeleting={false} + deletingId={null} /> ), }; diff --git a/web/src/stories/Pairing.stories.tsx b/web/src/stories/Pairing.stories.tsx index 6f769fa2..a260b63b 100644 --- a/web/src/stories/Pairing.stories.tsx +++ b/web/src/stories/Pairing.stories.tsx @@ -36,7 +36,7 @@ export const Armed: Story = { pending={{ data: pendingDevices, ...idle }} onApprove={noop} onDeny={noop} - busy={false} + pendingId={null} /> ), native: ( @@ -77,7 +77,7 @@ export const Armed: Story = { error={null} refetch={noop} onUnpair={noop} - isUnpairing={false} + pendingFingerprint={null} /> ), }, diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index d5449739..fd79c530 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -17,7 +17,8 @@ import type { StatsStatus } from "@/api/gen/model/statsStatus"; export const hostInfo: HostInfo = { abi_version: 2, app_version: "7.1.450.0", - codecs: ["h264", "h265", "av1"], + codecs: ["h264", "hevc", "av1"], + gamestream: true, gfe_version: "3.23.0.74", hostname: "ENRICOS-DESKTOP", local_ip: "192.168.1.173", @@ -46,9 +47,10 @@ export const statusActive: RuntimeStatus = { audio_streaming: true, paired_clients: 3, pin_pending: false, + active_sessions: 1, session: { width: 5120, height: 1440, fps: 240 }, stream: { - codec: "h265", + codec: "hevc", width: 5120, height: 1440, fps: 240, @@ -63,6 +65,7 @@ export const statusIdle: RuntimeStatus = { audio_streaming: false, paired_clients: 1, pin_pending: true, + active_sessions: 0, session: null, stream: null, }; @@ -128,6 +131,7 @@ export const statsStatusIdle: StatsStatus = { kind: "native", sample_count: 0, started_unix_ms: 0, + elapsed_ms: 0, }; // A native-path pipeline: capture → submit → encode → send. Deterministic (no @@ -171,7 +175,7 @@ export const captureMetas: CaptureMeta[] = [ id: "cap-20260628-2041", client: "enricos-macbook", kind: "native", - codec: "h265", + codec: "hevc", width: 5120, height: 1440, fps: 240,