forked from unom/punktfunk
A pre-release sweep of the management console for two things that no type check
and no diff can catch: primitives that were never @unom/ui's, and animation
that a nested motion parent quietly cancelled.
THE PRESET TILES ALL LANDED ON THE SAME FRAME. @unom/ui's <Section> sets
`delayChildren: stagger(...)`, so a page whose cards are direct descendants of
it staggers for free — which is why every page but one looked right. An
<AnimatedCard> is ALSO a motion element and sets no `delayChildren`, and the
Virtual displays preset tiles are cards nested INSIDE that page's config card,
so that card became their timing group. Measured in a headless browser: the
opacity spread between the first and last tile was 0.00 across the whole
animation (six tiles in lockstep), and is 0.98 now — a ~100 ms cascade matching
the rest of the console. The four hand-rolled copies of the stagger container
collapse into one `<Stagger>` that carries the explanation.
FIVE FILES IMPORTED THE WRONG BUTTON. `@unom/ui/button` exports both a plain
`Button` and the `AnimatedButton` that this console's wrapper re-exports under
the same name — so `import { Button } from "@unom/ui/button"` compiles, renders,
and silently opts out of the mount animation and the hover/tap response.
Displays, SessionGame, GPU, Update and PendingDevices had dead buttons sitting
next to live ones.
THREE PRIMITIVES HAD NO WRAPPER, SO NOBODY REACHED FOR THEM. @unom/ui ships
form/select, form/textarea and form/checkbox; components/ui did not, and the
gap was filled with browser-chrome `<select>`, `<textarea>` and
`<input type="checkbox">` in the add-hook modal and both library forms. Select
needs the same token correction Tabs needed — upstream `text-secondary` is a
text colour, but here `--secondary` is a SURFACE, so the trigger's chevron and
placeholder rendered at near-zero contrast on the card behind them.
The hook timeout also stops accepting a value the host rejects: `min`/`max` on
a controlled `<input type="number">` are decoration (no form validation ever
runs), so 900 went into a field capped at 600 and failed later, at run time.
@unom/ui's InputNumber clamps on blur and lets the field be empty while you
retype instead of snapping to the fallback.
Storybook gains the page that had no story at all — the console's largest
config surface, and the reason this shipped unseen. Its <Card> wrapper is load
bearing: it reproduces the motion nesting that IS the bug.
513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "@unom/ui/toast";
|
|
import { type FC, type ReactNode, useState } from "react";
|
|
import { ApiError } from "@/api/fetcher";
|
|
import type { UpdateStatus } from "@/api/gen/model";
|
|
import {
|
|
getGetUpdateStatusQueryKey,
|
|
useForceUpdateCheck,
|
|
useGetUpdateStatus,
|
|
} from "@/api/gen/update/update";
|
|
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import { apiErrorMessage } from "@/lib/errors";
|
|
import { fmtDateTimeSecs } from "@/lib/format";
|
|
import type { Loadable } from "@/lib/query";
|
|
import { m } from "@/paraglide/messages";
|
|
|
|
/** Give up waiting for the host to come back this long after apply started. */
|
|
const APPLY_TIMEOUT_MS = 8 * 60 * 1000;
|
|
|
|
/**
|
|
* Container: the host update card. Check everywhere (U0) + one-click apply where the host
|
|
* reports `apply: "full"` (U1 — Windows installer). The apply flow deliberately survives the
|
|
* console's own backends dying: once an apply is accepted, the card renders from the LAST
|
|
* status snapshot (React Query keeps data across failed polls) and treats poll errors as "the
|
|
* host is restarting", not as failures — until the target version answers or a timeout.
|
|
*/
|
|
export const UpdateSection: FC = () => {
|
|
const qc = useQueryClient();
|
|
const [applying, setApplying] = useState<{
|
|
target: string;
|
|
startedAt: number;
|
|
} | null>(null);
|
|
|
|
const status = useGetUpdateStatus({
|
|
// Poll fast while an apply is in flight so the post-restart status lands promptly.
|
|
query: { refetchInterval: applying ? 3_000 : 60_000 },
|
|
});
|
|
const check = useForceUpdateCheck();
|
|
|
|
const s = status.data;
|
|
// The apply resolved: either the host answers with the target version (reconcile wrote
|
|
// last_result across the restart), or ANY apply outcome newer than our start arrived —
|
|
// which covers failure, "staged, reboot to finish", and "your package source had nothing
|
|
// newer yet" (where the version deliberately doesn't change).
|
|
if (applying && s) {
|
|
if (
|
|
s.current_version === applying.target ||
|
|
(s.last_result &&
|
|
s.last_result.finished_unix * 1000 >= applying.startedAt - 60_000)
|
|
) {
|
|
setApplying(null);
|
|
}
|
|
}
|
|
|
|
const checkNow = () =>
|
|
check.mutate(undefined, {
|
|
onSuccess: (fresh) => {
|
|
qc.setQueryData(getGetUpdateStatusQueryKey(), fresh);
|
|
},
|
|
// The host throttles repeat checks (429). Swallowing it made a second click within the
|
|
// window look like a dead button; say the check was skipped and why.
|
|
onError: (e) =>
|
|
toast.error(
|
|
e instanceof ApiError && e.status === 429
|
|
? m.update_apply_throttled()
|
|
: (apiErrorMessage(e) ?? m.update_error()),
|
|
),
|
|
});
|
|
|
|
return (
|
|
<UpdateCard
|
|
state={status}
|
|
onCheck={checkNow}
|
|
checkBusy={check.isPending}
|
|
applying={applying}
|
|
onApplied={(target) => setApplying({ target, startedAt: Date.now() })}
|
|
onGiveUp={() => setApplying(null)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const UpdateCard: FC<{
|
|
state: Loadable<UpdateStatus>;
|
|
onCheck: () => void;
|
|
checkBusy: boolean;
|
|
applying: { target: string; startedAt: number } | null;
|
|
onApplied: (target: string) => void;
|
|
/** Leave the applying state after a timeout — otherwise the card waits forever. */
|
|
onGiveUp: () => void;
|
|
}> = ({ state, onCheck, checkBusy, applying, onApplied, onGiveUp }) => {
|
|
const s = state.data;
|
|
const inFlight = Boolean(applying) || Boolean(s?.job);
|
|
const timedOut =
|
|
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
|
|
// Is the snapshot we are rendering still being refreshed? While the host is gone the query
|
|
// keeps its LAST payload — including a `job` that was in progress when it went away — so the
|
|
// timeout warning, gated on `!job`, could never fire in the one case it exists for. A failing
|
|
// poll means the job field describes a host we can no longer see.
|
|
const snapshotStale = Boolean(state.error);
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>{m.update_title()}</CardTitle>
|
|
{s?.available && !inFlight && (
|
|
<Badge>{m.update_available_badge()}</Badge>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<QueryState
|
|
isLoading={state.isLoading}
|
|
// While an apply restarts the host (and, on Windows, this console server),
|
|
// failed polls are EXPECTED — keep rendering the last snapshot instead of
|
|
// swapping the card for an error box.
|
|
error={inFlight ? undefined : state.error}
|
|
refetch={state.refetch}
|
|
>
|
|
{s && (
|
|
<>
|
|
<dl className="grid grid-cols-1 gap-3">
|
|
<UpdateRow
|
|
label={m.update_current()}
|
|
value={
|
|
<span className="flex items-center gap-2 font-medium">
|
|
{s.current_version}
|
|
<Badge variant="secondary">{s.channel}</Badge>
|
|
<Badge variant="outline" title={m.update_install_kind()}>
|
|
{s.install_kind}
|
|
</Badge>
|
|
</span>
|
|
}
|
|
/>
|
|
<UpdateRow
|
|
label={m.update_latest()}
|
|
value={
|
|
s.manifest ? (
|
|
<span className="flex items-center gap-2 font-medium">
|
|
{s.manifest.version}
|
|
{s.manifest.notes_url && (
|
|
<a
|
|
href={s.manifest.notes_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-sm font-normal underline underline-offset-2"
|
|
>
|
|
{m.update_notes()}
|
|
</a>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-muted-foreground">
|
|
{s.not_published
|
|
? m.update_none_published()
|
|
: m.update_never_checked()}
|
|
</span>
|
|
)
|
|
}
|
|
/>
|
|
</dl>
|
|
|
|
{inFlight ? (
|
|
<ApplyProgress
|
|
status={s}
|
|
reconnecting={Boolean(state.error)}
|
|
timedOut={timedOut}
|
|
snapshotStale={snapshotStale}
|
|
onGiveUp={onGiveUp}
|
|
/>
|
|
) : s.available ? (
|
|
s.apply === "full" || s.apply === "staged" ? (
|
|
<ApplyPanel status={s} onApplied={onApplied} />
|
|
) : (
|
|
<div className="space-y-2 rounded-md border p-4">
|
|
<p className="text-sm">{m.update_how()}</p>
|
|
<CommandLine command={s.channel_hint} />
|
|
{s.opt_in_hint && (
|
|
<div className="space-y-1 border-t pt-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.update_opt_in()}
|
|
</p>
|
|
<CommandLine command={s.opt_in_hint} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
s.manifest && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.update_up_to_date()}
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
{!inFlight && s.last_result && (
|
|
<LastResult result={s.last_result} />
|
|
)}
|
|
|
|
{s.manifest?.stale && (
|
|
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
{m.update_stale()}
|
|
</p>
|
|
)}
|
|
{/* An empty channel is a normal state, not a fault: it looks like a
|
|
404 down at the transport, but it means "nobody has announced a
|
|
release here yet". Rendering it in the failure style told
|
|
operators their host was broken when nothing was. The host keeps
|
|
the two apart (`not_published` is never set alongside
|
|
`last_error`), so this stays a straight either/or. */}
|
|
{!inFlight && s.not_published && (
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.update_not_published({ channel: s.channel })}
|
|
</p>
|
|
)}
|
|
{!inFlight && s.last_error && (
|
|
<p className="text-sm text-destructive">
|
|
{m.update_error()} {s.last_error}
|
|
</p>
|
|
)}
|
|
{s.check_disabled ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{m.update_disabled()}
|
|
</p>
|
|
) : (
|
|
!inFlight && (
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onCheck}
|
|
disabled={checkBusy}
|
|
>
|
|
{checkBusy ? m.update_checking() : m.update_check_now()}
|
|
</Button>
|
|
{s.last_checked_unix != null && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{m.update_last_checked()}{" "}
|
|
{fmtDateTimeSecs(s.last_checked_unix)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
/** The one-click leg: the Update button + its password-confirm dialog. */
|
|
const ApplyPanel: FC<{
|
|
status: UpdateStatus;
|
|
onApplied: (target: string) => void;
|
|
}> = ({ status, onApplied }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [needsForce, setNeedsForce] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const target = status.manifest?.version ?? "";
|
|
|
|
const submit = async (force: boolean) => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
// Plain fetch on purpose: apiFetch treats ANY 401 as "session expired → /login",
|
|
// but here a 401 is just a wrong password confirmation.
|
|
const res = await fetch("/api/v1/update/apply", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({ password, force }),
|
|
});
|
|
if (res.status === 202) {
|
|
setOpen(false);
|
|
setPassword("");
|
|
onApplied(target);
|
|
return;
|
|
}
|
|
const body = (await res.json().catch(() => null)) as {
|
|
error?: string;
|
|
} | null;
|
|
if (res.status === 401) {
|
|
setError(m.update_apply_wrong_password());
|
|
} else if (res.status === 429) {
|
|
setError(m.update_apply_throttled());
|
|
} else if (res.status === 409 && body?.error?.includes("force")) {
|
|
// The host refused because a stream is live — escalate to the explicit
|
|
// "drop the stream" confirmation instead of showing a raw error.
|
|
setNeedsForce(true);
|
|
} else {
|
|
setError(body?.error ?? `HTTP ${res.status}`);
|
|
}
|
|
} catch {
|
|
setError(m.common_error());
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 rounded-md border p-4">
|
|
<p className="text-sm">{m.update_apply_ready({ version: target })}</p>
|
|
<div className="flex items-center gap-3">
|
|
<Button size="sm" onClick={() => setOpen(true)}>
|
|
{m.update_apply_button()}
|
|
</Button>
|
|
<CommandLine command={status.channel_hint} />
|
|
</div>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
setOpen(o);
|
|
if (!o) {
|
|
setPassword("");
|
|
setError(null);
|
|
setNeedsForce(false);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{m.update_apply_confirm_title({ version: target })}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{needsForce
|
|
? m.update_apply_force_warning()
|
|
: m.update_apply_confirm_body()}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form
|
|
className="space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void submit(needsForce);
|
|
}}
|
|
>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="update-apply-password">
|
|
{m.update_apply_password_label()}
|
|
</Label>
|
|
<Input
|
|
id="update-apply-password"
|
|
type="password"
|
|
autoFocus
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
</div>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
<DialogFooter>
|
|
<Button
|
|
type="submit"
|
|
variant={needsForce ? "destructive" : "default"}
|
|
disabled={busy || password.length === 0}
|
|
>
|
|
{busy
|
|
? m.update_apply_working()
|
|
: needsForce
|
|
? m.update_apply_force_button()
|
|
: m.update_apply_button()}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** The in-flight panel: download progress → applying → restarting → reconnecting. */
|
|
const ApplyProgress: FC<{
|
|
status: UpdateStatus;
|
|
reconnecting: boolean;
|
|
timedOut: boolean;
|
|
/** The rendered snapshot can no longer be refreshed — its `job` may describe a vanished host. */
|
|
snapshotStale: boolean;
|
|
onGiveUp: () => void;
|
|
}> = ({ status, reconnecting, timedOut, snapshotStale, onGiveUp }) => {
|
|
const job = status.job;
|
|
const pct =
|
|
job?.total_bytes && job.total_bytes > 0
|
|
? Math.min(100, Math.round((job.received_bytes / job.total_bytes) * 100))
|
|
: null;
|
|
const stageLabel = (() => {
|
|
if (reconnecting) return m.update_stage_reconnecting();
|
|
switch (job?.stage) {
|
|
case "downloading":
|
|
return pct === null
|
|
? m.update_stage_downloading_indeterminate()
|
|
: m.update_stage_downloading({ pct: String(pct) });
|
|
case "verifying":
|
|
return m.update_stage_verifying();
|
|
case "applying":
|
|
return m.update_stage_applying();
|
|
default:
|
|
return m.update_stage_restarting();
|
|
}
|
|
})();
|
|
return (
|
|
<div className="space-y-3 rounded-md border p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Spinner className="size-4" />
|
|
<p className="text-sm font-medium">
|
|
{m.update_applying_title({
|
|
version: job?.target_version ?? status.manifest?.version ?? "",
|
|
})}
|
|
</p>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">{stageLabel}</p>
|
|
{job?.stage === "downloading" && pct !== null && (
|
|
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
|
<div
|
|
className="h-full rounded bg-primary transition-all"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* A job we can still SEE progressing (e.g. the Deck's tens-of-minutes source rebuild) is
|
|
not "timed out" — the warning is for the host being GONE longer than a restart
|
|
explains. A stale snapshot's job does not count as seeing one. */}
|
|
{timedOut && (!job || snapshotStale) && (
|
|
<div className="space-y-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
|
<p>{m.update_apply_timeout()}</p>
|
|
{/* Without this the card sits in "applying" forever and the operator cannot even
|
|
re-check — the state was only ever cleared by a status that never arrives. */}
|
|
<Button variant="outline" size="sm" onClick={onGiveUp}>
|
|
{m.update_apply_give_up()}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/** Durable outcome of the last apply (written by the host across its own restart). */
|
|
const LastResult: FC<{
|
|
result: NonNullable<UpdateStatus["last_result"]>;
|
|
}> = ({ result }) =>
|
|
result.ok ? (
|
|
<p className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
|
{result.staged
|
|
? m.update_result_staged({ to: result.to })
|
|
: result.from === result.to
|
|
? m.update_result_noop()
|
|
: m.update_result_ok({ from: result.from, to: result.to })}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
|
<p className="font-medium text-destructive">
|
|
{m.update_result_failed({ to: result.to, stage: result.stage ?? "?" })}
|
|
</p>
|
|
{result.error && <p>{result.error}</p>}
|
|
{result.log_path && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{m.update_result_log()} <code>{result.log_path}</code>
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const UpdateRow: FC<{ label: string; value: ReactNode }> = ({
|
|
label,
|
|
value,
|
|
}) => (
|
|
<div className="flex items-baseline justify-between gap-4">
|
|
<dt className="text-sm text-muted-foreground">{label}</dt>
|
|
<dd>{value}</dd>
|
|
</div>
|
|
);
|
|
|
|
/** The copy-pastable update command, with a small clipboard affordance. */
|
|
const CommandLine: FC<{ command: string }> = ({ command }) => {
|
|
const [copied, setCopied] = useState(false);
|
|
const copy = () => {
|
|
navigator.clipboard
|
|
.writeText(command)
|
|
.then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
})
|
|
.catch(() => {
|
|
/* clipboard denied — the text is selectable, nothing to do */
|
|
});
|
|
};
|
|
return (
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">
|
|
{command}
|
|
</code>
|
|
<Button variant="ghost" size="sm" onClick={copy}>
|
|
{copied ? m.update_copied() : m.update_copy()}
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|