From 8d6241efae8ab1289670368d6b500ec2f49b8ba8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 13:07:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20the=20logs=20page=20can=20hand=20a?= =?UTF-8?q?=20log=20off=20=E2=80=94=20as=20a=20file,=20or=20to=20the=20sha?= =?UTF-8?q?re=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logs page could only be read in place. Getting a host log into a bug report meant selecting a screenful of monospace text and hoping the scroll container gave up the rest. Two controls in the toolbar now do it properly. Download writes a .log file named for the moment it was taken; the second button hands the same text to the OS share sheet where there is one (phones, iPads), and copies it to the clipboard everywhere else — which is why it is probed at runtime rather than guessed, and why the button is absent on the one combination where neither exists (plain HTTP, no Web Share). Both export what the filters currently match, not the rendered tail: the 1000-row cap is a DOM budget and has nothing to say about how long a file may be. Lines carry the full date and UTC offset, since a bare wall-clock time stops meaning anything the moment the file leaves the browser. Co-Authored-By: Claude Fable 5 --- web/messages/de.json | 5 ++ web/messages/en.json | 5 ++ web/src/sections/Logs/LogsCard.tsx | 104 ++++++++++++++++++++++++----- web/src/sections/Logs/export.ts | 94 ++++++++++++++++++++++++++ web/src/stories/Logs.stories.tsx | 8 +++ 5 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 web/src/sections/Logs/export.ts diff --git a/web/messages/de.json b/web/messages/de.json index d14f687b..77c304af 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -246,6 +246,11 @@ "logs_search": "Logs durchsuchen…", "logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.", "logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten", + "logs_download": "Logs herunterladen", + "logs_share": "Logs teilen", + "logs_copy": "Logs in die Zwischenablage kopieren", + "logs_copied": "Logs in die Zwischenablage kopiert", + "logs_share_failed": "Logs konnten nicht geteilt werden", "stats_title": "Leistung", "stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.", "stats_capture_title": "Aufzeichnung", diff --git a/web/messages/en.json b/web/messages/en.json index 82dc315e..2340e752 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -246,6 +246,11 @@ "logs_search": "Search logs…", "logs_empty": "No log entries match — adjust the filter or wait for host activity.", "logs_dropped": "Some entries were evicted before they could be fetched", + "logs_download": "Download logs", + "logs_share": "Share logs", + "logs_copy": "Copy logs to clipboard", + "logs_copied": "Logs copied to clipboard", + "logs_share_failed": "Couldn't share the logs", "stats_title": "Performance", "stats_subtitle": "Record a session's pipeline timings and review them as graphs.", "stats_capture_title": "Capture", diff --git a/web/src/sections/Logs/LogsCard.tsx b/web/src/sections/Logs/LogsCard.tsx index 6db28b2b..2953c5be 100644 --- a/web/src/sections/Logs/LogsCard.tsx +++ b/web/src/sections/Logs/LogsCard.tsx @@ -1,4 +1,5 @@ -import { Pause, Play, Trash2 } from "lucide-react"; +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"; @@ -8,6 +9,14 @@ import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { m } from "@/paraglide/messages"; +import { + detectShareMode, + downloadText, + logFilename, + logsToText, + type ShareMode, + shareLogs, +} from "./export"; const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const; type MinLevel = (typeof LEVELS)[number]; @@ -39,6 +48,13 @@ export const LogsSection: FC = () => { const [entries, setEntries] = useState([]); const [follow, setFollow] = useState(true); const [dropped, setDropped] = useState(false); + const [shareMode, setShareMode] = useState(null); + + // 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 }, @@ -60,6 +76,8 @@ export const LogsSection: FC = () => { setCursor(data.next); }, [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} /> ); }; -/** Pure log viewer: level/min filter + text search (local UI state), follow + clear controls. */ +/** + * 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. + */ export const LogsCard: FC<{ entries: LogEntry[]; follow: boolean; onFollow: (follow: boolean) => void; onClear: () => void; + onDownload: (shown: LogEntry[]) => void; + onShare: (shown: LogEntry[]) => void; + shareMode: ShareMode | null; dropped: boolean; -}> = ({ entries, follow, onFollow, onClear, dropped }) => { +}> = ({ + entries, + follow, + onFollow, + onClear, + onDownload, + onShare, + shareMode, + dropped, +}) => { const [minLevel, setMinLevel] = useState("DEBUG"); const [search, setSearch] = useState(""); const listRef = useRef(null); - const filtered = useMemo(() => { + const matched = useMemo(() => { const min = RANK[minLevel] ?? 0; const q = search.trim().toLowerCase(); - return entries - .filter( - (e) => - (RANK[e.level] ?? 0) >= min && - (q === "" || - e.msg.toLowerCase().includes(q) || - e.target.toLowerCase().includes(q)), - ) - .slice(-SHOW); + return entries.filter( + (e) => + (RANK[e.level] ?? 0) >= min && + (q === "" || + e.msg.toLowerCase().includes(q) || + e.target.toLowerCase().includes(q)), + ); }, [entries, minLevel, search]); + const visible = useMemo(() => matched.slice(-SHOW), [matched]); + const shareLabel = shareMode === "share" ? m.logs_share() : m.logs_copy(); // Keep the tail in view while following (entries are append-only, so length is a good signal). useEffect(() => { if (!follow) return; const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [follow, filtered.length]); + }, [follow, visible.length]); return ( @@ -131,6 +177,32 @@ export const LogsCard: FC<{ />
{dropped && {m.logs_dropped()}} + + {shareMode && ( + + )}