Files
punktfunk/web/src/sections/Pairing/PairedDevices.tsx
T
enricobuehlerandClaude Opus 4.8 75b3c94f60 fix(web): console sweep — pairing, displays, stats, logs, auth, i18n
Pairing:
- Refresh the paired-devices list after a native PIN pairing (the happy path never
  invalidated it, so a newly paired device stayed hidden until remount).
- Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so
  it now reads "PIN sent" instead of a false "Paired successfully".
- Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could
  never receive a PIN there.
- Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8).

Displays / Library:
- "Arrange displays" save refreshes the settings card (it rewrites the policy), without
  clobbering unsaved Custom edits (re-seed only when the draft still matches the server).
- Live-display list wrapped in QueryState so errors don't read as "no displays".
- "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo
  artwork (was dropped on save); per-card delete pending.

Stats:
- Distinct colour for the native "queue" latency stage (it collided with "capture").
- "Not measured on this path" note on the GameStream health chart; configured-bitrate
  target line on throughput; host-authoritative elapsed timer; LiveCard surfaces
  non-404 errors.

Shell / auth / i18n:
- SSR-stable locale: first client render matches the base-locale SSR (no hydration
  mismatch), then adopts the persisted/browser locale post-hydration.
- BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced
  into a post-login redirect loop.
- Logout checks the POST result before navigating; logs dedup by seq (StrictMode);
  login "next" keeps query/hash; Dashboard shows the active-session count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:06:39 +02:00

182 lines
5.1 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import type { FC } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
import {
getListNativeClientsQueryKey,
useListNativeClients,
useUnpairNativeClient,
} from "@/api/gen/native/native";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { m } from "@/paraglide/messages";
/** The two pairing protocols a device can be paired over. */
export type PairedProtocol = "native" | "moonlight";
/** One paired device, normalized across the native + Moonlight lists. */
export interface PairedRow {
protocol: PairedProtocol;
fingerprint: string;
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
name: string;
}
/**
* Container: ALL paired devices in one list. Merges the native (punktfunk/1) clients and the
* GameStream/Moonlight clients — two separate host endpoints — into a single table tagged by
* protocol, and routes each unpair back to the right endpoint.
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
const unpairMoonlight = useUnpairClient();
const rows: PairedRow[] = [
...(native.data ?? []).map(
(c): PairedRow => ({
protocol: "native",
fingerprint: c.fingerprint,
name: c.name,
}),
),
...(moonlight.data ?? []).map(
(c): PairedRow => ({
protocol: "moonlight",
fingerprint: c.fingerprint,
name: c.subject ?? "",
}),
),
];
const onUnpair = (protocol: PairedProtocol, fingerprint: string) => {
if (!confirm(m.pairing_native_unpair_confirm())) return;
if (protocol === "native") {
unpairNative.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }),
},
);
} else {
unpairMoonlight.mutate(
{ fingerprint },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
},
);
}
};
// The fingerprint of the row whose unpair is in flight (if any) — so only THAT row's button
// disables, not every row's.
const pendingFingerprint =
(unpairNative.isPending
? unpairNative.variables?.fingerprint
: undefined) ??
(unpairMoonlight.isPending
? unpairMoonlight.variables?.fingerprint
: undefined) ??
null;
return (
<PairedDevices
rows={rows}
isLoading={native.isLoading || moonlight.isLoading}
error={native.error ?? moonlight.error}
refetch={() => {
native.refetch();
moonlight.refetch();
}}
onUnpair={onUnpair}
pendingFingerprint={pendingFingerprint}
/>
);
};
/** All paired devices (native + Moonlight) in one table, differentiated by a protocol badge. */
export const PairedDevices: FC<{
rows: PairedRow[];
isLoading: boolean;
error: unknown;
refetch: () => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
/** Fingerprint of the row whose unpair is in flight, or null — only that row disables. */
pendingFingerprint: string | null;
}> = ({ rows, isLoading, error, refetch, onUnpair, pendingFingerprint }) => (
<Card>
<CardHeader>
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
</CardHeader>
<CardContent className="p-6">
<QueryState isLoading={isLoading} error={error} refetch={refetch}>
{rows.length === 0 ? (
m.pairing_native_empty()
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.clients_name()}</TableHead>
<TableHead>{m.pairing_protocol()}</TableHead>
<TableHead>{m.clients_fingerprint()}</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={`${r.protocol}:${r.fingerprint}`}>
<TableCell className="font-medium">{r.name || "—"}</TableCell>
<TableCell>
<Badge
variant={
r.protocol === "native" ? "default" : "secondary"
}
>
{r.protocol === "native"
? m.pairing_protocol_native()
: m.pairing_protocol_moonlight()}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{r.fingerprint.slice(0, 16)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={m.action_unpair()}
disabled={pendingFingerprint === r.fingerprint}
onClick={() => onUnpair(r.protocol, r.fingerprint)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</QueryState>
</CardContent>
</Card>
);