Client logs join the host log on one timeline instead of a table below it #274

Merged
enricobuehler merged 2 commits from worktree-console-log-tabs into main 2026-08-16 20:51:34 +00:00
11 changed files with 1054 additions and 310 deletions
@@ -856,7 +856,11 @@ mod tests {
let mask = plane(&[0, 0, 1, 1]);
let out = masked_color_to_rgba(&color, &mask, 4, 1);
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 colour=0 ⇒ black");
assert_eq!(px(&out, 1), [0xCC, 0, 0, 0xFF], "AND=0 colour ⇒ opaque colour");
assert_eq!(
px(&out, 1),
[0xCC, 0, 0, 0xFF],
"AND=0 colour ⇒ opaque colour"
);
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
// so the outline claims it — same as the monochrome table.
assert_eq!(
@@ -864,7 +868,11 @@ mod tests {
OPAQUE_WHITE,
"outline grows into adjacent transparency"
);
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 colour≠0 ⇒ invert, not drop");
assert_eq!(
px(&out, 3),
OPAQUE_BLACK,
"AND=1 colour≠0 ⇒ invert, not drop"
);
}
/// AND=1 and a zero colour pixel stays transparent when nothing invert-neighbours it.
+7 -3
View File
@@ -439,9 +439,14 @@
"diag_uinput_access_title": "Unterstützung für virtuelle Controller",
"diag_server_conflict_title": "Konkurrierender Streaming-Server",
"logs_title": "Logs",
"logs_source_all": "Alle",
"logs_sources_label": "Quellen",
"logs_sources_none": "Keine Quelle ausgewählt — wähle oben mindestens eine aus.",
"logs_devices_empty": "Noch kein Gerät hat Logs geschickt — nutze „Logs an Host senden“ im Host-Menü eines Clients.",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
"logs_export_all": "Alles exportieren",
"logs_export_all_working": "Wird gesammelt…",
"logs_export_all_failed": "Export konnte nicht erstellt werden",
"logs_empty_plugins": "Noch keine Plugin-Ausgabe. Plugins loggen hier, sobald der Plugin-Runner läuft — prüfe `punktfunk-host plugins status`.",
"logs_follow": "Folgen",
"logs_pause": "Pause",
@@ -455,8 +460,7 @@
"logs_copy": "Logs in die Zwischenablage kopieren",
"logs_copied": "Logs in die Zwischenablage kopiert",
"logs_share_failed": "Logs konnten nicht geteilt werden",
"client_logs_title": "Client-Logs",
"client_logs_subtitle": "Log-Pakete, die deine Geräte mit „Logs an Host senden“ geschickt haben — von Plattformen, deren eigene Dateien unerreichbar sind, etwa einem Steam Deck im Gaming Mode oder einem Apple TV.",
"client_logs_manage": "{count} hochgeladene Pakete verwalten",
"client_logs_col_received": "Empfangen",
"client_logs_col_device": "Gerät",
"client_logs_col_size": "Größe",
+7 -3
View File
@@ -439,9 +439,14 @@
"diag_uinput_access_title": "Virtual controller support",
"diag_server_conflict_title": "Competing streaming server",
"logs_title": "Logs",
"logs_source_all": "All",
"logs_sources_label": "Sources",
"logs_sources_none": "No sources selected — pick at least one above.",
"logs_devices_empty": "No device has sent logs yet — use “Send logs to host” in a client's host menu.",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
"logs_export_all": "Export all",
"logs_export_all_working": "Collecting…",
"logs_export_all_failed": "Couldn't assemble the export",
"logs_empty_plugins": "No plugin output yet. Plugins log here once the plugin runner is running — check `punktfunk-host plugins status`.",
"logs_follow": "Follow",
"logs_pause": "Pause",
@@ -455,8 +460,7 @@
"logs_copy": "Copy logs to clipboard",
"logs_copied": "Logs copied to clipboard",
"logs_share_failed": "Couldn't share the logs",
"client_logs_title": "Client logs",
"client_logs_subtitle": "Log bundles your devices sent with “Send logs to host” — from platforms whose own files are out of reach, like a Steam Deck in Gaming Mode or an Apple TV.",
"client_logs_manage": "Manage {count} uploaded bundles",
"client_logs_col_received": "Received",
"client_logs_col_device": "Device",
"client_logs_col_size": "Size",
+106 -86
View File
@@ -1,18 +1,16 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Download, Trash2 } from "lucide-react";
import type { FC } from "react";
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import { ChevronDown, ChevronRight, Download, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
clientLogsGet,
getClientLogsListQueryKey,
useClientLogsDelete,
useClientLogsList,
} from "@/api/gen/logs/logs";
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Table,
TableBody,
@@ -33,14 +31,20 @@ const fmtSize = (bytes: number): string =>
: `${(bytes / 1024).toFixed(1)} KB`;
/**
* Container: log bundles paired clients uploaded via "Send logs to host" — the log-escape hatch
* for platforms whose own files the user can't reach (a Deck in Gaming Mode, tvOS). Owns the
* list query, the text download, and delete.
* Container for bundle housekeeping: fetch the raw file, delete one.
*
* Reading a bundle is no longer this component's job — the device chips merge it into the viewer
* above. What is left is the file-cabinet half (keep the raw bytes, throw one away), which is
* occasional and belongs behind a disclosure rather than in a card of its own competing with the
* log for the top of the page.
*/
export const ClientLogsSection: FC = () => {
export const ClientLogsSection: FC<{
list: Loadable<ClientLogMeta[]>;
/** Drop a deleted bundle's rows from the viewer — the list alone cannot do that. */
onDeleted: (id: string) => void;
}> = ({ list, onDeleted }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const bundles = useClientLogsList();
const del = useClientLogsDelete();
const onDelete = async (id: string) => {
@@ -54,8 +58,10 @@ export const ClientLogsSection: FC = () => {
del.mutate(
{ id },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() }),
onSuccess: () => {
onDeleted(id);
qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() });
},
onError: (e) =>
toast.error(apiErrorMessage(e) ?? m.client_logs_delete_failed()),
},
@@ -82,7 +88,7 @@ export const ClientLogsSection: FC = () => {
return (
<ClientLogsCard
bundles={bundles}
bundles={list}
onDownload={onDownload}
onDelete={onDelete}
isDeleting={del.isPending}
@@ -90,89 +96,103 @@ export const ClientLogsSection: FC = () => {
);
};
/** Uploaded client log bundles, newest first, with Download / Delete row actions. */
/**
* Uploaded bundles as a collapsed disclosure: Download (raw) / Delete per row.
*
* Collapsed by default because it answers a question nobody arrives with. It renders nothing at all
* when there is nothing stored — the "no device has sent logs yet" hint now lives beside the source
* chips, where someone who has never used the feature will actually meet it.
*/
export const ClientLogsCard: FC<{
bundles: Loadable<ClientLogMeta[]>;
onDownload: (id: string) => void;
onDelete: (id: string) => void;
isDeleting: boolean;
}> = ({ bundles, onDownload, onDelete, isDeleting }) => {
const [open, setOpen] = useState(false);
const rows = bundles.data ?? [];
// No bundles is the ordinary state (nothing was ever sent) — an empty card would just be
// noise on every visit, so the whole card only appears once something arrived. Errors and
// loading still render: a broken list must not look like "nothing was sent".
// Errors and loading still render: a broken list must not look like "nothing was sent".
if (!bundles.isLoading && !bundles.error && rows.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="space-y-1">
<h2 className="text-lg font-medium">{m.client_logs_title()}</h2>
<p className="text-sm text-muted-foreground">
{m.client_logs_subtitle()}
</p>
</div>
</CardHeader>
<QueryState
isLoading={bundles.isLoading}
error={bundles.error}
refetch={bundles.refetch}
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<CardContent flush>
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.client_logs_col_received()}</TableHead>
<TableHead>{m.client_logs_col_device()}</TableHead>
<TableHead className="text-right">
{m.client_logs_col_size()}
</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.received_ms)}
</TableCell>
<TableCell>
<span>{r.device_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{r.fingerprint_prefix}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtSize(r.size_bytes)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_download()}
title={m.client_logs_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_delete()}
title={m.client_logs_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
{m.client_logs_manage({ count: rows.length })}
</button>
{open && (
<QueryState
isLoading={bundles.isLoading}
error={bundles.error}
refetch={bundles.refetch}
>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.client_logs_col_received()}</TableHead>
<TableHead>{m.client_logs_col_device()}</TableHead>
<TableHead className="text-right">
{m.client_logs_col_size()}
</TableHead>
<TableHead className="w-24" />
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</QueryState>
</Card>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.received_ms)}
</TableCell>
<TableCell>
<span>{r.device_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{r.fingerprint_prefix}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtSize(r.size_bytes)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_download()}
title={m.client_logs_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_delete()}
title={m.client_logs_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</QueryState>
)}
</div>
);
};
+149 -190
View File
@@ -1,22 +1,29 @@
import { toast } from "@unom/ui/toast";
import { Copy, Download, Pause, Play, Share2, 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 {
AlertCircle,
Copy,
Download,
Pause,
Play,
Share2,
Trash2,
} from "lucide-react";
import {
type FC,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react";
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 { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import {
detectShareMode,
downloadText,
logFilename,
logsToText,
type ShareMode,
shareLogs,
} from "./export";
import type { ShareMode } from "./export";
import { PLUGINS_SOURCE, type Row } from "./rows";
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
type MinLevel = (typeof LEVELS)[number];
@@ -35,151 +42,48 @@ const LEVEL_CLASS: Record<string, string> = {
TRACE: "text-muted-foreground",
};
const KEEP = 5_000; // accumulated entries (client memory bound)
const SHOW = 1_000; // rendered rows (DOM bound)
/**
* Producer filter. The ring carries the host's own `tracing` events AND whatever the plugin runner
* ships up (`POST /api/v1/plugins/logs`), the latter targeted `plugin:<name>`. Without this the two
* are interleaved with nothing but the target column to tell them apart, and "show me what my
* plugin said" — the question that sends people to `journalctl` — means knowing to type `plugin:`
* into the search box.
* One selectable producer. Host and plugins are always offered; devices appear as their bundles
* arrive.
*/
const SOURCES = ["all", "host", "plugins"] as const;
type Source = (typeof SOURCES)[number];
/** The target prefix the host stamps on every runner-shipped line. */
const PLUGIN_TARGET_PREFIX = "plugin:";
const matchesSource = (target: string, source: Source): boolean =>
source === "all" ||
(source === "plugins") === target.startsWith(PLUGIN_TARGET_PREFIX);
const SOURCE_LABEL: Record<Source, () => string> = {
all: () => m.logs_source_all(),
host: () => m.logs_source_host(),
plugins: () => m.logs_source_plugins(),
};
export interface SourceChoice {
id: string;
label: string;
/** A device chip's second line — when that bundle landed. */
hint?: string;
selected: boolean;
/** Fetching the bundle, or failed to. Host and plugins are never either. */
state?: "loading" | "error";
}
/**
* 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 [shareMode, setShareMode] = useState<ShareMode | null>(null);
// Set while a poll has failed and we have not yet re-read the ring from the start.
const [resync, setResync] = useState(false);
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
// on hydration. Until then the share button is simply absent.
useEffect(() => {
setShareMode(detectShareMode());
}, []);
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{
query: {
refetchInterval: follow ? 2_000 : false,
// Pausing must actually pause. Stopping only the interval left React Query's default
// focus/reconnect refetches landing, and the append effect consumed them
// unconditionally — so tabbing away and back evicted the lines the operator had
// paused on, from behind the pause button.
refetchOnWindowFocus: follow,
refetchOnReconnect: follow,
},
},
);
// Resync after the host goes away and comes back.
//
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
// no error, no dropped badge and no way back short of a full reload. The console's own update
// flow restarts the host, so this was reachable from two clicks away.
//
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
// we re-read from the start of the ring once and let the effect below decide whether the
// sequence actually regressed.
const failed = query.isError;
useEffect(() => {
if (failed) setResync(true);
}, [failed]);
useEffect(() => {
if (resync && cursor !== 0) setCursor(0);
}, [resync, cursor]);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
const lastSeq = prev.at(-1)?.seq ?? -1;
// A page whose newest entry is OLDER than what we already hold can only mean the host's
// sequence restarted underneath us — the buffer describes a host that no longer exists,
// so replace it wholesale rather than filtering every new line away as "already seen".
const newest = data.entries.at(-1)?.seq ?? -1;
if (newest < lastSeq) return data.entries.slice(-KEEP);
// Otherwise append only what's newer — 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), and makes the post-resync
// re-read from 0 a no-op when the host did NOT restart.
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);
setResync(false);
}, [data]);
// The card hands back the entries its filters currently match, so an export carries exactly what
// the viewer shows — never the DOM-bounded tail of it.
return (
<LogsCard
entries={entries}
follow={follow}
onFollow={setFollow}
onClear={() => {
setEntries([]);
setDropped(false);
}}
onDownload={(shown) =>
downloadText(logsToText(shown), logFilename(new Date()))
}
onShare={async (shown) => {
const outcome = await shareLogs(
logsToText(shown),
logFilename(new Date()),
);
if (outcome === "copied") toast.success(m.logs_copied());
else if (outcome === "failed") toast.error(m.logs_share_failed());
}}
shareMode={shareMode}
dropped={dropped}
error={query.error}
isLoading={query.isLoading}
onRetry={() => query.refetch()}
/>
);
};
/**
* Pure log viewer: level/min filter + text search (local UI state), follow, clear, and export.
* Export is the filters' full result, not the rendered tail — the `SHOW` cap is a DOM budget and
* has no business truncating a file destined for a bug report.
* The log viewer: one pane, one timeline, every producer on it.
*
* The source control is **multi-select**, which is the whole point rather than a detail. The old
* `All | Host | Plugins` strip could only ever isolate one producer, so the question that actually
* brings someone here — "the client stalled at 12:03:47; what was the host doing?" — had no view
* at all. Any combination is now expressible, and Host + one device is the interesting one.
*
* A line that does not parse still renders (see `rows.ts`), and the level/search filters treat an
* unparsed row as level-less rather than hiding it: a filter must never be the reason a log looks
* empty when it isn't.
*/
export const LogsCard: FC<{
entries: LogEntry[];
/** Every loaded row, merged and sorted. The card filters; the caller does not pre-filter. */
rows: Row[];
sources: SourceChoice[];
onToggleSource: (id: string) => void;
/** No device has ever uploaded — the hint that teaches the feature exists. */
devicesEmpty?: boolean;
/** Bundle housekeeping (download raw / delete), tucked under the viewer. */
manage?: ReactNode;
follow: boolean;
onFollow: (follow: boolean) => void;
onClear: () => void;
onDownload: (shown: LogEntry[]) => void;
onShare: (shown: LogEntry[]) => void;
onDownload: (shown: Row[]) => void;
onShare: (shown: Row[]) => void;
shareMode: ShareMode | null;
dropped: boolean;
/** The poll's failure, if any — without it a broken /logs is indistinguishable from a quiet host. */
@@ -187,7 +91,11 @@ export const LogsCard: FC<{
isLoading?: boolean;
onRetry?: () => void;
}> = ({
entries,
rows,
sources,
onToggleSource,
devicesEmpty,
manage,
follow,
onFollow,
onClear,
@@ -200,32 +108,47 @@ export const LogsCard: FC<{
onRetry,
}) => {
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
const [source, setSource] = useState<Source>("all");
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => new Set(sources.filter((s) => s.selected).map((s) => s.id)),
[sources],
);
const matched = useMemo(() => {
const min = RANK[minLevel] ?? 0;
const q = search.trim().toLowerCase();
return entries.filter(
(e) =>
(RANK[e.level] ?? 0) >= min &&
matchesSource(e.target, source) &&
return rows.filter(
(r) =>
selected.has(r.source) &&
// An unparsed row has no level to rank. Ranking it as 0 would hide it behind any
// filter above DEBUG, which is the one outcome a fail-soft parser must not produce —
// so it is always in range and only the text search can exclude it.
(r.level === "" || (RANK[r.level] ?? 0) >= min) &&
(q === "" ||
e.msg.toLowerCase().includes(q) ||
e.target.toLowerCase().includes(q)),
r.msg.toLowerCase().includes(q) ||
r.target.toLowerCase().includes(q) ||
(r.device?.toLowerCase().includes(q) ?? false)),
);
}, [entries, minLevel, source, search]);
}, [rows, selected, minLevel, search]);
const visible = useMemo(() => matched.slice(-SHOW), [matched]);
const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy();
const nothingSelected = selected.size === 0;
// "No plugin output" has a specific, actionable cause that the generic "adjust the filter" line
// actively misdirects from: the runner is a separate service and is opt-in on Linux, so the
// usual reason for an empty Plugins view is that it simply isn't running. Only worth saying
// when plugins are the ONLY thing being looked at — otherwise the emptiness is not about them.
const onlyPlugins = selected.size === 1 && selected.has(PLUGINS_SOURCE);
// Keep the tail in view while following.
//
// Keyed on the newest RENDERED seq, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
// Keyed on the newest RENDERED key, not on `visible.length`: `visible` is `matched.slice(-SHOW)`,
// so once the filter matches SHOW rows its length is pinned at SHOW forever. The effect then
// stopped re-running and follow-mode quietly stopped following — exactly when the log is busy
// enough to need it. The newest seq keeps changing for as long as lines arrive.
const newestVisible = visible.at(-1)?.seq ?? -1;
// enough to need it. The newest key keeps changing for as long as lines arrive.
const newestVisible = visible.at(-1)?.key ?? "";
// NOTE: biome flags `newestVisible` as an unnecessary dependency (it is not read in the body) and
// offers to remove it. Do NOT take that fix — it is a TRIGGER, the signal that new lines arrived.
// Removing it reinstates the bug this replaced: the effect stops re-running and follow-mode
@@ -245,6 +168,43 @@ export const LogsCard: FC<{
{/* The page heading says "Troubleshooting" now, so this card names itself — otherwise
the log stream is the only section on the page with no label. */}
<h2 className="text-lg font-medium">{m.logs_title()}</h2>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{m.logs_sources_label()}
</span>
{sources.map((s) => (
<Button
key={s.id}
size="sm"
variant={s.selected ? "secondary" : "outline"}
aria-pressed={s.selected}
disabled={s.state === "loading"}
onClick={() => onToggleSource(s.id)}
>
{s.state === "loading" && <Spinner className="mr-1 size-3.5" />}
{s.state === "error" && (
<AlertCircle className="mr-1 size-3.5 text-destructive" />
)}
{s.label}
{s.hint && (
<span className="ml-1.5 text-xs text-muted-foreground">
{s.hint}
</span>
)}
</Button>
))}
{/* The bundle list used to vanish entirely when empty, which meant the one place
that could teach "your devices can send their logs here" showed nothing to
anyone who had never already used it. One line is not the noise a whole empty
card was. */}
{devicesEmpty && (
<span className="text-xs text-muted-foreground">
{m.logs_devices_empty()}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
@@ -258,18 +218,6 @@ export const LogsCard: FC<{
</Button>
))}
</div>
<div className="flex items-center gap-1 border-l pl-2">
{SOURCES.map((s) => (
<Button
key={s}
size="sm"
variant={source === s ? "secondary" : "ghost"}
onClick={() => setSource(s)}
>
{SOURCE_LABEL[s]()}
</Button>
))}
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -326,7 +274,7 @@ export const LogsCard: FC<{
{/* A failing poll while lines are already on screen keeps them there — during a host
restart the last lines before it went away are the interesting ones — but says so,
instead of letting a frozen view read as a quiet host. */}
{error != null && entries.length > 0 && (
{error != null && rows.length > 0 && (
<p
role="status"
className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive"
@@ -340,8 +288,9 @@ export const LogsCard: FC<{
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{visible.length === 0 ? (
// An empty list has three quite different causes and used to render one sentence
// for all of them: the host is quiet, the request failed, or it hasn't answered yet.
// An empty list has four quite different causes and used to render one sentence
// for all of them: the host is quiet, the request failed, it hasn't answered yet,
// or every source is switched off.
<div className="p-2">
{error ? (
<div className="space-y-2 font-sans">
@@ -356,36 +305,46 @@ export const LogsCard: FC<{
<p className="text-muted-foreground">
{isLoading
? m.common_loading()
: // "No plugin output" has a specific, actionable cause that the generic
// "adjust the filter" line actively misdirects from: the runner is a
// separate service and is opt-in on Linux, so the usual reason for an
// empty Plugins view is that it simply isn't running.
source === "plugins"
? m.logs_empty_plugins()
: m.logs_empty()}
: nothingSelected
? m.logs_sources_none()
: onlyPlugins
? m.logs_empty_plugins()
: m.logs_empty()}
</p>
)}
</div>
) : (
visible.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
<span className="text-muted-foreground">
{fmtTime(e.ts_ms)}{" "}
</span>
visible.map((r) => (
<div key={r.key} className="whitespace-pre-wrap break-words">
<span className="text-muted-foreground">{fmtTime(r.ts)} </span>
<span
className={cn(
"font-medium",
LEVEL_CLASS[e.level] ?? "text-muted-foreground",
LEVEL_CLASS[r.level] ?? "text-muted-foreground",
)}
>
{e.level.padEnd(5)}{" "}
{r.level.padEnd(5)}{" "}
</span>
<span className="text-muted-foreground">{e.target} </span>
<span>{e.msg}</span>
{/* Only device rows are tagged. Absence of a tag reads as "this host",
and stamping every host line would double the noise in the common
case where no bundle is loaded at all. */}
{/* Theme-aware, unlike LEVEL_CLASS above: one fixed mid shade is legible on
exactly one of the two palettes, and this tag is the thing that has
to stay readable for a merged view to be worth having. Checked in
both themes, not inferred. */}
{r.device && (
<span className="text-violet-600 dark:text-violet-400">
[{r.device}]{" "}
</span>
)}
<span className="text-muted-foreground">{r.target} </span>
<span>{r.msg}</span>
</div>
))
)}
</div>
{manage}
</CardContent>
</Card>
);
+96 -5
View File
@@ -1,4 +1,7 @@
import type { LogEntry } from "@/api/gen/model/logEntry";
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { checkTitle, statusLabel, worstFirst } from "@/lib/diagnostics";
import type { Row } from "./rows";
/**
* How the log entries can leave the page. Probed at runtime rather than assumed: `share` is Web
@@ -13,18 +16,106 @@ export type ShareOutcome = "shared" | "copied" | "cancelled" | "failed";
const MIME = "text/plain";
/**
* One line per entry, in the on-screen column order but with the full date and UTC offset — a bare
* One line per row, in the on-screen column order but with the full date and UTC offset — a bare
* wall-clock time is ambiguous the moment the file leaves the browser, and bug reports span days.
* A device row keeps its origin tag: in a merged export, "which machine said this" is the first
* thing a reader needs and the last thing they can reconstruct.
*/
export const logsToText = (entries: LogEntry[]): string =>
entries
.map((e) => `${stamp(e.ts_ms)} ${e.level.padEnd(5)} ${e.target} ${e.msg}`)
export const logsToText = (rows: Row[]): string =>
rows
.map(
(r) =>
`${stamp(r.ts)} ${r.level.padEnd(5)} ${r.device ? `[${r.device}] ` : ""}${r.target} ${r.msg}`,
)
.join("\n");
/** `punktfunk-logs-20260730-142231.log` — sorts chronologically in a downloads folder. */
export const logFilename = (now: Date): string =>
`punktfunk-logs-${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.log`;
/** `punktfunk-diagnostics-20260730-142231.txt` — the combined export's sibling name. */
export const diagnosticsFilename = (now: Date): string =>
`punktfunk-diagnostics-${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}.txt`;
const banner = (title: string): string =>
`\n${"=".repeat(72)}\n== ${title}\n${"=".repeat(72)}\n`;
/**
* Everything this page knows, as one file: the host's health checks, the host + plugin log, and
* every uploaded device bundle verbatim.
*
* **Plain text, assembled in the browser** — two deliberate calls.
*
* Text rather than an archive because the artifact's job is to be pasted into a bug report or
* scrolled by the person who asked for it; a zip makes both a two-step operation and buys
* compression nobody was short of.
*
* In the browser rather than from a host endpoint because the console holds *more* host log than
* the host does: the ring is 4096 entries (`log_capture.rs`), while an open console accumulates up
* to 5000 and keeps lines the ring has already evicted. A host-side export would quietly ship less
* history than the page it was launched from — and it would cost a new authenticated route, a
* regenerated OpenAPI document in both of its checked-in copies, and a row in the mgmt lane matrix.
*
* Device bundles go in verbatim, not re-rendered from parsed rows: a leg whose format this build
* cannot parse must still export intact, and the raw bytes are the only version guaranteed to.
*/
export const diagnosticsText = (parts: {
generatedAt: Date;
checks: HostCheck[];
checksUnavailable?: boolean;
rows: Row[];
bundles: { meta: ClientLogMeta; text: string }[];
}): string => {
const { generatedAt, checks, checksUnavailable, rows, bundles } = parts;
const out: string[] = [
`punktfunk diagnostics export`,
`generated: ${stamp(generatedAt.getTime())}`,
`host log lines: ${rows.length}`,
`device bundles: ${bundles.length}`,
];
out.push(banner("HEALTH CHECKS"));
if (checksUnavailable) {
out.push(
"This host has no diagnostics route (it predates the checks API).",
);
} else if (checks.length === 0) {
out.push("No checks reported.");
} else {
for (const c of worstFirst(checks)) {
out.push(`[${statusLabel(c)}] ${checkTitle(c)} (${c.id})`);
if (c.summary) out.push(` summary: ${c.summary}`);
if (c.impact) out.push(` impact: ${c.impact}`);
if (c.remedy?.text) out.push(` remedy: ${c.remedy.text}`);
if (c.remedy?.command) out.push(` command: ${c.remedy.command}`);
}
}
out.push(banner("HOST AND PLUGIN LOG"));
out.push(rows.length ? logsToText(rows) : "No entries.");
for (const { meta, text } of bundles) {
out.push(
banner(
`DEVICE BUNDLE — ${meta.device_name} (${meta.fingerprint_prefix}), received ${stamp(meta.received_ms)}`,
),
);
out.push(text.trimEnd());
}
// A device's clock is its own, and a bundle that looks minutes off from the host log is a
// property of the machines, not of this file. Saying so at the bottom costs one line and saves
// the reader from "correcting" a correlation that was never wrong.
if (bundles.length > 0) {
out.push(
banner("NOTE"),
"Device timestamps come from each device's own clock and may differ from the host's.",
);
}
return `${out.join("\n")}\n`;
};
export const downloadText = (text: string, filename: string): void => {
const url = URL.createObjectURL(new Blob([text], { type: MIME }));
const a = document.createElement("a");
+189 -10
View File
@@ -1,24 +1,203 @@
import type { FC } from "react";
import { toast } from "@unom/ui/toast";
import { Download } from "lucide-react";
import { type FC, useEffect, useMemo, useState } from "react";
import { ApiError } from "@/api/fetcher";
import { useGetDiagnostics } from "@/api/gen/diagnostics/diagnostics";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { ChecksSection } from "./ChecksCard";
import { ClientLogsSection } from "./ClientLogsCard";
import { LogsSection } from "./LogsCard";
import {
detectShareMode,
diagnosticsFilename,
diagnosticsText,
downloadText,
logFilename,
logsToText,
type ShareMode,
shareLogs,
} from "./export";
import { LogsCard, type SourceChoice } from "./LogsCard";
import {
deviceSource,
HOST_SOURCE,
hostRows,
mergeRows,
PLUGINS_SOURCE,
} from "./rows";
import { useDeviceLogs, useHostLog } from "./useLogSources";
import { LogsView } from "./view";
// Troubleshooting = the host's health checks over one self-contained viewer card owning its
// polling; this container only binds the layout. Client-uploaded bundles ("Send logs to host")
// render beneath the live host log — same page a reporter already exports the host log from, so
// both halves of a report live in one place.
/** `12:04` — a chip has room for when a bundle landed, not for the date it landed on. */
const fmtClock = (ms: number): string => {
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}`;
};
/**
* Troubleshooting: the host's health checks over one viewer that holds every log this host can
* reach — its own, the plugin runner's, and whatever paired devices have uploaded.
*
* The page owns the log state rather than the viewer card, because two consumers read it: the
* viewer, and the "export everything" action in the heading.
*/
export const SectionLogs: FC = () => {
useLocale();
const host = useHostLog();
const devices = useDeviceLogs();
// Host and plugins on by default — the page's previous "All", and still the right opening
// state: a device bundle is a deliberate act of correlation, not something to be opted out of.
const [selected, setSelected] = useState<Set<string>>(
() => new Set([HOST_SOURCE, PLUGINS_SOURCE]),
);
const [shareMode, setShareMode] = useState<ShareMode | null>(null);
const [exporting, setExporting] = useState(false);
// Probed after mount: the server render has no `navigator`, and guessing there would mismatch
// on hydration. Until then the share button is simply absent.
useEffect(() => {
setShareMode(detectShareMode());
}, []);
// The checks are already on this page; the export reads the same cached entry rather than
// asking the host to run every probe a second time.
const diagnostics = useGetDiagnostics({
query: { staleTime: 5 * 60_000, retry: false },
});
const checksUnsupported =
diagnostics.error instanceof ApiError && diagnostics.error.status === 404;
const fromHost = useMemo(() => hostRows(host.entries), [host.entries]);
// Only SELECTED device bundles reach the merge. An export loads every bundle as a side effect,
// and without this the pool would silently grow by a few thousand rows per device that nobody
// asked to see — sorted on every poll, for nothing.
const rows = useMemo(
() =>
mergeRows([
fromHost,
...devices.bundles
.filter((b) => selected.has(deviceSource(b.meta.id)))
.map((b) => b.rows),
]),
[fromHost, devices.bundles, selected],
);
const sources = useMemo<SourceChoice[]>(
() => [
{
id: HOST_SOURCE,
label: m.logs_source_host(),
selected: selected.has(HOST_SOURCE),
},
{
id: PLUGINS_SOURCE,
label: m.logs_source_plugins(),
selected: selected.has(PLUGINS_SOURCE),
},
...devices.bundles.map((b) => ({
id: deviceSource(b.meta.id),
label: b.meta.device_name,
hint: fmtClock(b.meta.received_ms),
selected: selected.has(deviceSource(b.meta.id)),
state:
b.state === "loading"
? ("loading" as const)
: b.state === "error"
? ("error" as const)
: undefined,
})),
],
[devices.bundles, selected],
);
const onToggleSource = (id: string) => {
const turningOn = !selected.has(id);
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Switching a device on is what asks for its bundle — including after a failure, so a
// second click is a retry rather than a no-op.
if (!turningOn) return;
const bundle = devices.bundles.find((b) => deviceSource(b.meta.id) === id);
if (bundle && bundle.state !== "loaded" && bundle.state !== "loading") {
void devices.load(bundle.meta);
}
};
const onExportAll = async () => {
setExporting(true);
try {
const bundles = await devices.loadAll();
downloadText(
diagnosticsText({
generatedAt: new Date(),
checks: diagnostics.data?.checks ?? [],
checksUnavailable: checksUnsupported,
// The full host buffer, not `rows` — the export is "everything", and what the
// viewer is filtered to has no bearing on that.
rows: fromHost,
bundles,
}),
diagnosticsFilename(new Date()),
);
} catch {
toast.error(m.logs_export_all_failed());
} finally {
setExporting(false);
}
};
const devicesEmpty =
!devices.list.isLoading &&
!devices.list.error &&
devices.bundles.length === 0;
return (
<LogsView
checks={<ChecksSection />}
actions={
<Button variant="outline" onClick={onExportAll} disabled={exporting}>
<Download className="size-4" />
{exporting ? m.logs_export_all_working() : m.logs_export_all()}
</Button>
}
viewer={
<>
<LogsSection />
<ClientLogsSection />
</>
<LogsCard
rows={rows}
sources={sources}
onToggleSource={onToggleSource}
devicesEmpty={devicesEmpty}
manage={
<ClientLogsSection list={devices.list} onDeleted={devices.forget} />
}
follow={host.follow}
onFollow={host.setFollow}
onClear={host.clear}
onDownload={(shown) =>
downloadText(logsToText(shown), logFilename(new Date()))
}
onShare={async (shown) => {
const outcome = await shareLogs(
logsToText(shown),
logFilename(new Date()),
);
if (outcome === "copied") toast.success(m.logs_copied());
else if (outcome === "failed") toast.error(m.logs_share_failed());
}}
shareMode={shareMode}
dropped={host.dropped}
error={host.error}
isLoading={host.isLoading}
onRetry={() => host.refetch()}
/>
}
/>
);
+146
View File
@@ -0,0 +1,146 @@
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import type { LogEntry } from "@/api/gen/model/logEntry";
/**
* One row model for every producer, so the viewer has a single timeline instead of a stream plus a
* table of attachments.
*
* The insight this rests on: a client bundle is not a foreign artifact. `clients/session`'s
* `ring_layer` formats each line as `<ISO8601-Z> <LEVEL> <target> <msg>` — the same four fields as
* a host `LogEntry`, only serialized as text instead of JSON — and it does so in **wall clock**
* precisely "so a bundle correlates with the host log it lands next to". Parsing it back into rows
* is therefore recovering structure the client already had, not inventing it, and it buys the view
* the whole feature exists for: the client's stall and the host's account of the same second, on
* one screen.
*/
/** Source ids. Devices get one each, so a chip can address a single bundle. */
export const HOST_SOURCE = "host";
export const PLUGINS_SOURCE = "plugins";
export const deviceSource = (bundleId: string): string => `device:${bundleId}`;
/** The target prefix the host stamps on every runner-shipped line. */
const PLUGIN_TARGET_PREFIX = "plugin:";
export interface Row {
/**
* React key. `seq` is unique within the host ring but says nothing about a bundle's lines, so
* the moment one is loaded two rows collide and React silently drops one. Always compose the
* source into the key.
*/
key: string;
/**
* Wall-clock ms. Host rows carry the host's clock, device rows the device's — the two can be
* minutes apart, which is exactly why every device row is tagged with where it came from.
*/
ts: number;
level: string;
target: string;
msg: string;
source: string;
/**
* The device a row came from, rendered as the origin tag. Host and plugin rows carry none:
* absence reads as "this host", and tagging every host line would double the noise in the
* common case where no bundle is loaded at all.
*/
device?: string;
}
/** Host ring entries, split onto the two producers the ring interleaves. */
export const hostRows = (entries: LogEntry[]): Row[] =>
entries.map((e) => {
const source = e.target.startsWith(PLUGIN_TARGET_PREFIX)
? PLUGINS_SOURCE
: HOST_SOURCE;
return {
key: `${source}:${e.seq}`,
ts: e.ts_ms,
level: e.level,
target: e.target,
msg: e.msg,
source,
};
});
/**
* `2026-08-15T12:03:47.123Z INFO punktfunk_session::stream frame late by 34ms`
*
* The level is written through `{:5}`, so it arrives padded and the gap before the target is one
* or two spaces — matched loosely rather than pinned, since the padding is a formatting detail of
* the client and not a wire contract.
*/
const BUNDLE_LINE =
/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z)\s+([A-Z]{4,5})\s+(\S+)\s*([\s\S]*)$/;
/**
* A bundle's text as rows.
*
* **Fails soft, by design.** Only the desktop session shell installs the ring layer today; the
* Apple, Android and webOS legs are still open and will not emit this shape when they land. A line
* that does not parse is therefore kept verbatim as its own row rather than dropped — an
* unrecognized format degrades to "a log you can still read and search", never to a blank pane.
* The same path carries the bundle's header line and its `… N older lines evicted …` note, which
* are prose and were never meant to parse.
*/
export const bundleRows = (text: string, meta: ClientLogMeta): Row[] => {
const source = deviceSource(meta.id);
const rows: Row[] = [];
// Unparsed lines inherit the timestamp of the line above so they sort beside it. Those that
// arrive BEFORE any timestamped line (the bundle header) have nothing to inherit yet and are
// backfilled from the first real one below — otherwise the header sorts to 1970 and the merged
// view opens on it.
const leading: Row[] = [];
let lastTs: number | null = null;
text.split("\n").forEach((line, i) => {
if (line.trim() === "") return;
const key = `${source}:${i}`;
const [, stamp, level, target, msg] = BUNDLE_LINE.exec(line) ?? [];
const ts = stamp === undefined ? Number.NaN : Date.parse(stamp);
if (level !== undefined && target !== undefined && !Number.isNaN(ts)) {
lastTs = ts;
rows.push({
key,
ts,
level,
target,
msg: msg ?? "",
source,
device: meta.device_name,
});
return;
}
const row: Row = {
key,
ts: lastTs ?? 0,
level: "",
target: "",
msg: line,
source,
device: meta.device_name,
};
rows.push(row);
if (lastTs === null) leading.push(row);
});
// `received_ms` is the fallback for a bundle that parsed nothing at all: it is the one
// timestamp we always have, and it puts such a bundle at the point in the timeline where it
// actually arrived rather than at the epoch.
const firstTs = rows.find((r) => r.ts > 0)?.ts ?? meta.received_ms;
for (const row of leading) row.ts = firstTs;
return rows;
};
/**
* Merge pre-sorted groups onto one timeline.
*
* `sort` is stable (spec-required since ES2019), so rows sharing a millisecond keep their own
* source's order instead of shuffling between polls.
*/
export const mergeRows = (groups: Row[][]): Row[] =>
groups.length === 1
? (groups[0] ?? [])
: groups.flat().sort((a, b) => a.ts - b.ts);
+196
View File
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
clientLogsGet,
useClientLogsList,
useLogsGet,
} from "@/api/gen/logs/logs";
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import type { LogEntry } from "@/api/gen/model/logEntry";
import type { Loadable } from "@/lib/query";
import { bundleRows, type Row } from "./rows";
const KEEP = 5_000; // accumulated entries (client memory bound)
/**
* The host log poll, lifted out of the viewer card.
*
* It lives at page level because two things now read it: the viewer, and the page's "export
* everything" action. A second copy of this hook would mean a second cursor and a second poll
* racing the first over the same ring, so there is exactly one and the page passes it down.
*
* Cursor-paged: 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 useHostLog = () => {
const [cursor, setCursor] = useState(0);
const [entries, setEntries] = useState<LogEntry[]>([]);
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
// Set while a poll has failed and we have not yet re-read the ring from the start.
const [resync, setResync] = useState(false);
const query = useLogsGet(
{ after: cursor > 0 ? cursor : undefined },
{
query: {
refetchInterval: follow ? 2_000 : false,
// Pausing must actually pause. Stopping only the interval left React Query's default
// focus/reconnect refetches landing, and the append effect consumed them
// unconditionally — so tabbing away and back evicted the lines the operator had
// paused on, from behind the pause button.
refetchOnWindowFocus: follow,
refetchOnReconnect: follow,
},
},
);
// Resync after the host goes away and comes back.
//
// The host's log ring restarts at seq 1 on every restart, while our cursor stays wherever it
// got to. `GET /logs?after=8000` against a fresh ring is not an error — it is a permanently
// EMPTY page (`next` echoes `after`), so the page would poll forever showing stale lines with
// no error, no dropped badge and no way back short of a full reload. The console's own update
// flow restarts the host, so this was reachable from two clicks away.
//
// A restart always breaks the poll first, so a failed query is the trigger: on the next success
// we re-read from the start of the ring once and let the effect below decide whether the
// sequence actually regressed.
const failed = query.isError;
useEffect(() => {
if (failed) setResync(true);
}, [failed]);
useEffect(() => {
if (resync && cursor !== 0) setCursor(0);
}, [resync, cursor]);
const data = query.data;
useEffect(() => {
if (!data || data.entries.length === 0) return;
setEntries((prev) => {
const lastSeq = prev.at(-1)?.seq ?? -1;
// A page whose newest entry is OLDER than what we already hold can only mean the host's
// sequence restarted underneath us — the buffer describes a host that no longer exists,
// so replace it wholesale rather than filtering every new line away as "already seen".
const newest = data.entries.at(-1)?.seq ?? -1;
if (newest < lastSeq) return data.entries.slice(-KEEP);
// Otherwise append only what's newer — 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), and makes the post-resync
// re-read from 0 a no-op when the host did NOT restart.
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);
setResync(false);
}, [data]);
const clear = useCallback(() => {
setEntries([]);
setDropped(false);
}, []);
return {
entries,
follow,
setFollow,
dropped,
clear,
error: query.error,
isLoading: query.isLoading,
refetch: query.refetch,
};
};
/** A bundle plus whatever this page has managed to fetch of it. */
export interface DeviceBundle {
meta: ClientLogMeta;
state: "idle" | "loading" | "loaded" | "error";
rows: Row[];
/** The bundle verbatim — what the combined export embeds and what a raw view would show. */
text?: string;
}
/**
* The uploaded client bundles: the list, and the text of the ones the operator has opened.
*
* Bundles load **on demand**. Each is up to 1 MiB of a device's newest ~4096 lines, and pulling
* every one on every visit to the troubleshooting page would cost far more than it earns — most
* visits are about the host. Clicking a device's chip is the request to merge it in.
*/
export const useDeviceLogs = () => {
const list = useClientLogsList();
const [fetched, setFetched] = useState<
Record<string, { state: DeviceBundle["state"]; rows: Row[]; text?: string }>
>({});
const metas = useMemo(() => list.data ?? [], [list.data]);
const load = useCallback(async (meta: ClientLogMeta) => {
setFetched((prev) =>
prev[meta.id]?.state === "loaded"
? prev
: { ...prev, [meta.id]: { state: "loading", rows: [] } },
);
try {
const text = await clientLogsGet(meta.id);
setFetched((prev) => ({
...prev,
[meta.id]: { state: "loaded", rows: bundleRows(text, meta), text },
}));
return text;
} catch {
setFetched((prev) => ({
...prev,
[meta.id]: { state: "error", rows: [] },
}));
return null;
}
}, []);
/**
* Every bundle's text, fetching whatever is not in hand yet — what the combined export needs.
* Sequential rather than parallel: bundles are capped at 1 MiB each and this runs behind an
* explicit click, so being polite to the host beats shaving a second off a rare action.
*/
const loadAll = useCallback(async (): Promise<
{ meta: ClientLogMeta; text: string }[]
> => {
const out: { meta: ClientLogMeta; text: string }[] = [];
for (const meta of metas) {
const text = await load(meta);
if (text !== null) out.push({ meta, text });
}
return out;
}, [metas, load]);
const bundles = useMemo<DeviceBundle[]>(
() =>
metas.map((meta) => ({
meta,
state: fetched[meta.id]?.state ?? "idle",
rows: fetched[meta.id]?.rows ?? [],
text: fetched[meta.id]?.text,
})),
[metas, fetched],
);
// A deleted bundle must not keep its rows in the viewer; dropping the fetched copy is enough,
// since `bundles` is derived from the server list.
const forget = useCallback((id: string) => {
setFetched((prev) => {
if (!(id in prev)) return prev;
const { [id]: _gone, ...rest } = prev;
return rest;
});
}, []);
return {
bundles,
load,
loadAll,
forget,
list: list as Loadable<ClientLogMeta[]>,
};
};
+20 -9
View File
@@ -13,18 +13,29 @@ import { m } from "@/paraglide/messages";
* Order is deliberate: checks first (structured, actionable), the log stream underneath. When the
* checks are green and something is still broken, the log is the natural next step — now one scroll
* away instead of a separate destination.
*
* `actions` sits in the heading rather than in the viewer's toolbar because what lives there is a
* PAGE-level export — every check, every producer, every stored bundle, regardless of what the
* viewer is currently filtered to. The toolbar's own download means "what I am looking at"; keeping
* the two apart in space is what keeps them apart in meaning.
*/
export const LogsView: FC<{ checks?: ReactNode; viewer: ReactNode }> = ({
checks,
viewer,
}) => (
export const LogsView: FC<{
checks?: ReactNode;
actions?: ReactNode;
viewer: ReactNode;
}> = ({ checks, actions, viewer }) => (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.troubleshooting_title()}</h1>
<p className="text-sm text-muted-foreground">
{m.troubleshooting_subtitle()}
</p>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">
{m.troubleshooting_title()}
</h1>
<p className="text-sm text-muted-foreground">
{m.troubleshooting_subtitle()}
</p>
</div>
{actions}
</div>
{checks}
+128 -2
View File
@@ -1,6 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Download } from "lucide-react";
import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta";
import type { LogEntry } from "@/api/gen/model/logEntry";
import { Button } from "@/components/ui/button";
import { LogsCard } from "@/sections/Logs/LogsCard";
import {
bundleRows,
deviceSource,
HOST_SOURCE,
hostRows,
mergeRows,
PLUGINS_SOURCE,
} from "@/sections/Logs/rows";
import { LogsView } from "@/sections/Logs/view";
const noop = () => {};
@@ -82,6 +93,42 @@ const fixtureEntries: LogEntry[] = [
),
];
const DECK: ClientLogMeta = {
id: "1750000000_ab12cd34ef567890_couch-deck",
device_name: "couch-deck",
fingerprint_prefix: "ab12cd34ef567890",
received_ms: BASE + 12_000,
size_bytes: 384_512,
};
/**
* A bundle exactly as `clients/session`'s ring layer writes it: a header line that does NOT parse,
* then `<ISO8601-Z> <LEVEL> <target> <msg>`. The unparsed header is in the fixture on purpose — it
* is the cheapest standing proof that the fail-soft path renders rather than swallows, which is
* what the Apple/Android/webOS legs will depend on when they land with formats of their own.
*
* Its timestamps interleave with the host's rather than sitting after them, because interleaving is
* the entire reason the two are in one pane.
*/
const iso = (seq: number) => new Date(BASE + seq * 750).toISOString();
const deckBundle = [
"punktfunk-session 0.4.2 (linux x86_64)",
`${iso(4)} INFO punktfunk_session::stream connected host=skynet mode=1920x1080@60`,
`${iso(6)} WARN punktfunk_session::audio egress late=31% — link stalled`,
`${iso(7)} ERROR punktfunk_session::pad no rumble device for DualSense (permission denied)`,
`${iso(9)} INFO punktfunk_session::stream decode queue drained`,
].join("\n");
const hostOnly = hostRows(fixtureEntries);
const merged = mergeRows([hostOnly, bundleRows(deckBundle, DECK)]);
const chip = (id: string, label: string, selected: boolean, hint?: string) => ({
id,
label,
selected,
hint,
});
const meta = {
title: "Pages/Logs",
component: LogsView,
@@ -96,9 +143,23 @@ type Story = StoryObj<typeof meta>;
// desktop (clipboard) and mobile (share sheet) affordance stay covered by the screenshot run.
export const Following: Story = {
args: {
// The page-level export lives in the heading, deliberately away from the toolbar's own
// download ("what I am looking at"). Pinned in a story so the two cannot drift back together.
actions: (
<Button variant="outline">
<Download className="size-4" />
Export all
</Button>
),
viewer: (
<LogsCard
entries={fixtureEntries}
rows={hostOnly}
sources={[
chip(HOST_SOURCE, "Host", true),
chip(PLUGINS_SOURCE, "Plugins", true),
]}
onToggleSource={noop}
devicesEmpty
follow
onFollow={noop}
onClear={noop}
@@ -115,7 +176,13 @@ export const PausedWithGap: Story = {
args: {
viewer: (
<LogsCard
entries={fixtureEntries}
rows={hostOnly}
sources={[
chip(HOST_SOURCE, "Host", true),
chip(PLUGINS_SOURCE, "Plugins", true),
]}
onToggleSource={noop}
devicesEmpty
follow={false}
onFollow={noop}
onClear={noop}
@@ -127,3 +194,62 @@ export const PausedWithGap: Story = {
),
},
};
/**
* The view the multi-select exists for: the host and one device on one timeline, device lines
* carrying their origin tag. This is the story to check when touching the merge, the tag, or the
* chips — a regression here is invisible in the host-only stories above.
*/
export const HostAndDeviceMerged: Story = {
args: {
viewer: (
<LogsCard
rows={merged}
sources={[
chip(HOST_SOURCE, "Host", true),
chip(PLUGINS_SOURCE, "Plugins", true),
chip(deviceSource(DECK.id), DECK.device_name, true, "12:04"),
]}
onToggleSource={noop}
follow={false}
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="copy"
dropped={false}
/>
),
},
};
/** A device chip mid-fetch, and one whose bundle failed — both states live on the chip itself. */
export const DeviceChipStates: Story = {
args: {
viewer: (
<LogsCard
rows={hostOnly}
sources={[
chip(HOST_SOURCE, "Host", true),
chip(PLUGINS_SOURCE, "Plugins", false),
{
...chip(deviceSource(DECK.id), DECK.device_name, false, "12:04"),
state: "loading" as const,
},
{
...chip(deviceSource("other"), "living-room-tv", false, "09:41"),
state: "error" as const,
},
]}
onToggleSource={noop}
follow={false}
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="copy"
dropped={false}
/>
),
},
};