Files
punktfunk/web/src/sections/Stats/CaptureControl.tsx
T
enricobuehlerandClaude Opus 5 9e505aba41 fix(web): the console stops swallowing the host's answer when it says no
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) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00

130 lines
4.2 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Circle, Square } from "lucide-react";
import type { FC } from "react";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
import {
getStatsCaptureStatusQueryKey,
getStatsRecordingsListQueryKey,
useStatsCaptureStart,
useStatsCaptureStatus,
useStatsCaptureStop,
} from "@/api/gen/stats/stats";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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";
import { fmtDuration, kindLabel, Stat } from "./helpers";
/**
* Container: arm/disarm the capture. Owns the polled status query plus start/stop; stopping also
* refreshes the recordings list (owned by the Recordings subsection — invalidated here by key).
*/
export const CaptureControlSection: FC = () => {
const qc = useQueryClient();
const status = useStatsCaptureStatus({ query: { refetchInterval: 2_000 } });
const start = useStatsCaptureStart();
const stop = useStatsCaptureStop();
const refreshStatus = () =>
qc.invalidateQueries({ queryKey: getStatsCaptureStatusQueryKey() });
// Both paths report failure. A failed STOP is the one that matters: it is "stop & save", so
// swallowing the error let a capture the operator had been recording for minutes disappear with
// no recording written and nothing on screen to say so.
const onStart = () =>
start.mutate(undefined, {
onSuccess: refreshStatus,
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_start_failed()),
});
const onStop = () =>
stop.mutate(undefined, {
onSuccess: () => {
refreshStatus();
qc.invalidateQueries({ queryKey: getStatsRecordingsListQueryKey() });
},
onError: (e) => toast.error(apiErrorMessage(e) ?? m.stats_stop_failed()),
});
return (
<CaptureControlCard
status={status}
onStart={onStart}
onStop={onStop}
isStarting={start.isPending}
isStopping={stop.isPending}
/>
);
};
/** Start/Stop + a Recording/Idle pill with elapsed + sample count. */
export const CaptureControlCard: FC<{
status: Loadable<StatsStatus>;
onStart: () => void;
onStop: () => void;
isStarting: boolean;
isStopping: boolean;
}> = ({ status, onStart, onStop, isStarting, isStopping }) => {
const s = status.data;
const armed = s?.armed ?? false;
// 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 (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-3">
<span>{m.stats_capture_title()}</span>
{armed ? (
<Badge variant="destructive" className="gap-1.5">
<Circle className="size-2.5 animate-pulse fill-current" />
{m.stats_recording()}
</Badge>
) : (
<Badge variant="outline">{m.stats_idle()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{m.stats_capture_desc()}
</p>
{armed && s && (
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-sm tabular-nums">
<Stat label={m.stats_elapsed()} value={fmtDuration(elapsed)} />
<Stat label={m.stats_samples()} value={String(s.sample_count)} />
{s.kind && (
<Stat label={m.stats_kind()} value={kindLabel(s.kind)} />
)}
</dl>
)}
<div className="flex gap-2">
{armed ? (
<Button
variant="destructive"
disabled={isStopping}
onClick={onStop}
>
<Square className="size-4" />
{m.stats_stop()}
</Button>
) : (
<Button disabled={isStarting} onClick={onStart}>
<Circle className="size-4 fill-current" />
{m.stats_start()}
</Button>
)}
</div>
</CardContent>
</Card>
</QueryState>
);
};