diff --git a/web/messages/de.json b/web/messages/de.json index ef3408db..b451becb 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -46,6 +46,15 @@ "automation_field_timeout": "Zeitlimit (s)", "automation_confirm_title": "Automatisierung speichern?", "automation_confirm_body": "Diese Befehle laufen auf diesem Rechner als Host-Benutzer, sobald ihr Ereignis eintritt. Bestätige mit dem Konsolen-Passwort.", + "library_delete_failed": "Dieser Eintrag konnte nicht gelöscht werden.", + "gpu_apply_failed": "Die GPU-Auswahl konnte nicht geändert werden.", + "stats_start_failed": "Die Aufzeichnung konnte nicht gestartet werden.", + "stats_stop_failed": "Die Aufzeichnung konnte nicht gestoppt werden — sie wurde womöglich nicht gespeichert.", + "stats_delete_failed": "Diese Aufzeichnung konnte nicht gelöscht werden.", + "stats_download_failed": "Diese Aufzeichnung konnte nicht heruntergeladen werden.", + "games_end_failed": "Das Spiel konnte nicht beendet werden.", + "action_stop_failed": "Die Sitzung konnte nicht beendet werden.", + "action_idr_failed": "Es konnte kein Keyframe angefordert werden.", "nav_settings": "Einstellungen", "nav_more": "Mehr", "status_title": "Live-Status", diff --git a/web/messages/en.json b/web/messages/en.json index c140b1be..fe1eb4ca 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -41,6 +41,15 @@ "automation_field_timeout": "Timeout (s)", "automation_confirm_title": "Save automation?", "automation_confirm_body": "These commands run on this machine, as the host user, whenever their event fires. Confirm with the console password.", + "library_delete_failed": "Could not delete this entry.", + "gpu_apply_failed": "Could not change the GPU preference.", + "stats_start_failed": "Could not start the capture.", + "stats_stop_failed": "Could not stop the capture — it may not have been saved.", + "stats_delete_failed": "Could not delete this recording.", + "stats_download_failed": "Could not download this recording.", + "games_end_failed": "Could not end the game.", + "action_stop_failed": "Could not stop the session.", + "action_idr_failed": "Could not request a keyframe.", "nav_settings": "Settings", "nav_more": "More", "nav_plugins": "Plugins", diff --git a/web/src/api/fetcher.ts b/web/src/api/fetcher.ts index 5e6b9699..8cef112a 100644 --- a/web/src/api/fetcher.ts +++ b/web/src/api/fetcher.ts @@ -39,15 +39,31 @@ export async function apiFetch( return body as T; } -/** On lost session, send the user to the login screen, remembering where they were. */ +/** + * On lost session, send the user to the login screen, remembering where they were. + * + * Deferred by a beat rather than navigating inline. This runs inside whichever call noticed the + * 401 — very often a background poll the user never asked for — and a synchronous + * `location.href =` there tears the page down mid-render, taking any unsaved editing state with it + * (the Displays page models exactly such a draft). Letting the current task finish first means the + * caller's own error handling still runs, and a `beforeunload` guard can still speak up. + * + * Guarded so a burst of parallel 401s (every card on a page polling at once) schedules one + * navigation, not one per request. + */ +let redirecting = false; function redirectToLogin(): void { if (typeof window === "undefined") return; if (window.location.pathname === "/login") return; + if (redirecting) return; + redirecting = true; // 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}`; + setTimeout(() => { + window.location.href = `/login?next=${next}`; + }, 0); } function safeJson(text: string): unknown { diff --git a/web/src/lib/errors.ts b/web/src/lib/errors.ts new file mode 100644 index 00000000..49450a47 --- /dev/null +++ b/web/src/lib/errors.ts @@ -0,0 +1,20 @@ +import { ApiError } from "@/api/fetcher"; + +/** + * The server's own `{ error }` message from a thrown `ApiError` (its `.data` body), for inline + * display — falling back to the HTTP status text, then to whatever was thrown. + * + * The host writes genuinely useful refusals ("entry is owned by provider `x` — update it through + * its reconcile"), and showing a generic "something went wrong" in their place throws away the one + * piece of information that tells the operator what to do next. + * + * Lives here rather than in a section because several of them need it; it started life private to + * the display card. + */ +export function apiErrorMessage(err: unknown): string | undefined { + if (err instanceof ApiError) { + const data = err.data as { error?: string } | undefined; + return data?.error ?? err.message; + } + return err ? String(err) : undefined; +} diff --git a/web/src/sections/Dashboard/index.tsx b/web/src/sections/Dashboard/index.tsx index b8108359..2e697bcd 100644 --- a/web/src/sections/Dashboard/index.tsx +++ b/web/src/sections/Dashboard/index.tsx @@ -1,4 +1,5 @@ import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "@unom/ui/toast"; import type { FC } from "react"; import { getGetStatusQueryKey, useGetStatus } from "@/api/gen/host/host"; import { useGetLibrary } from "@/api/gen/library/library"; @@ -8,6 +9,7 @@ import { useRequestIdr, useStopSession, } from "@/api/gen/session/session"; +import { apiErrorMessage } from "@/lib/errors"; import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; import { DashboardView } from "./view"; @@ -39,6 +41,11 @@ export const SectionDashboard: FC = () => { const invalidate = () => qc.invalidateQueries({ queryKey: getGetStatusQueryKey() }); + /** Every session control reports its failure. These are the console's most consequential + * buttons — stopping a session, ending a game — and a refusal used to be completely silent. */ + const failed = (fallback: string) => (e: unknown) => + toast.error(apiErrorMessage(e) ?? fallback); + /** * "End now" means two different things, and which one is right follows from the row's state: a * game whose session is still live ends by stopping that session (what then happens to the game @@ -66,12 +73,15 @@ export const SectionDashboard: FC = () => { return; endGame.mutate( { data: { app_id: game.app_id ?? null } }, - { onSuccess: invalidate }, + { onSuccess: invalidate, onError: failed(m.games_end_failed()) }, ); return; } if (!confirmStopAll()) return; - stop.mutate(undefined, { onSuccess: invalidate }); + stop.mutate(undefined, { + onSuccess: invalidate, + onError: failed(m.action_stop_failed()), + }); }; /** Shared by "End now" on a live row and the card's own Stop-session button: with more than one @@ -88,9 +98,14 @@ export const SectionDashboard: FC = () => { library={library.data} onStopSession={() => { if (!confirmStopAll()) return; - stop.mutate(undefined, { onSuccess: invalidate }); + stop.mutate(undefined, { + onSuccess: invalidate, + onError: failed(m.action_stop_failed()), + }); }} - onRequestIdr={() => idr.mutate(undefined)} + onRequestIdr={() => + idr.mutate(undefined, { onError: failed(m.action_idr_failed()) }) + } onEndGame={onEndGame} isStopping={stop.isPending} isRequestingIdr={idr.isPending} diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 462b3a1e..eab80770 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -11,7 +11,6 @@ import { useRef, useState, } from "react"; -import { ApiError } from "@/api/fetcher"; import { getGetDisplaySettingsQueryKey, getGetDisplayStateQueryKey, @@ -42,6 +41,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { apiErrorMessage } from "@/lib/errors"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; @@ -97,16 +97,6 @@ export const DisplaySection: FC = () => { }, ); - /** - * Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits - * along for the ride. - * - * These three controls apply immediately by design, but they used to send `{...draft}`: flipping - * DDC while the Custom block held unsaved edits committed those edits too, and the shared - * `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so - * the operator got a policy they never saved with no trace it had happened. Send the axis on top - * of the last SAVED policy, and merge only that axis back into the draft. - */ /** * Save the hand-edited Custom block. * @@ -120,6 +110,16 @@ export const DisplaySection: FC = () => { apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor }); }; + /** + * Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits + * along for the ride. + * + * These three controls apply immediately by design, but they used to send `{...draft}`: flipping + * DDC while the Custom block held unsaved edits committed those edits too, and the shared + * `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so + * the operator got a policy they never saved with no trace it had happened. Send the axis on top + * of the last SAVED policy, and merge only that axis back into the draft. + */ const applyAxis = (patch: Partial) => { const base = seeded.current ?? draft; if (!base) return; @@ -1198,15 +1198,6 @@ const DisplayRow: FC<{ ); }; -/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */ -const apiErrorMessage = (err: unknown): string | undefined => { - if (err instanceof ApiError) { - const data = err.data as { error?: string } | undefined; - return data?.error ?? err.message; - } - return err ? String(err) : undefined; -}; - /** Presets the host can't honor yet (one-click apply would 400) are surfaced but disabled. Empty * now that `gaming-rig` (`keep_alive: forever`) ships: the display is Pinned (Linux + Windows) and * freed via Release. */ diff --git a/web/src/sections/Host/GpuCard.tsx b/web/src/sections/Host/GpuCard.tsx index bfb2e091..cd02902c 100644 --- a/web/src/sections/Host/GpuCard.tsx +++ b/web/src/sections/Host/GpuCard.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@unom/ui/button"; +import { toast } from "@unom/ui/toast"; import type { FC } from "react"; import { getListGpusQueryKey, @@ -10,6 +11,7 @@ import type { GpuState } from "@/api/gen/model"; import { QueryState } from "@/components/query-state"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { apiErrorMessage } from "@/lib/errors"; import type { Loadable } from "@/lib/query"; import { m } from "@/paraglide/messages"; @@ -25,12 +27,15 @@ export const GpuSection: FC = () => { const gpus = useListGpus({ query: { refetchInterval: 20_000 } }); const setPref = useSetGpuPreference(); + // A refused GPU preference used to vanish: nothing read `setPref.error`, so the card simply + // stayed on the old selection as though the click had missed. const apply = (mode: "auto" | "manual", gpuId?: string) => setPref.mutate( { data: { mode, gpu_id: gpuId ?? null } }, { onSuccess: () => qc.invalidateQueries({ queryKey: getListGpusQueryKey() }), + onError: (e) => toast.error(apiErrorMessage(e) ?? m.gpu_apply_failed()), }, ); diff --git a/web/src/sections/Library/GameForm.tsx b/web/src/sections/Library/GameForm.tsx index f2226c67..832a6f39 100644 --- a/web/src/sections/Library/GameForm.tsx +++ b/web/src/sections/Library/GameForm.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { apiErrorMessage } from "@/lib/errors"; import { m } from "@/paraglide/messages"; import { customId } from "./helpers"; @@ -133,10 +134,18 @@ export const GameFormSection: FC<{ const invalidate = () => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); + // A rejected save must not close the form and must not look like a success. It used to do both: + // nothing read `create.error`/`update.error`, and the un-caught `mutateAsync` rejection meant + // the entry silently didn't save while the dialog disappeared — taking the operator's typing + // with it. const onSubmit = async (data: CustomInput) => { - if (target === "new") await create.mutateAsync({ data }).then(invalidate); - else - await update.mutateAsync({ id: customId(target), data }).then(invalidate); + try { + if (target === "new") await create.mutateAsync({ data }); + else await update.mutateAsync({ id: customId(target), data }); + } catch { + return; // the message is rendered from the mutation's own error state below + } + invalidate(); onClose(); }; @@ -147,6 +156,7 @@ export const GameFormSection: FC<{ onSubmit={onSubmit} onCancel={onClose} isSaving={create.isPending || update.isPending} + error={apiErrorMessage(create.error ?? update.error)} /> ); }; @@ -187,7 +197,9 @@ export const GameForm: FC<{ onSubmit: (data: CustomInput) => void; onCancel: () => void; isSaving: boolean; -}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => { + /** The host's refusal, if the last save failed — shown next to the button that caused it. */ + error?: string; +}> = ({ initial, mode, onSubmit, onCancel, isSaving, error }) => { const [form, setForm] = useState(initial); const set = (key: keyof FormState) => (value: string) => setForm((f) => ({ ...f, [key]: value })); @@ -331,6 +343,14 @@ export const GameForm: FC<{ help={m.library_field_tags_help()} /> + {error && ( +

+ {error} +

+ )}