Files
punktfunk/web/src/sections/Host/GpuCard.tsx
T
enricobuehler a85e845255 fix(web): the console stops falling out of its own design system
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.
2026-08-07 22:34:24 +02:00

198 lines
6.4 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import {
getListGpusQueryKey,
useListGpus,
useSetGpuPreference,
} from "@/api/gen/gpu/gpu";
import type { GpuState } from "@/api/gen/model";
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";
/**
* Container: the host's GPU inventory + selection. Polls (a stream starting/stopping moves the
* "In use" badge; an eGPU can appear) and applies auto/preferred choices via the mgmt API. A
* preference applies to the NEXT session — the help text says so.
*/
export const GpuSection: FC = () => {
const qc = useQueryClient();
// GPU state only moves when a session starts or ends, which the event stream reports — so this
// is a slow safety net rather than a 5 s poll of a device enumeration.
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()),
},
);
return <GpuCard state={gpus} onApply={apply} busy={setPref.isPending} />;
};
const fmtVram = (mb: number) =>
mb >= 1024 ? `${Math.round(mb / 1024)} GiB` : `${mb} MiB`;
/**
* The vendor an explicit `PUNKTFUNK_ENCODER` pin can open on (display name) — the console mirror
* of the host's backend→vendor table. Vendor-agnostic pins (software) and unknown/multi-vendor
* spellings (vaapi, vulkan, pyrowave) map to nothing: no conflict to warn about.
*/
const encoderPinVendor: Record<string, string> = {
nvenc: "NVIDIA",
nvidia: "NVIDIA",
cuda: "NVIDIA",
hw: "NVIDIA",
amf: "AMD",
amd: "AMD",
qsv: "Intel",
intel: "Intel",
};
/**
* The host.env encoder pin, surfaced so a conflicting GPU choice doesn't just look broken: amber
* when the pin's vendor contradicts the next session's GPU (the host overrides the pin at session
* open — the stale pin should be removed), a muted note otherwise.
*/
const EncoderPinNote: FC<{ state: GpuState; pin: string }> = ({
state,
pin,
}) => {
const vendor = encoderPinVendor[pin];
const conflicting =
vendor && state.selected && state.selected.vendor !== vendor.toLowerCase();
return conflicting && state.selected ? (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.gpu_encoder_pin_warning({
value: pin,
vendor,
name: state.selected.name,
})}
</p>
) : (
<p className="text-xs text-muted-foreground">
{m.gpu_encoder_pin_note({ value: pin })}
</p>
);
};
/**
* GPU list in the compositors-card style: per-GPU badges for the manual pick ("Preferred"), what
* the next session will use ("Next session"), and what live sessions encode on right now
* ("In use · NVENC"), plus an Automatic/Prefer control pair.
*/
export const GpuCard: FC<{
state: Loadable<GpuState>;
onApply: (mode: "auto" | "manual", gpuId?: string) => void;
busy: boolean;
}> = ({ state, onApply, busy }) => {
const s = state.data;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between gap-4">
<span>{m.host_gpus()}</span>
{s && s.gpus.length > 0 && (
<Button
size="sm"
variant={s.mode === "auto" ? "default" : "outline"}
disabled={busy || s.mode === "auto"}
onClick={() => onApply("auto")}
>
{m.gpu_automatic()}
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_gpus_help()}</p>
<QueryState
isLoading={state.isLoading}
error={state.error}
refetch={state.refetch}
>
{s &&
(s.gpus.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.gpu_none()}</p>
) : (
<ul className="divide-y rounded-md border">
{s.gpus.map((g) => {
const isActive = s.active?.id === g.id;
const isSelected = s.selected?.id === g.id;
const isPreferred =
s.mode === "manual" && s.preferred_id === g.id;
return (
<li
key={g.id}
className="flex items-center justify-between gap-4 px-4 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{g.name}</span>
{isPreferred && (
<Badge variant="secondary">
{m.gpu_preferred()}
</Badge>
)}
{isActive && s.active ? (
<Badge variant="success">
{m.gpu_in_use({
backend: s.active.backend.toUpperCase(),
})}
</Badge>
) : (
isSelected && (
<Badge variant="default">
{m.gpu_next_session()}
</Badge>
)
)}
</div>
<code className="text-xs text-muted-foreground">
{g.vendor}
{g.vram_mb > 0 ? ` · ${fmtVram(g.vram_mb)}` : ""}
{` · ${g.id}`}
</code>
</div>
<Button
size="sm"
variant="outline"
disabled={busy || isPreferred}
onClick={() => onApply("manual", g.id)}
>
{m.gpu_prefer()}
</Button>
</li>
);
})}
</ul>
))}
{s?.selected?.source === "preference_missing" && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.gpu_missing_warning({ name: s.preferred_name ?? "?" })}
</p>
)}
{s?.env_override && s.mode === "auto" && (
<p className="text-xs text-muted-foreground">
{m.gpu_env_note({ value: s.env_override })}
</p>
)}
{s?.encoder_pin && <EncoderPinNote state={s} pin={s.encoder_pin} />}
</QueryState>
</CardContent>
</Card>
);
};