fix(web): console sweep — pairing, displays, stats, logs, auth, i18n
ci / rust (push) Failing after 45s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
ci / docs-site (push) Successful in 1m6s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 15m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
arch / build-publish (push) Successful in 20m32s
android / android (push) Successful in 20m50s
deb / build-publish (push) Successful in 20m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m41s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled

Pairing:
- Refresh the paired-devices list after a native PIN pairing (the happy path never
  invalidated it, so a newly paired device stayed hidden until remount).
- Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so
  it now reads "PIN sent" instead of a false "Paired successfully".
- Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could
  never receive a PIN there.
- Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8).

Displays / Library:
- "Arrange displays" save refreshes the settings card (it rewrites the policy), without
  clobbering unsaved Custom edits (re-seed only when the draft still matches the server).
- Live-display list wrapped in QueryState so errors don't read as "no displays".
- "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo
  artwork (was dropped on save); per-card delete pending.

Stats:
- Distinct colour for the native "queue" latency stage (it collided with "capture").
- "Not measured on this path" note on the GameStream health chart; configured-bitrate
  target line on throughput; host-authoritative elapsed timer; LiveCard surfaces
  non-404 errors.

Shell / auth / i18n:
- SSR-stable locale: first client render matches the base-locale SSR (no hydration
  mismatch), then adopts the persisted/browser locale post-hydration.
- BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced
  into a post-login redirect loop.
- Logout checks the POST result before navigating; logs dedup by seq (StrictMode);
  login "next" keeps query/hash; Dashboard shows the active-session count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 21:06:16 +02:00
