feat(host,web): host log ring + GET /api/v1/logs + console Logs page
Remote debugging without shell access: a tracing layer tees every event at DEBUG-and-up — independent of the RUST_LOG filter gating stderr/host.log, so console-side debugging never needs a restart — into a bounded in-memory ring (log_capture.rs, 4096 newest entries, OnceLock singleton like config()), installed at both init sites (stderr path in main, the Windows service file path). The mgmt API serves it cursor-paged at GET /api/v1/logs?after=&limit= — bearer-only and deliberately NOT on the mTLS cert allowlist (log lines can name client identities and host paths). The web console grows a Logs page (follow/pause · min-level filter · text search · eviction-gap badge); polling self-paces: a non-empty page advances the after-cursor (new query key → immediate refetch, drains backlogs), an empty page idles at the 2s interval. OpenAPI regenerated; ring pagination/eviction, layer wiring, and the authed route are unit-tested; Storybook story included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
GaugeCircle,
|
||||
KeyRound,
|
||||
LibraryBig,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
@@ -22,6 +23,7 @@ const NAV = [
|
||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionLogs } from "@/sections/Logs";
|
||||
|
||||
export const Route = createFileRoute("/logs")({ component: SectionLogs });
|
||||
@@ -0,0 +1,183 @@
|
||||
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) => [...prev, ...data.entries].slice(-KEEP));
|
||||
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)}`;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { FC } from "react";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { LogsSection } from "./LogsCard";
|
||||
import { LogsView } from "./view";
|
||||
|
||||
// Logs = one self-contained viewer card owning its polling; this container only binds the layout.
|
||||
export const SectionLogs: FC = () => {
|
||||
useLocale();
|
||||
return <LogsView viewer={<LogsSection />} />;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* The Logs page LAYOUT — the live page (`index.tsx`) and the Storybook story fill the single
|
||||
* `viewer` slot, so the arrangement can never drift between them (same pattern as StatsView).
|
||||
*/
|
||||
export const LogsView: FC<{ viewer: ReactNode }> = ({ viewer }) => (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.logs_title()}</h1>
|
||||
<p className="text-sm text-muted-foreground">{m.logs_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
{viewer}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||
import { LogsCard } from "@/sections/Logs/LogsCard";
|
||||
import { LogsView } from "@/sections/Logs/view";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// A deterministic slice of host logs covering every level, incl. the gamepad-driver health lines
|
||||
// the page exists to surface — no live host needed.
|
||||
const BASE = 1_750_000_000_000;
|
||||
const entry = (
|
||||
seq: number,
|
||||
level: string,
|
||||
target: string,
|
||||
msg: string,
|
||||
): LogEntry => ({ seq, ts_ms: BASE + seq * 750, level, target, msg });
|
||||
|
||||
const fixtureEntries: LogEntry[] = [
|
||||
entry(1, "INFO", "punktfunk_host", "punktfunk-host 0.4.2 (punktfunk_core ABI v2)"),
|
||||
entry(2, "INFO", "punktfunk_host::mgmt", "management API listening over HTTPS addr=0.0.0.0:47990"),
|
||||
entry(3, "DEBUG", "punktfunk_host::discovery", "mDNS advertise _punktfunk._udp pair=required"),
|
||||
entry(4, "INFO", "punktfunk_host::punktfunk1", "session start mode=1920x1080@60 codec=hevc"),
|
||||
entry(5, "INFO", "punktfunk_host::inject", "virtual Xbox 360 created (Windows XUSB companion)"),
|
||||
entry(6, "WARN", "punktfunk_host::inject", "gamepad driver not attached to Global\\pfxusb-shm-0 after 3s — is the pf_xusb driver installed? (punktfunk-host.exe driver install --gamepad)"),
|
||||
entry(7, "ERROR", "punktfunk_host::inject", "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)"),
|
||||
entry(8, "INFO", "punktfunk_host::encode", "NVENC opened 1920x1080 nv12 gop=inf rfi=on"),
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Logs",
|
||||
component: LogsView,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof LogsView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// The real page layout (LogsView) with the pure viewer card + fixture entries in its slot.
|
||||
export const Following: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
<LogsCard
|
||||
entries={fixtureEntries}
|
||||
follow
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
dropped={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const PausedWithGap: Story = {
|
||||
args: {
|
||||
viewer: (
|
||||
<LogsCard
|
||||
entries={fixtureEntries}
|
||||
follow={false}
|
||||
onFollow={noop}
|
||||
onClear={noop}
|
||||
dropped
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user