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 && ( + + )}