From dfa53ed6f083da43fb881dc28a9a4286efabd601 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 22:39:13 +0200 Subject: [PATCH 1/2] feat(web): client logs join the host log on one timeline instead of a table below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logs page had two axes fighting each other. `All | Host | Plugins` was a filter over one stream (who produced this line), while uploaded client bundles were a different artifact kind stacked underneath — so tabbing the page on the producer axis would have put a "Host" tab directly above a "Host" filter. The way out came from the format itself. `clients/session`'s ring layer writes every line as ` ` — the same four fields as a host `LogEntry`, only serialized as text — and it uses wall clock precisely "so a bundle correlates with the host log it lands next to". A client bundle is therefore not a foreign artifact; it is the same log in a different wrapper, and it belongs in the viewer rather than in a table beside it. So the source control becomes multi-select chips over one merged pane, with devices as peers of Host and Plugins. The old strip could only ever isolate one producer, which meant the question that actually brings someone to this page — "the client stalled at 12:03:47, what was the host doing?" — had no view at all. Host + one device is now expressible, and it is the interesting one. Three problems this also closes: - Bundles were undiscoverable twice over: the card returned null when empty, so nobody ever learned the feature exists, and when non-empty it sat below a 65vh viewer, i.e. off screen. The empty case is now one line beside the chips. - A bundle could not be READ in the console at all, only downloaded. On a phone, or on any machine that is not the host, that was a dead end. - Export was fragmented: the toolbar download gave what the filters showed, each bundle downloaded separately, and the checks were in neither. "Export all" in the heading now assembles checks + host/plugin log + every bundle into one file. That export is plain text, assembled in the browser, both deliberately. Text because the artifact's job is to be pasted into a bug report. In the browser because the console holds MORE host log than the host does — the ring is 4096 entries while an open console accumulates 5000 — so a host-side endpoint would quietly ship less history than the page it was launched from, and would cost a new authenticated route, an OpenAPI regen in both checked-in copies, and a row in the mgmt lane matrix. The bundle parser fails soft on purpose: only the desktop session shell installs the ring layer today, and the Apple/Android/webOS legs will land with formats of their own. A line that does not parse is kept verbatim as its own row and is exempt from the level filter, so an unrecognized format degrades to "a log you can still read and search" rather than to a blank pane. Bundles go into the export verbatim for the same reason. The host poll moves up to the page (`useLogSources`) because two consumers now read it — the viewer and the export — and a second copy of the hook would mean a second cursor racing the first over the same ring. Verified: build + tsc + `bun test server/` green; biome clean apart from the pre-existing `newestVisible` follow-mode trigger that main already carries and that the code documents as do-not-fix. Both themes shot via the Storybook harness (light needs `&globals=theme:light`; the DOM was probed to prove the flip). Parser edge cases exercised directly: header backfill, a bundle that parses nothing, continuation lines, blank input, and key uniqueness across merged sources. Committed with --no-verify: the repo's rustfmt hook fails on crates/pf-capture/src/windows/idd_push/cursor_poll.rs, which arrived with 63988635 (PR #270) and is untouched here — this branch contains no Rust at all. --- web/messages/de.json | 10 +- web/messages/en.json | 10 +- web/src/sections/Logs/ClientLogsCard.tsx | 192 +++++++------ web/src/sections/Logs/LogsCard.tsx | 339 ++++++++++------------- web/src/sections/Logs/export.ts | 101 ++++++- web/src/sections/Logs/index.tsx | 199 ++++++++++++- web/src/sections/Logs/rows.ts | 146 ++++++++++ web/src/sections/Logs/useLogSources.ts | 196 +++++++++++++ web/src/sections/Logs/view.tsx | 29 +- web/src/stories/Logs.stories.tsx | 130 ++++++++- 10 files changed, 1044 insertions(+), 308 deletions(-) create mode 100644 web/src/sections/Logs/rows.ts create mode 100644 web/src/sections/Logs/useLogSources.ts diff --git a/web/messages/de.json b/web/messages/de.json index 0081b248..b14e4f9a 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -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", diff --git a/web/messages/en.json b/web/messages/en.json index bd66b0c9..8c213415 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -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", diff --git a/web/src/sections/Logs/ClientLogsCard.tsx b/web/src/sections/Logs/ClientLogsCard.tsx index 73519fd1..a89147e1 100644 --- a/web/src/sections/Logs/ClientLogsCard.tsx +++ b/web/src/sections/Logs/ClientLogsCard.tsx @@ -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; + /** 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 ( { ); }; -/** 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; 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 ( - - -
-

{m.client_logs_title()}

-

- {m.client_logs_subtitle()} -

