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. 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: ( + + ), + }, +};