From 9e505aba4122a9eb37102ba1c1de51f6e2347f7f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:46:25 +0200 Subject: [PATCH] fix(web): the console stops swallowing the host's answer when it says no MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host writes genuinely useful refusals — "entry is owned by provider `x`, update it through its reconcile" — and a dozen call sites threw them away. The pattern was always one of two: a mutation whose `error` nothing rendered, or an `await mutateAsync(...)` with no catch, which additionally produced an unhandled rejection. Either way the operator clicked, nothing visible happened, and the thing they asked for silently hadn't. Fixed at each site, with the host's own message shown where there is one: - Adding or editing a library entry kept the form open and said why, instead of closing it as if it had saved and taking the typing with it. Deleting one reports the refusal rather than leaving the card sitting there. - The GPU preference, capture start/stop, recording delete and download, and the dashboard's stop-session / request-keyframe / end-game all report failure. The failed capture STOP is the one that mattered most: it is "stop & save", so a swallowed error meant minutes of recording vanished with nothing on screen. - The recordings Download had a comment claiming the detail view surfaces its errors. It only does that for the selected row, and Download is on every row. Two related fixes in the same area: - `apiFetch` no longer navigates to /login synchronously from inside whichever call noticed a 401 — very often a background poll the user never started. Tearing the page down mid-render took unsaved editing state with it, which the Displays page explicitly models. It defers a beat and coalesces, so a burst of parallel 401s schedules one navigation. - The plugin liveness probe treated the auth gate's 302 → /login → 200 HTML as a healthy plugin, and rendered the console's own login page inside the plugin's iframe. It also gave up permanently on the first failed probe, so the runner restart at the end of every install threw away whatever was open in another plugin. It rejects the redirect and keeps probing on a slower beat while down. `apiErrorMessage` moves out of the display card into src/lib/errors.ts, since half the console needs it now. Co-Authored-By: Claude Opus 5 (1M context) --- web/messages/de.json | 9 +++++++ web/messages/en.json | 9 +++++++ web/src/api/fetcher.ts | 20 +++++++++++++-- web/src/lib/errors.ts | 20 +++++++++++++++ web/src/sections/Dashboard/index.tsx | 23 ++++++++++++++--- web/src/sections/Displays/DisplayCard.tsx | 31 ++++++++--------------- web/src/sections/Host/GpuCard.tsx | 5 ++++ web/src/sections/Library/GameForm.tsx | 28 +++++++++++++++++--- web/src/sections/Library/LibraryGrid.tsx | 15 ++++++++--- web/src/sections/Plugins/index.tsx | 21 ++++++++++++--- web/src/sections/Stats/CaptureControl.tsx | 12 ++++++++- web/src/sections/Stats/Recordings.tsx | 11 ++++++-- 12 files changed, 164 insertions(+), 40 deletions(-) create mode 100644 web/src/lib/errors.ts 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} +

+ )}