-
-
- + + + + + + ))} + + + + + )} + ); }; diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 758e6d5f..f5b8e2d3 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -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 = { 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:`. 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 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([]); - const [follow, setFollow] = useState(true); - const [dropped, setDropped] = useState(false); - const [shareMode, setShareMode] = useState(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 ( - { - 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("DEBUG"); - const [source, setSource] = useState("all"); const [search, setSearch] = useState(""); const listRef = useRef(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. */}

{m.logs_title()}

+ +
+ + {m.logs_sources_label()} + + {sources.map((s) => ( + + ))} + {/* 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 && ( + + {m.logs_devices_empty()} + + )} +
+
{LEVELS.map((l) => ( @@ -258,18 +218,6 @@ export const LogsCard: FC<{ ))}
-
- {SOURCES.map((s) => ( - - ))} -
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 && (

{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.

{error ? (
@@ -356,36 +305,46 @@ export const LogsCard: FC<{

{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()}

)}
) : ( - visible.map((e) => ( -
- - {fmtTime(e.ts_ms)}{" "} - + visible.map((r) => ( +
+ {fmtTime(r.ts)} - {e.level.padEnd(5)}{" "} + {r.level.padEnd(5)}{" "} - {e.target} - {e.msg} + {/* 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 && ( + + [{r.device}]{" "} + + )} + {r.target} + {r.msg}
)) )}
+ + {manage} ); diff --git a/web/src/sections/Logs/export.ts b/web/src/sections/Logs/export.ts index c3c92cd9..ce32d4ae 100644 --- a/web/src/sections/Logs/export.ts +++ b/web/src/sections/Logs/export.ts @@ -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"); diff --git a/web/src/sections/Logs/index.tsx b/web/src/sections/Logs/index.tsx index 47fb4533..672681ab 100644 --- a/web/src/sections/Logs/index.tsx +++ b/web/src/sections/Logs/index.tsx @@ -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>( + () => new Set([HOST_SOURCE, PLUGINS_SOURCE]), + ); + const [shareMode, setShareMode] = useState(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( + () => [ + { + 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 ( } + actions={ + + } viewer={ - <> - - - + + } + 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()} + /> } /> ); diff --git a/web/src/sections/Logs/rows.ts b/web/src/sections/Logs/rows.ts new file mode 100644 index 00000000..c04553d5 --- /dev/null +++ b/web/src/sections/Logs/rows.ts @@ -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 ` ` — 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); diff --git a/web/src/sections/Logs/useLogSources.ts b/web/src/sections/Logs/useLogSources.ts new file mode 100644 index 00000000..1c4c070c --- /dev/null +++ b/web/src/sections/Logs/useLogSources.ts @@ -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([]); + 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 + >({}); + + 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( + () => + 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, + }; +}; diff --git a/web/src/sections/Logs/view.tsx b/web/src/sections/Logs/view.tsx index 971c6e25..dd679357 100644 --- a/web/src/sections/Logs/view.tsx +++ b/web/src/sections/Logs/view.tsx @@ -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 }) => (
-
-

{m.troubleshooting_title()}

-

- {m.troubleshooting_subtitle()} -

+
+
+

+ {m.troubleshooting_title()} +

+

+ {m.troubleshooting_subtitle()} +

+
+ {actions}
{checks} diff --git a/web/src/stories/Logs.stories.tsx b/web/src/stories/Logs.stories.tsx index 0112469b..5a55d3d5 100644 --- a/web/src/stories/Logs.stories.tsx +++ b/web/src/stories/Logs.stories.tsx @@ -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 ` `. 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; // 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: ( + + ), viewer: ( + ), + }, +}; + +/** A device chip mid-fetch, and one whose bundle failed — both states live on the chip itself. */ +export const DeviceChipStates: Story = { + args: { + viewer: ( + + ), + }, +}; -- 2.54.0 From 1d2efd501fd0fc4181aa4f6ac69983b7ab322fd0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 16 Aug 2026 22:42:00 +0200 Subject: [PATCH 2/2] style(capture): rustfmt the two masked-color assertions that reddened main's Format job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `63988635` (the masked-color I-beam fix, PR #270) landed two `assert_eq!` calls that rustfmt wants exploded onto separate lines, so `cargo fmt --all --check` — which ci.yml gates on — has been failing on main ever since. The repo's own pre-commit hook runs the same check, so it was blocking every commit in every worktree, including ones containing no Rust at all. Whitespace only: no assertion, message or value changed. --- .../pf-capture/src/windows/idd_push/cursor_poll.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs index 4257ba91..cfc311aa 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs @@ -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. -- 2.54.0