parent b8da32e8b6
commit 75b3c94f60
24 changed files with 397 additions and 144 deletions
+8 -2
View File
@@ -17,6 +17,7 @@
"status_streaming": "Aktiv", "status_streaming": "Aktiv",
"status_idle": "Inaktiv", "status_idle": "Inaktiv",
"status_session": "Sitzung", "status_session": "Sitzung",
"status_sessions_active": "{count} aktiv",
"status_no_session": "Keine aktive Sitzung", "status_no_session": "Keine aktive Sitzung",
"status_paired_count": "Gekoppelte Geräte", "status_paired_count": "Gekoppelte Geräte",
"status_pin_pending": "Kopplungs-PIN ausstehend", "status_pin_pending": "Kopplungs-PIN ausstehend",
@@ -63,6 +64,7 @@
"display_keep_alive": "Nach Trennung aktiv halten", "display_keep_alive": "Nach Trennung aktiv halten",
"display_keep_alive_off": "Aus", "display_keep_alive_off": "Aus",
"display_keep_alive_keep": "Behalten für", "display_keep_alive_keep": "Behalten für",
"display_keep_alive_forever": "Dauerhaft",
"display_keep_alive_seconds": "Sekunden", "display_keep_alive_seconds": "Sekunden",
"display_topology": "Topologie", "display_topology": "Topologie",
"display_topology_auto": "Automatisch", "display_topology_auto": "Automatisch",
@@ -137,8 +139,8 @@
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:", "pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
"pairing_pin_label": "PIN", "pairing_pin_label": "PIN",
"pairing_submit": "PIN bestätigen", "pairing_submit": "PIN bestätigen",
"pairing_success": "Erfolgreich gekoppelt.", "pairing_pin_sent": "PIN gesendet. Wenn sie stimmt, schließt der Client die Kopplung ab und erscheint unten unter „Gekoppelte Geräte“.",
"pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.", "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_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_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.", "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_portrait": "Portrait-Bild-URL",
"library_field_hero": "Hero-Bild-URL", "library_field_hero": "Hero-Bild-URL",
"library_field_header": "Header-Bild-URL", "library_field_header": "Header-Bild-URL",
"library_field_logo": "Logo-Bild-URL",
"library_field_command": "Startbefehl", "library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
"library_save": "Speichern", "library_save": "Speichern",
@@ -200,6 +203,7 @@
"login_error": "Falsches Passwort.", "login_error": "Falsches Passwort.",
"login_signing_in": "Anmeldung läuft…", "login_signing_in": "Anmeldung läuft…",
"action_logout": "Abmelden", "action_logout": "Abmelden",
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
"nav_stats": "Leistung", "nav_stats": "Leistung",
"nav_logs": "Logs", "nav_logs": "Logs",
"logs_title": "Logs", "logs_title": "Logs",
@@ -233,6 +237,8 @@
"stats_fps_new": "Neue fps", "stats_fps_new": "Neue fps",
"stats_fps_repeat": "Wiederholte fps", "stats_fps_repeat": "Wiederholte fps",
"stats_mbps": "Mb/s", "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_p99": "p99",
"stats_p50": "p50", "stats_p50": "p50",
"stats_frames_dropped": "Verworfene Frames", "stats_frames_dropped": "Verworfene Frames",
+8 -2
View File
@@ -17,6 +17,7 @@
"status_streaming": "Streaming", "status_streaming": "Streaming",
"status_idle": "Idle", "status_idle": "Idle",
"status_session": "Session", "status_session": "Session",
"status_sessions_active": "{count} active",
"status_no_session": "No active session", "status_no_session": "No active session",
"status_paired_count": "Paired clients", "status_paired_count": "Paired clients",
"status_pin_pending": "Pairing PIN pending", "status_pin_pending": "Pairing PIN pending",
@@ -63,6 +64,7 @@
"display_keep_alive": "Keep alive after disconnect", "display_keep_alive": "Keep alive after disconnect",
"display_keep_alive_off": "Off", "display_keep_alive_off": "Off",
"display_keep_alive_keep": "Keep for", "display_keep_alive_keep": "Keep for",
"display_keep_alive_forever": "Forever",
"display_keep_alive_seconds": "seconds", "display_keep_alive_seconds": "seconds",
"display_topology": "Topology", "display_topology": "Topology",
"display_topology_auto": "Automatic", "display_topology_auto": "Automatic",
@@ -137,8 +139,8 @@
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:", "pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
"pairing_pin_label": "PIN", "pairing_pin_label": "PIN",
"pairing_submit": "Submit PIN", "pairing_submit": "Submit PIN",
"pairing_success": "Paired successfully.", "pairing_pin_sent": "PIN sent. If it's correct, the client finishes pairing and appears under Paired devices below.",
"pairing_failed": "Pairing failed — check the PIN and try again.", "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_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_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.", "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_portrait": "Portrait art URL",
"library_field_hero": "Hero art URL", "library_field_hero": "Hero art URL",
"library_field_header": "Header art URL", "library_field_header": "Header art URL",
"library_field_logo": "Logo art URL",
"library_field_command": "Launch command", "library_field_command": "Launch command",
"library_field_command_help": "Optional. The command the host runs to launch this title.", "library_field_command_help": "Optional. The command the host runs to launch this title.",
"library_save": "Save", "library_save": "Save",
@@ -200,6 +203,7 @@
"login_error": "Wrong password.", "login_error": "Wrong password.",
"login_signing_in": "Signing in…", "login_signing_in": "Signing in…",
"action_logout": "Sign out", "action_logout": "Sign out",
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
"nav_stats": "Performance", "nav_stats": "Performance",
"nav_logs": "Logs", "nav_logs": "Logs",
"logs_title": "Logs", "logs_title": "Logs",
@@ -233,6 +237,8 @@
"stats_fps_new": "New fps", "stats_fps_new": "New fps",
"stats_fps_repeat": "Repeat fps", "stats_fps_repeat": "Repeat fps",
"stats_mbps": "Mb/s", "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_p99": "p99",
"stats_p50": "p50", "stats_p50": "p50",
"stats_frames_dropped": "Frames dropped", "stats_frames_dropped": "Frames dropped",
+15
View File
@@ -4,6 +4,7 @@
// request, then proxy. The management API itself binds loopback only — this proxy is the // 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. // ONLY path to it from the LAN, and it's authenticated.
import { import {
createError,
defineEventHandler, defineEventHandler,
getRequestURL, getRequestURL,
proxyRequest, proxyRequest,
@@ -46,5 +47,19 @@ export default defineEventHandler((event) => {
// Don't forward the session cookie to the management API. // Don't forward the session cookie to the management API.
cookie: "", 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)",
});
}
},
}); });
}); });
+4 -1
View File
@@ -43,7 +43,10 @@ export async function apiFetch<T>(
function redirectToLogin(): void { function redirectToLogin(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (window.location.pathname === "/login") 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}`; window.location.href = `/login?next=${next}`;
} }
+50 -6
View File
@@ -1,21 +1,61 @@
// Thin reactive layer over Paraglide. Paraglide's `m.*` message functions and // Thin reactive layer over Paraglide, made SSR-safe.
// `setLocale`/`getLocale` are framework-agnostic; this hook re-renders React when the //
// locale changes (Paraglide's localStorage strategy persists the choice across reloads). // 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 { 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`. */ /** The available locales as a union (`'en' | 'de'`), derived from Paraglide's `locales`. */
export type Locale = (typeof locales)[number]; 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>(); 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) { export function changeLocale(locale: Locale) {
rendered = locale;
// `reload: false` keeps the SPA mounted; we re-render via the store below. // `reload: false` keeps the SPA mounted; we re-render via the store below.
setLocale(locale, { reload: false }); setLocale(locale, { reload: false });
if (typeof document !== "undefined") document.documentElement.lang = locale;
for (const l of listeners) l(); 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) { function subscribe(cb: () => void) {
listeners.add(cb); listeners.add(cb);
return () => listeners.delete(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. */ /** Current locale, reactive — components using `m.*` should read this so they re-render. */
export function useLocale(): Locale { export function useLocale(): Locale {
return useSyncExternalStore(subscribe, getLocale, () => "en" as Locale); return useSyncExternalStore(
subscribe,
() => rendered,
() => baseLocale as Locale,
);
} }
export { locales }; export { locales };
+7
View File
@@ -10,7 +10,9 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import "@fontsource-variable/geist"; import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast"; import { Toaster } from "@unom/ui/toast";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { adoptStoredLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url"; import appCss from "@/styles.css?url";
export interface RouterContext { export interface RouterContext {
@@ -34,6 +36,11 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}); });
function RootComponent() { 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. // The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({ const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login", select: (s) => s.location.pathname === "/login",
+5
View File
@@ -66,6 +66,11 @@ export const DashboardView: FC<{
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<MonitorPlay className="size-4" /> <MonitorPlay className="size-4" />
{m.status_session()} {m.status_session()}
{s.active_sessions > 1 && (
<Badge variant="secondary">
{m.status_sessions_active({ count: s.active_sessions })}
</Badge>
)}
</CardTitle> </CardTitle>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
+43 -4
View File
@@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button"; import { Button } from "@unom/ui/button";
import { toast } from "@unom/ui/toast"; import { toast } from "@unom/ui/toast";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { type FC, type MouseEvent, type ReactNode, useEffect, useState } from "react"; import { type FC, type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { import {
getGetDisplayStateQueryKey, getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey, getGetDisplaySettingsQueryKey,
@@ -48,10 +48,28 @@ export const DisplaySection: FC = () => {
const q = useGetDisplaySettings(); const q = useGetDisplaySettings();
const save = useSetDisplaySettings(); const save = useSetDisplaySettings();
// Local edit buffer, seeded once from the server and re-seeded after every successful apply. // Local edit buffer, seeded from the server. `seeded` tracks the server value we last seeded
// from, so we can adopt a server-side change that happens underneath us — e.g. saving the display
// ARRANGEMENT switches `layout` to manual and locks in Custom fields (`PUT /display/layout`) —
// WITHOUT clobbering unsaved local edits: re-seed only while the draft still matches the last
// seed (no pending edits). Custom edits aren't auto-applied (there's an explicit Save), so a
// naive "always re-seed on server change" would eat them.
const [draft, setDraft] = useState<DisplayPolicy | null>(null); const [draft, setDraft] = useState<DisplayPolicy | null>(null);
const seeded = useRef<DisplayPolicy | null>(null);
useEffect(() => { useEffect(() => {
if (q.data && draft === null) setDraft(q.data.settings); if (!q.data) return;
const server = q.data.settings;
if (draft === null) {
setDraft(server);
seeded.current = server;
} else if (
seeded.current &&
deepEqual(draft, seeded.current) &&
!deepEqual(server, seeded.current)
) {
setDraft(server);
seeded.current = server;
}
}, [q.data, draft]); }, [q.data, draft]);
// Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on // Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on
@@ -62,6 +80,7 @@ export const DisplaySection: FC = () => {
{ {
onSuccess: (res) => { onSuccess: (res) => {
setDraft(res.settings); setDraft(res.settings);
seeded.current = res.settings;
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
// The policy auto-saves on every preset pick / field edit — without a signal // The policy auto-saves on every preset pick / field edit — without a signal
// users kept looking for a Save button. Errors stay inline (apiErrorMessage). // users kept looking for a Save button. Errors stay inline (apiErrorMessage).
@@ -363,6 +382,14 @@ const DisplayForm: FC<{
> >
{m.display_keep_alive_keep()} {m.display_keep_alive_keep()}
</Button> </Button>
<Button
size="sm"
variant={ka.mode === "forever" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "forever" } })}
>
{m.display_keep_alive_forever()}
</Button>
{ka.mode === "duration" && ( {ka.mode === "duration" && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -713,6 +740,9 @@ const LiveDisplays: FC = () => {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 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". */}
<QueryState isLoading={state.isLoading} error={state.error} refetch={state.refetch}>
{kept.length > 0 && ( {kept.length > 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
@@ -739,6 +769,7 @@ const LiveDisplays: FC = () => {
))} ))}
</ul> </ul>
)} )}
</QueryState>
<DisplayArrangement displays={displays} /> <DisplayArrangement displays={displays} />
</div> </div>
); );
@@ -777,7 +808,15 @@ const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) =>
const onSave = () => const onSave = () =>
saveLayout.mutate( saveLayout.mutate(
{ data: { positions: cur } }, { 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 ( return (
+17 -1
View File
@@ -20,6 +20,7 @@ interface FormState {
portrait: string; portrait: string;
hero: string; hero: string;
header: string; header: string;
logo: string;
command: string; command: string;
} }
@@ -28,6 +29,7 @@ const emptyForm: FormState = {
portrait: "", portrait: "",
hero: "", hero: "",
header: "", header: "",
logo: "",
command: "", command: "",
}; };
@@ -37,11 +39,14 @@ function formFrom(entry: GameEntry): FormState {
portrait: entry.art.portrait ?? "", portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "", hero: entry.art.hero ?? "",
header: entry.art.header ?? "", header: entry.art.header ?? "",
logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "", 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 { function toInput(f: FormState): CustomInput {
const trim = (s: string) => { const trim = (s: string) => {
const t = s.trim(); const t = s.trim();
@@ -54,6 +59,7 @@ function toInput(f: FormState): CustomInput {
portrait: trim(f.portrait), portrait: trim(f.portrait),
hero: trim(f.hero), hero: trim(f.hero),
header: trim(f.header), header: trim(f.header),
logo: trim(f.logo),
}, },
launch: command ? { kind: "command", value: command } : null, launch: command ? { kind: "command", value: command } : null,
}; };
@@ -177,6 +183,16 @@ export const GameForm: FC<{
} }
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="lib-logo">{m.library_field_logo()}</Label>
<Input
id="lib-logo"
type="url"
inputMode="url"
value={form.logo}
onChange={(e) => setForm((f) => ({ ...f, logo: e.target.value }))}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label> <Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input <Input
+6 -4
View File
@@ -38,7 +38,8 @@ export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
library={library} library={library}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} 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<GameEntry[]>; library: Loadable<GameEntry[]>;
onEdit: (entry: GameEntry) => void; onEdit: (entry: GameEntry) => void;
onDelete: (entry: GameEntry) => void; onDelete: (entry: GameEntry) => void;
isDeleting: boolean; /** Custom id of the card whose delete is in flight, or null — only that card disables. */
}> = ({ library, onEdit, onDelete, isDeleting }) => { deletingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId }) => {
const games = library.data ?? []; const games = library.data ?? [];
return ( return (
<QueryState <QueryState
@@ -76,7 +78,7 @@ export const LibraryGrid: FC<{
game={game} game={game}
onEdit={() => onEdit(game)} onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)} onDelete={() => onDelete(game)}
deleting={isDeleting} deleting={deletingId === customId(game)}
/> />
))} ))}
</motion.div> </motion.div>
+9 -4
View File
@@ -48,7 +48,14 @@ export const LogsSection: FC = () => {
const data = query.data; const data = query.data;
useEffect(() => { useEffect(() => {
if (!data || data.entries.length === 0) return; 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); setDropped((d) => d || data.dropped);
setCursor(data.next); setCursor(data.next);
}, [data]); }, [data]);
@@ -123,9 +130,7 @@ export const LogsCard: FC<{
className="max-w-xs" className="max-w-xs"
/> />
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{dropped && ( {dropped && <Badge variant="secondary">{m.logs_dropped()}</Badge>}
<Badge variant="secondary">{m.logs_dropped()}</Badge>
)}
<Button <Button
size="sm" size="sm"
variant={follow ? "secondary" : "outline"} variant={follow ? "secondary" : "outline"}
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, KeyRound } from "lucide-react"; import { Info, KeyRound } from "lucide-react";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
import type { PairingStatus } from "@/api/gen/model/pairingStatus"; import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import { import {
@@ -96,7 +96,7 @@ export const MoonlightPairing: FC<{
id="pin" id="pin"
inputMode="numeric" inputMode="numeric"
autoComplete="off" autoComplete="off"
maxLength={8} maxLength={16}
value={pin} value={pin}
onChange={(e) => onChange={(e) =>
onPinChange(e.target.value.replace(/\D/g, "")) onPinChange(e.target.value.replace(/\D/g, ""))
@@ -108,10 +108,13 @@ export const MoonlightPairing: FC<{
<Button type="submit" disabled={pin.length < 4 || isSubmitting}> <Button type="submit" disabled={pin.length < 4 || isSubmitting}>
{m.pairing_submit()} {m.pairing_submit()}
</Button> </Button>
{/* 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 && ( {isSuccess && (
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]"> <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CheckCircle2 className="size-4" /> <Info className="size-4" />
{m.pairing_success()} {m.pairing_pin_sent()}
</p> </p>
)} )}
{isError && ( {isError && (
+19 -1
View File
@@ -1,9 +1,10 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { KeyRound, Smartphone, Timer } from "lucide-react"; 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 type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import { import {
getGetNativePairingQueryKey, getGetNativePairingQueryKey,
getListNativeClientsQueryKey,
useArmNativePairing, useArmNativePairing,
useDisarmNativePairing, useDisarmNativePairing,
useGetNativePairing, useGetNativePairing,
@@ -32,6 +33,23 @@ export const NativePairingSection: FC = () => {
const arm = useArmNativePairing(); const arm = useArmNativePairing();
const disarm = useDisarmNativePairing(); 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 = () => const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() }); qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
const onArm = () => const onArm = () =>
+16 -4
View File
@@ -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 ( return (
<PairedDevices <PairedDevices
rows={rows} rows={rows}
@@ -96,7 +107,7 @@ export const PairedDevicesSection: FC = () => {
moonlight.refetch(); moonlight.refetch();
}} }}
onUnpair={onUnpair} onUnpair={onUnpair}
isUnpairing={unpairNative.isPending || unpairMoonlight.isPending} pendingFingerprint={pendingFingerprint}
/> />
); );
}; };
@@ -108,8 +119,9 @@ export const PairedDevices: FC<{
error: unknown; error: unknown;
refetch: () => void; refetch: () => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void; onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
isUnpairing: boolean; /** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */
}> = ({ rows, isLoading, error, refetch, onUnpair, isUnpairing }) => ( pendingFingerprint: string | null;
}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => (
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2> <h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
@@ -152,7 +164,7 @@ export const PairedDevices: FC<{
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={m.action_unpair()} aria-label={m.action_unpair()}
disabled={isUnpairing} disabled={pendingFingerprint === r.fingerprint}
onClick={() => onUnpair(r.protocol, r.fingerprint)} onClick={() => onUnpair(r.protocol, r.fingerprint)}
> >
<Trash2 className="size-4 text-destructive" /> <Trash2 className="size-4 text-destructive" />
+12 -5
View File
@@ -42,12 +42,18 @@ export const PendingDevicesSection: FC = () => {
}; };
const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh }); 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 ( return (
<PendingDevices <PendingDevices
pending={pending} pending={pending}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onDeny={onDeny}
busy={approve.isPending || deny.isPending} pendingId={pendingId}
/> />
); );
}; };
@@ -61,8 +67,9 @@ export const PendingDevices: FC<{
pending: Loadable<PendingDevice[]>; pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void; onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void; onDeny: (id: number) => void;
busy: boolean; /** Id of the row whose approve/deny is in flight, or null — only that row disables. */
}> = ({ pending, onApprove, onDeny, busy }) => { pendingId: number | null;
}> = ({ pending, onApprove, onDeny, pendingId }) => {
const rows = pending.data ?? []; const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow // 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 // 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<{
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
size="sm" size="sm"
disabled={busy} disabled={pendingId === p.id}
onClick={() => onApprove(p.id, p.name)} onClick={() => onApprove(p.id, p.name)}
> >
{m.pairing_pending_approve()} {m.pairing_pending_approve()}
@@ -113,7 +120,7 @@ export const PendingDevices: FC<{
size="sm" size="sm"
variant="ghost" variant="ghost"
aria-label={m.pairing_pending_deny()} aria-label={m.pairing_pending_deny()}
disabled={busy} disabled={pendingId === p.id}
onClick={() => onDeny(p.id)} onClick={() => onDeny(p.id)}
> >
<X className="size-4" /> <X className="size-4" />
+7 -1
View File
@@ -1,4 +1,5 @@
import type { FC } from "react"; import type { FC } from "react";
import { useGetHostInfo } from "@/api/gen/host/host";
import { useLocale } from "@/lib/i18n"; import { useLocale } from "@/lib/i18n";
import { MoonlightPairingSection } from "./MoonlightPairingCard"; import { MoonlightPairingSection } from "./MoonlightPairingCard";
import { NativePairingSection } from "./NativePairingCard"; import { NativePairingSection } from "./NativePairingCard";
@@ -12,11 +13,16 @@ import { PairingView } from "./view";
// fill the same slots — the layout is defined once and can't drift. // fill the same slots — the layout is defined once and can't drift.
export const SectionPairing: FC = () => { export const SectionPairing: FC = () => {
useLocale(); useLocale();
// Moonlight/GameStream pairing only works when the host runs the compat planes (`--gamestream`,
// off by default). Otherwise a Moonlight PIN can never arrive, so the card is dead UI — hide it
// (and until host info loads, to avoid a flash of an un-actionable card).
const host = useGetHostInfo();
const gamestream = host.data?.gamestream === true;
return ( return (
<PairingView <PairingView
pending={<PendingDevicesSection />} pending={<PendingDevicesSection />}
native={<NativePairingSection />} native={<NativePairingSection />}
moonlight={<MoonlightPairingSection />} moonlight={gamestream ? <MoonlightPairingSection /> : null}
paired={<PairedDevicesSection />} paired={<PairedDevicesSection />}
/> />
); );
+10 -1
View File
@@ -1,4 +1,5 @@
import Section from "@unom/ui/section"; import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -12,8 +13,16 @@ export const SectionSettings: FC = () => {
const current = useLocale(); const current = useLocale();
const onLogout = async () => { const onLogout = async () => {
await fetch("/_auth/logout", { method: "POST" }); try {
const res = await fetch("/_auth/logout", { method: "POST" });
if (!res.ok) throw new Error(`logout failed: ${res.status}`);
window.location.href = "/login"; window.location.href = "/login";
} catch {
// The logout POST failed, so the session cookie likely survives. Navigating to /login
// anyway would look logged out while a live session still re-admits on the next gated
// nav — surface the failure and stay put so the user can retry.
toast.error(m.settings_logout_failed());
}
}; };
return ( return (
+3 -1
View File
@@ -59,7 +59,9 @@ export const CaptureControlCard: FC<{
}> = ({ status, onStart, onStop, isStarting, isStopping }) => { }> = ({ status, onStart, onStop, isStarting, isStopping }) => {
const s = status.data; const s = status.data;
const armed = s?.armed ?? false; const armed = s?.armed ?? false;
const elapsed = armed && s ? Date.now() - s.started_unix_ms : 0; // Host-measured elapsed (monotonic) — not `Date.now() - started_unix_ms`, which mixes the
// browser's clock with the host's and reads wrong (or clamps to 0:00) under any skew.
const elapsed = armed && s ? s.elapsed_ms : 0;
return ( return (
<QueryState <QueryState
isLoading={status.isLoading} isLoading={status.isLoading}
+1 -1
View File
@@ -71,7 +71,7 @@ export const DetailCard: FC<{
<ThroughputChart samples={samples} /> <ThroughputChart samples={samples} />
</ChartBlock> </ChartBlock>
<ChartBlock title={m.stats_health_title()}> <ChartBlock title={m.stats_health_title()}>
<HealthChart samples={samples} /> <HealthChart samples={samples} kind={cap?.meta.kind} />
</ChartBlock> </ChartBlock>
</div> </div>
)} )}
+10
View File
@@ -1,9 +1,11 @@
import type { FC } from "react"; import type { FC } from "react";
import { ApiError } from "@/api/fetcher";
import type { Capture } from "@/api/gen/model/capture"; import type { Capture } from "@/api/gen/model/capture";
import { import {
useStatsCaptureLive, useStatsCaptureLive,
useStatsCaptureStatus, useStatsCaptureStatus,
} from "@/api/gen/stats/stats"; } from "@/api/gen/stats/stats";
import { QueryState } from "@/components/query-state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Loadable } from "@/lib/query"; import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
@@ -28,12 +30,19 @@ export const LiveSection: FC = () => {
/** Live graphs while a capture is armed: latency stack + throughput. */ /** Live graphs while a capture is armed: latency stack + throughput. */
export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => { export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
const samples = live.data?.samples ?? []; 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{m.stats_live_title()}</CardTitle> <CardTitle>{m.stats_live_title()}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-8"> <CardContent className="space-y-8">
<QueryState isLoading={false} error={error} refetch={live.refetch}>
{samples.length === 0 ? ( {samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
{m.stats_live_waiting()} {m.stats_live_waiting()}
@@ -51,6 +60,7 @@ export const LiveCard: FC<{ live: Loadable<Capture> }> = ({ live }) => {
</ChartBlock> </ChartBlock>
</> </>
)} )}
</QueryState>
</CardContent> </CardContent>
</Card> </Card>
); );
+38 -5
View File
@@ -2,7 +2,7 @@
// CLIENT-ONLY (behind <ChartFrame>'s mounted guard): recharts' ResponsiveContainer // CLIENT-ONLY (behind <ChartFrame>'s mounted guard): recharts' ResponsiveContainer
// measures its parent via ResizeObserver, which has no width during SSR and would // 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 // 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. // (capture/encode/packetize/send) both stack sensibly.
import { type ReactElement, useEffect, useState } from "react"; import { type ReactElement, useEffect, useState } from "react";
import { 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 // 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. // appearance order, so an unexpected stage name still renders a distinct band.
const STAGE_COLORS: Record<string, string> = { const STAGE_COLORS: Record<string, string> = {
// `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", capture: "#6c5bf3",
submit: "#22a2f2", submit: "#22a2f2",
encode: "#f2a922", 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[] }) { export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
const rows = samples.map((s) => ({ const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000), t: Math.round(s.t_ms / 1000),
fps: s.fps, fps: s.fps,
repeat: s.repeat_fps, repeat: s.repeat_fps,
mbps: s.mbps, mbps: s.mbps,
// The configured encoder target (kbps → Mb/s) so goodput can be read against it.
target: s.bitrate_kbps / 1000,
})); }));
return ( return (
<ChartFrame> <ChartFrame>
@@ -203,13 +208,31 @@ export function ThroughputChart({ samples }: { samples: StatsSample[] }) {
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
/> />
<Line
yAxisId="mbps"
type="monotone"
dataKey="target"
name={m.stats_bitrate_target()}
stroke="#94a3b8"
strokeDasharray="2 3"
dot={false}
isAnimationActive={false}
/>
</LineChart> </LineChart>
</ChartFrame> </ChartFrame>
); );
} }
/** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. */ /** Loss/recovery counters per window — frames/packets/send drops + FEC recovered. On the
export function HealthChart({ samples }: { samples: StatsSample[] }) { * 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) => ({ const rows = samples.map((s) => ({
t: Math.round(s.t_ms / 1000), t: Math.round(s.t_ms / 1000),
frames: s.frames_dropped, frames: s.frames_dropped,
@@ -218,8 +241,17 @@ export function HealthChart({ samples }: { samples: StatsSample[] }) {
fec: s.fec_recovered, fec: s.fec_recovered,
})); }));
return ( return (
<>
{kind === "gamestream" && (
<p className="mb-2 text-xs text-muted-foreground">
{m.stats_health_gamestream_note()}
</p>
)}
<ChartFrame> <ChartFrame>
<LineChart data={rows} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}> <LineChart
data={rows}
margin={{ top: 6, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" /> <XAxis dataKey="t" tick={axisTick} stroke={gridStroke} unit="s" />
<YAxis <YAxis
@@ -264,5 +296,6 @@ export function HealthChart({ samples }: { samples: StatsSample[] }) {
/> />
</LineChart> </LineChart>
</ChartFrame> </ChartFrame>
</>
); );
} }
+3 -2
View File
@@ -10,6 +10,7 @@ const emptyForm = {
portrait: "", portrait: "",
hero: "", hero: "",
header: "", header: "",
logo: "",
command: "", command: "",
}; };
@@ -29,7 +30,7 @@ export const Populated: Story = {
library={{ data: library, ...idle }} library={{ data: library, ...idle }}
onEdit={noop} onEdit={noop}
onDelete={noop} onDelete={noop}
isDeleting={false} deletingId={null}
/> />
), ),
}; };
@@ -40,7 +41,7 @@ export const Empty: Story = {
library={{ data: [], ...idle }} library={{ data: [], ...idle }}
onEdit={noop} onEdit={noop}
onDelete={noop} onDelete={noop}
isDeleting={false} deletingId={null}
/> />
), ),
}; };
+2 -2
View File
@@ -36,7 +36,7 @@ export const Armed: Story = {
pending={{ data: pendingDevices, ...idle }} pending={{ data: pendingDevices, ...idle }}
onApprove={noop} onApprove={noop}
onDeny={noop} onDeny={noop}
busy={false} pendingId={null}
/> />
), ),
native: ( native: (
@@ -77,7 +77,7 @@ export const Armed: Story = {
error={null} error={null}
refetch={noop} refetch={noop}
onUnpair={noop} onUnpair={noop}
isUnpairing={false} pendingFingerprint={null}
/> />
), ),
}, },
+7 -3
View File
@@ -17,7 +17,8 @@ import type { StatsStatus } from "@/api/gen/model/statsStatus";
export const hostInfo: HostInfo = { export const hostInfo: HostInfo = {
abi_version: 2, abi_version: 2,
app_version: "7.1.450.0", app_version: "7.1.450.0",
codecs: ["h264", "h265", "av1"], codecs: ["h264", "hevc", "av1"],
gamestream: true,
gfe_version: "3.23.0.74", gfe_version: "3.23.0.74",
hostname: "ENRICOS-DESKTOP", hostname: "ENRICOS-DESKTOP",
local_ip: "192.168.1.173", local_ip: "192.168.1.173",
@@ -46,9 +47,10 @@ export const statusActive: RuntimeStatus = {
audio_streaming: true, audio_streaming: true,
paired_clients: 3, paired_clients: 3,
pin_pending: false, pin_pending: false,
active_sessions: 1,
session: { width: 5120, height: 1440, fps: 240 }, session: { width: 5120, height: 1440, fps: 240 },
stream: { stream: {
codec: "h265", codec: "hevc",
width: 5120, width: 5120,
height: 1440, height: 1440,
fps: 240, fps: 240,
@@ -63,6 +65,7 @@ export const statusIdle: RuntimeStatus = {
audio_streaming: false, audio_streaming: false,
paired_clients: 1, paired_clients: 1,
pin_pending: true, pin_pending: true,
active_sessions: 0,
session: null, session: null,
stream: null, stream: null,
}; };
@@ -128,6 +131,7 @@ export const statsStatusIdle: StatsStatus = {
kind: "native", kind: "native",
sample_count: 0, sample_count: 0,
started_unix_ms: 0, started_unix_ms: 0,
elapsed_ms: 0,
}; };
// A native-path pipeline: capture → submit → encode → send. Deterministic (no // A native-path pipeline: capture → submit → encode → send. Deterministic (no
@@ -171,7 +175,7 @@ export const captureMetas: CaptureMeta[] = [
id: "cap-20260628-2041", id: "cap-20260628-2041",
client: "enricos-macbook", client: "enricos-macbook",
kind: "native", kind: "native",
codec: "h265", codec: "hevc",
width: 5120, width: 5120,
height: 1440, height: 1440,
fps: 240, fps: 240,