Files
punktfunk/web/src/sections/Logs/LogsCard.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

189 lines
5.6 KiB
TypeScript

import { Pause, Play, Trash2 } from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { useLogsGet } from "@/api/gen/logs/logs";
import type { LogEntry } from "@/api/gen/model/logEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
type MinLevel = (typeof LEVELS)[number];
const RANK: Record<string, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
const LEVEL_CLASS: Record<string, string> = {
ERROR: "text-red-400",
WARN: "text-amber-400",
INFO: "text-sky-300",
DEBUG: "text-muted-foreground",
TRACE: "text-muted-foreground",
};
const KEEP = 5_000; // accumulated entries (client memory bound)
const SHOW = 1_000; // rendered rows (DOM bound)
/**
* Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key,
* so the next page fetches immediately and a backlog drains fast; an empty page leaves the key
* unchanged and `refetchInterval` paces the idle poll. Pausing (follow off) stops the interval.
*/
export const LogsSection: FC = () => {
const [cursor, setCursor] = useState(0);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{ query: { refetchInterval: follow ? 2_000 : false } },
);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
// Only append entries newer than what we already hold — dedup by the monotonic `seq`.
// Guards a double-invoked mount effect (React StrictMode, or `data` warm in cache) from
// appending the same page twice (duplicate rows + duplicate React keys).
const lastSeq = prev.at(-1)?.seq ?? -1;
const fresh = data.entries.filter((e) => e.seq > lastSeq);
return fresh.length ? [...prev, ...fresh].slice(-KEEP) : prev;
});
setDropped((d) => d || data.dropped);
setCursor(data.next);
}, [data]);
return (
<LogsCard
entries={entries}
follow={follow}
onFollow={setFollow}
onClear={() => {
setEntries([]);
setDropped(false);
}}
dropped={dropped}
/>
);
};
/** Pure log viewer: level/min filter + text search (local UI state), follow + clear controls. */
export const LogsCard: FC<{
entries: LogEntry[];
follow: boolean;
onFollow: (follow: boolean) => void;
onClear: () => void;
dropped: boolean;
}> = ({ entries, follow, onFollow, onClear, dropped }) => {
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const min = RANK[minLevel] ?? 0;
const q = search.trim().toLowerCase();
return entries
.filter(
(e) =>
(RANK[e.level] ?? 0) >= min &&
(q === "" ||
e.msg.toLowerCase().includes(q) ||
e.target.toLowerCase().includes(q)),
)
.slice(-SHOW);
}, [entries, minLevel, search]);
// Keep the tail in view while following (entries are append-only, so length is a good signal).
useEffect(() => {
if (!follow) return;
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [follow, filtered.length]);
return (
<Card>
<CardContent className="flex flex-col gap-3 pt-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
<Button
key={l}
size="sm"
variant={minLevel === l ? "secondary" : "ghost"}
onClick={() => setMinLevel(l)}
>
{l}
</Button>
))}
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={m.logs_search()}
className="max-w-xs"
/>
<div className="ml-auto flex items-center gap-2">
{dropped && <Badge variant="secondary">{m.logs_dropped()}</Badge>}
<Button
size="sm"
variant={follow ? "secondary" : "outline"}
onClick={() => onFollow(!follow)}
>
{follow ? (
<Pause className="mr-1 size-3.5" />
) : (
<Play className="mr-1 size-3.5" />
)}
{follow ? m.logs_pause() : m.logs_follow()}
</Button>
<Button size="sm" variant="ghost" onClick={onClear}>
<Trash2 className="mr-1 size-3.5" />
{m.logs_clear()}
</Button>
</div>
</div>
<div
ref={listRef}
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{filtered.length === 0 ? (
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
) : (
filtered.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
<span className="text-muted-foreground">
{fmtTime(e.ts_ms)}{" "}
</span>
<span
className={cn(
"font-medium",
LEVEL_CLASS[e.level] ?? "text-muted-foreground",
)}
>
{e.level.padEnd(5)}{" "}
</span>
<span className="text-muted-foreground">{e.target} </span>
<span>{e.msg}</span>
</div>
))
)}
</div>
</CardContent>
</Card>
);
};
const fmtTime = (ts: number): string => {
const d = new Date(ts);
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
};