feat(web): CI screenshot capture for the mgmt console

Marketing/store screenshots of the console, captured from the built Storybook
with headless Chromium (web/tools/screenshots.mjs) — every Pages/* + Shell/*
story rendered at 1440x900@2x. The page stories render from fixtures, so no live
mgmt API, login, or GPU is needed (the web analogue of apple.yml's screenshots
job). Gated to stable release tags in a standalone best-effort workflow; PNGs
upload as a 30-day artifact, not committed.

- Add Stats + Pairing stories (the two pages that lacked them) with stats/pairing
  fixtures typed against the generated models.
- Extract a pure PairingView (index.tsx -> view.tsx), matching the
  Dashboard/Clients/Stats split, so the page renders host-free from mock state
  instead of racing its polling queries. Container wiring is behaviour-identical.
- Playwright driver + a chromium-capable tag-gated job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 15:05:27 +00:00
parent 1bd60ffb34
commit 9e98618e5f
10 changed files with 882 additions and 328 deletions
+53 -326
View File
@@ -1,13 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
KeyRound,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import { type FC, useState } from "react";
import {
getGetNativePairingQueryKey,
@@ -27,224 +18,57 @@ import {
useGetPairingStatus,
useSubmitPairingPin,
} from "@/api/gen/pairing/pairing";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { PairingView } from "./view";
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
// Pairing composes four independent sub-cards, each its own little container
// (own query + mutations). They share the page's staggered entrance via <Section>.
// Container: owns the four sub-cards' queries + mutations and hands a plain props
// surface to PairingView. (The presentational split mirrors Dashboard/Clients/Stats
// and lets Storybook render the page with mock state — no live host.)
export const SectionPairing: FC = () => {
useLocale();
return (
<Section>
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevices />
<NativePairingCard />
<NativeDevices />
<MoonlightPairingCard />
</Section>
);
};
/** Seconds since a knock → a short relative label. */
function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot — no PIN fetched out of band.
* Renders nothing while empty (the common case); polls so a knock appears while
* the operator is looking at the page.
*/
function PendingDevices() {
const qc = useQueryClient();
const [pin, setPin] = useState("");
// Devices awaiting delegated approval — polls so a knock appears while looking.
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
const refresh = () => {
// Native (punktfunk/1) pairing: poll fast while armed (live countdown), slow otherwise.
const native = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
});
const arm = useArmNativePairing();
const disarm = useDisarmNativePairing();
const clients = useListNativeClients();
const unpair = useUnpairNativeClient();
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const refreshPending = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const refreshNative = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
const onApprove = (id: number, currentName: string) => {
const name = prompt(m.pairing_pending_name_prompt(), currentName);
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
{ onSuccess: refreshPending },
);
};
const onDeny = (id: number) =>
deny.mutate({ id }, { onSuccess: refreshPending });
return (
<div className="space-y-2">
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Card>
<CardContent className="p-0">
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={approve.isPending || deny.isPending}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={approve.isPending || deny.isPending}
onClick={() =>
deny.mutate({ id: p.id }, { onSuccess: refresh })
}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</QueryState>
</div>
);
}
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
function NativePairingCard() {
const qc = useQueryClient();
// Poll fast while armed (live countdown), slow otherwise.
const status = useGetNativePairing({
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
});
const arm = useArmNativePairing();
const disarm = useDisarmNativePairing();
const d = status.data;
const refresh = () =>
qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() });
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.pairing_native_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!d?.enabled ? (
<p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? (
<div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p>
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
{d.pin}
</div>
{d.expires_in_secs != null && (
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Timer className="size-4" />
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
</p>
)}
<Button
variant="outline"
className="w-full"
disabled={disarm.isPending}
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
>
{m.pairing_native_cancel()}
</Button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button
disabled={arm.isPending}
onClick={() =>
arm.mutate(
{ data: { ttl_secs: 120 } },
{ onSuccess: refresh },
)
}
>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
</Button>
</>
)}
</CardContent>
</Card>
</QueryState>
);
}
/** The paired native (punktfunk/1) devices, with unpair. */
function NativeDevices() {
const qc = useQueryClient();
const clients = useListNativeClients();
const unpair = useUnpairNativeClient();
const rows = clients.data ?? [];
const onArm = () =>
arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refreshNative });
const onDisarm = () => disarm.mutate(undefined, { onSuccess: refreshNative });
const onUnpair = (fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
@@ -257,73 +81,7 @@ function NativeDevices() {
);
};
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
{m.pairing_native_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">
{c.name || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={unpair.isPending}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</div>
);
}
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
function MoonlightPairingCard() {
const qc = useQueryClient();
const [pin, setPin] = useState("");
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
const submit = useSubmitPairingPin();
const pending = pairing.data?.pin_pending ?? false;
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
const onSubmitPin = () =>
submit.mutate(
{ data: { pin } },
{
@@ -333,59 +91,28 @@ function MoonlightPairingCard() {
},
},
);
};
return (
<QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
{m.pairing_moonlight_title()}
</CardTitle>
</CardHeader>
<CardContent>
{!pending ? (
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
) : (
<form onSubmit={onSubmit} className="space-y-4">
<p className="text-sm">{m.pairing_waiting()}</p>
<div className="space-y-2">
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
<Input
id="pin"
inputMode="numeric"
autoComplete="off"
maxLength={8}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
placeholder="0000"
className="font-mono text-lg tracking-widest"
/>
</div>
<Button
type="submit"
disabled={pin.length < 4 || submit.isPending}
>
{m.pairing_submit()}
</Button>
{submit.isSuccess && (
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
<CheckCircle2 className="size-4" />
{m.pairing_success()}
</p>
)}
{submit.isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form>
)}
</CardContent>
</Card>
</QueryState>
<PairingView
pending={pending}
onApprove={onApprove}
onDeny={onDeny}
pendingBusy={approve.isPending || deny.isPending}
native={native}
onArm={onArm}
onDisarm={onDisarm}
isArming={arm.isPending}
isDisarming={disarm.isPending}
clients={clients}
onUnpair={onUnpair}
isUnpairing={unpair.isPending}
moonlight={pairing}
pin={pin}
onPinChange={setPin}
onSubmitPin={onSubmitPin}
isSubmittingPin={submit.isPending}
pinSuccess={submit.isSuccess}
pinError={submit.isError}
/>
);
}
};
+387
View File
@@ -0,0 +1,387 @@
import {
CheckCircle2,
KeyRound,
Smartphone,
Timer,
Trash2,
UserPlus,
X,
} from "lucide-react";
import type { FC } from "react";
import type { NativeClient } from "@/api/gen/model/nativeClient";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/** Seconds → `m:ss`. */
function fmtTime(secs: number): string {
const s = Math.max(0, Math.floor(secs));
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
}
/** Seconds since a knock → a short relative label. */
function fmtAge(secs: number): string {
if (secs < 10) return m.pairing_pending_age_just_now();
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
}
export interface PairingViewProps {
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
pendingBusy: boolean;
native: Loadable<NativePairStatus>;
onArm: () => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
clients: Loadable<NativeClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
moonlight: Loadable<PairingStatus>;
pin: string;
onPinChange: (v: string) => void;
onSubmitPin: () => void;
isSubmittingPin: boolean;
pinSuccess: boolean;
pinError: boolean;
}
// Pairing composes four independent sub-cards. This is the pure presentational
// surface (mirrors every other page's view.tsx); the container in index.tsx wires
// the queries + mutations. Stories feed mock state so no live host is needed.
export const PairingView: FC<PairingViewProps> = (props) => (
<Section>
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
<PendingDevicesCard
pending={props.pending}
onApprove={props.onApprove}
onDeny={props.onDeny}
busy={props.pendingBusy}
/>
<NativePairingCard
status={props.native}
onArm={props.onArm}
onDisarm={props.onDisarm}
isArming={props.isArming}
isDisarming={props.isDisarming}
/>
<NativeDevicesCard
clients={props.clients}
onUnpair={props.onUnpair}
isUnpairing={props.isUnpairing}
/>
<MoonlightPairingCard
pairing={props.moonlight}
pin={props.pin}
onPinChange={props.onPinChange}
onSubmit={props.onSubmitPin}
isSubmitting={props.isSubmittingPin}
isSuccess={props.pinSuccess}
isError={props.pinError}
/>
</Section>
);
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
* (the common case) unless there's an error to surface.
*/
const PendingDevicesCard: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
busy: boolean;
}> = ({ pending, onApprove, onDeny, busy }) => {
const rows = pending.data ?? [];
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
if (rows.length === 0 && !pending.error) return null;
return (
<div className="space-y-2">
<h2 className="flex items-center gap-2 text-lg font-medium">
<UserPlus className="size-4" />
{m.pairing_pending_title()}
</h2>
<p className="text-sm text-muted-foreground">
{m.pairing_pending_desc()}
</p>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Card>
<CardContent className="p-0">
<Table>
<TableBody>
{rows.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={busy}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={busy}
onClick={() => onDeny(p.id)}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</QueryState>
</div>
);
};
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
const NativePairingCard: FC<{
status: Loadable<NativePairStatus>;
onArm: () => void;
onDisarm: () => void;
isArming: boolean;
isDisarming: boolean;
}> = ({ status, onArm, onDisarm, isArming, isDisarming }) => {
const d = status.data;
return (
<QueryState
isLoading={status.isLoading}
error={status.error}
refetch={status.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="size-4" />
{m.pairing_native_title()}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!d?.enabled ? (
<p className="text-sm text-muted-foreground">
{m.pairing_native_disabled()}
</p>
) : d.armed && d.pin ? (
<div className="space-y-3">
<p className="text-sm">{m.pairing_native_enter()}</p>
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
{d.pin}
</div>
{d.expires_in_secs != null && (
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Timer className="size-4" />
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
</p>
)}
<Button
variant="outline"
className="w-full"
disabled={isDisarming}
onClick={onDisarm}
>
{m.pairing_native_cancel()}
</Button>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
{m.pairing_native_desc()}
</p>
<Button disabled={isArming} onClick={onArm}>
<KeyRound className="size-4" />
{m.pairing_native_arm()}
</Button>
</>
)}
</CardContent>
</Card>
</QueryState>
);
};
/** The paired native (punktfunk/1) devices, with unpair. */
const NativeDevicesCard: FC<{
clients: Loadable<NativeClient[]>;
onUnpair: (fingerprint: string) => void;
isUnpairing: boolean;
}> = ({ clients, onUnpair, isUnpairing }) => {
const rows = clients.data ?? [];
return (
<div className="space-y-2">
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
<QueryState
isLoading={clients.isLoading}
error={clients.error}
refetch={clients.refetch}
>
{rows.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
{m.pairing_native_empty()}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((c) => (
<TableRow key={c.fingerprint}>
<TableCell className="font-medium">
{c.name || "—"}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{c.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={isUnpairing}
onClick={() => onUnpair(c.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</QueryState>
</div>
);
};
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
const MoonlightPairingCard: FC<{
pairing: Loadable<PairingStatus>;
pin: string;
onPinChange: (v: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
isSuccess: boolean;
isError: boolean;
}> = ({
pairing,
pin,
onPinChange,
onSubmit,
isSubmitting,
isSuccess,
isError,
}) => {
const pending = pairing.data?.pin_pending ?? false;
return (
<QueryState
isLoading={pairing.isLoading}
error={pairing.error}
refetch={pairing.refetch}
>
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
{m.pairing_moonlight_title()}
</CardTitle>
</CardHeader>
<CardContent>
{!pending ? (
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
<p className="text-sm">{m.pairing_waiting()}</p>
<div className="space-y-2">
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
<Input
id="pin"
inputMode="numeric"
autoComplete="off"
maxLength={8}
value={pin}
onChange={(e) =>
onPinChange(e.target.value.replace(/\D/g, ""))
}
placeholder="0000"
className="font-mono text-lg tracking-widest"
/>
</div>
<Button type="submit" disabled={pin.length < 4 || isSubmitting}>
{m.pairing_submit()}
</Button>
{isSuccess && (
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
<CheckCircle2 className="size-4" />
{m.pairing_success()}
</p>
)}
{isError && (
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
)}
</form>
)}
</CardContent>
</Card>
</QueryState>
);
};
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PairingView } from "@/sections/Pairing/view";
import {
nativeClients,
nativePairArmed,
pairingIdle,
pendingDevices,
} from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
const meta = {
title: "Pages/Pairing",
component: PairingView,
parameters: { layout: "padded" },
args: {
onApprove: noop,
onDeny: noop,
pendingBusy: false,
onArm: noop,
onDisarm: noop,
isArming: false,
isDisarming: false,
onUnpair: noop,
isUnpairing: false,
pin: "",
onPinChange: noop,
onSubmitPin: noop,
isSubmittingPin: false,
pinSuccess: false,
pinError: false,
},
} satisfies Meta<typeof PairingView>;
export default meta;
type Story = StoryObj<typeof meta>;
// The marketing state: a PIN armed for a phone, one device knocking for delegated
// approval, two already-paired native clients.
export const Armed: Story = {
args: {
pending: { data: pendingDevices, ...idle },
native: { data: nativePairArmed, ...idle },
clients: { data: nativeClients, ...idle },
moonlight: { data: pairingIdle, ...idle },
},
};
+48
View File
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StatsView } from "@/sections/Stats/view";
import { captureDetail, captureMetas, statsStatusIdle } from "./lib/fixtures";
const noop = () => {};
const idle = { isLoading: false, error: null, refetch: noop };
const meta = {
title: "Pages/Stats",
component: StatsView,
parameters: { layout: "padded" },
args: {
onStart: noop,
onStop: noop,
onSelect: noop,
onDownload: noop,
onDelete: noop,
isStarting: false,
isStopping: false,
isDeleting: false,
},
} satisfies Meta<typeof StatsView>;
export default meta;
type Story = StoryObj<typeof meta>;
// A finished run open in the detail view: recordings table populated and the full
// graph set (latency stack · throughput · loss/FEC) rendered from a deterministic
// fixture series — no live host or capture needed.
export const Recording: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: captureMetas, ...idle },
detail: { data: captureDetail, ...idle },
selectedId: captureMetas[0]?.id ?? null,
},
};
export const Empty: Story = {
args: {
status: { data: statsStatusIdle, ...idle },
live: { data: undefined, ...idle },
recordings: { data: [], ...idle },
detail: { data: undefined, ...idle },
selectedId: null,
},
};
+120
View File
@@ -1,10 +1,18 @@
// Mock API payloads for the page stories — typed against the generated models so
// they stay honest if the OpenAPI schema changes.
import type { AvailableCompositor } from "@/api/gen/model/availableCompositor";
import type { Capture } from "@/api/gen/model/capture";
import type { CaptureMeta } from "@/api/gen/model/captureMeta";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import type { HostInfo } from "@/api/gen/model/hostInfo";
import type { NativeClient } from "@/api/gen/model/nativeClient";
import type { NativePairStatus } from "@/api/gen/model/nativePairStatus";
import type { PairedClient } from "@/api/gen/model/pairedClient";
import type { PairingStatus } from "@/api/gen/model/pairingStatus";
import type { PendingDevice } from "@/api/gen/model/pendingDevice";
import type { RuntimeStatus } from "@/api/gen/model/runtimeStatus";
import type { StatsSample } from "@/api/gen/model/statsSample";
import type { StatsStatus } from "@/api/gen/model/statsStatus";
export const hostInfo: HostInfo = {
abi_version: 2,
@@ -112,3 +120,115 @@ export const library: GameEntry[] = [
launch: null,
},
];
// --- Performance (stats) page ------------------------------------------------
export const statsStatusIdle: StatsStatus = {
armed: false,
kind: "native",
sample_count: 0,
started_unix_ms: 0,
};
// A native-path pipeline: capture → submit → encode → send. Deterministic (no
// Math.random) so the screenshot is byte-stable across CI runs; a gentle sine
// gives the charts a realistic shape without a live capture.
const STAGE_BASE_US: Record<string, number> = {
capture: 320,
submit: 90,
encode: 760,
send: 140,
};
const STAGE_ORDER = ["capture", "submit", "encode", "send"];
function buildSamples(n: number): StatsSample[] {
const out: StatsSample[] = [];
for (let i = 0; i < n; i++) {
const wobble = Math.sin(i / 4);
out.push({
t_ms: i * 1000,
session_id: 1,
fps: 240,
repeat_fps: i % 3 === 0 ? 2 : 1,
mbps: 920 + wobble * 55,
bitrate_kbps: 150_000,
frames_dropped: i % 17 === 0 ? 1 : 0,
packets_dropped: i % 9 === 0 ? 2 : 0,
send_dropped: 0,
fec_recovered: i % 5 === 0 ? 3 : 1,
stages: STAGE_ORDER.map((name) => {
const base = STAGE_BASE_US[name] ?? 100;
const p50 = Math.round(base + wobble * base * 0.15);
return { name, p50_us: p50, p99_us: Math.round(p50 * 1.8) };
}),
});
}
return out;
}
export const captureMetas: CaptureMeta[] = [
{
id: "cap-20260628-2041",
client: "enricos-macbook",
kind: "native",
codec: "h265",
width: 5120,
height: 1440,
fps: 240,
duration_ms: 92_000,
sample_count: 92,
started_unix_ms: 1_782_415_260_000,
},
{
id: "cap-20260628-1903",
client: "living-room-tv",
kind: "gamestream",
codec: "av1",
width: 3840,
height: 2160,
fps: 120,
duration_ms: 240_000,
sample_count: 240,
started_unix_ms: 1_782_409_380_000,
},
];
export const captureDetail: Capture = {
meta: captureMetas[0] as CaptureMeta,
samples: buildSamples(60),
};
// --- Pairing page ------------------------------------------------------------
export const nativePairArmed: NativePairStatus = {
enabled: true,
armed: true,
pin: "4827",
expires_in_secs: 98,
paired_clients: 2,
};
export const pendingDevices: PendingDevice[] = [
{
id: 1,
name: "studio-deck",
fingerprint:
"9f8e7d6c5b4a39281706f5e4d3c2b1a0998877665544332211ffeeddccbbaa00",
age_secs: 8,
},
];
export const nativeClients: NativeClient[] = [
{
name: "enricos-macbook",
fingerprint:
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
},
{
name: "living-room-tv",
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
},
];
export const pairingIdle: PairingStatus = { pin_pending: false };