feat(web): the logs page can hand a log off — as a file, or to the share sheet

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 13:07:15 +02:00
co-authored by Claude Fable 5
parent 384a0adc83
commit 8d6241efae
5 changed files with 200 additions and 16 deletions
+5
View File
@@ -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",
+5
View File
@@ -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",
+88 -16
View File
@@ -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<LogEntry[]>([]);
const [follow, setFollow] = useState(true);
const [dropped, setDropped] = useState(false);
const [shareMode, setShareMode] = useState<ShareMode | null>(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 (
<LogsCard
entries={entries}
@@ -69,43 +87,71 @@ export const LogsSection: FC = () => {
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<MinLevel>("DEBUG");
const [search, setSearch] = useState("");
const listRef = useRef<HTMLDivElement>(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 (
<Card>
@@ -131,6 +177,32 @@ export const LogsCard: FC<{
/>
<div className="ml-auto flex items-center gap-2">
{dropped && <Badge variant="secondary">{m.logs_dropped()}</Badge>}
<Button
size="icon"
variant="ghost"
disabled={matched.length === 0}
title={m.logs_download()}
aria-label={m.logs_download()}
onClick={() => onDownload(matched)}
>
<Download className="size-4" />
</Button>
{shareMode && (
<Button
size="icon"
variant="ghost"
disabled={matched.length === 0}
title={shareLabel}
aria-label={shareLabel}
onClick={() => onShare(matched)}
>
{shareMode === "share" ? (
<Share2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
)}
<Button
size="sm"
variant={follow ? "secondary" : "outline"}
@@ -154,10 +226,10 @@ export const LogsCard: FC<{
ref={listRef}
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
>
{filtered.length === 0 ? (
{visible.length === 0 ? (
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
) : (
filtered.map((e) => (
visible.map((e) => (
<div key={e.seq} className="whitespace-pre-wrap break-words">
<span className="text-muted-foreground">
{fmtTime(e.ts_ms)}{" "}
+94
View File
@@ -0,0 +1,94 @@
import type { LogEntry } from "@/api/gen/model/logEntry";
/**
* How the log entries can leave the page. Probed at runtime rather than assumed: `share` is Web
* Share level 2 (mobile Safari/Chrome), `copy` is every secure-context desktop browser, and `null`
* is a page served over plain HTTP to a browser without Web Share — there the clipboard API is
* absent too, so the button is left out instead of offered as a no-op.
*/
export type ShareMode = "share" | "copy";
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
* wall-clock time is ambiguous the moment the file leaves the browser, and bug reports span days.
*/
export const logsToText = (entries: LogEntry[]): string =>
entries
.map((e) => `${stamp(e.ts_ms)} ${e.level.padEnd(5)} ${e.target} ${e.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`;
export const downloadText = (text: string, filename: string): void => {
const url = URL.createObjectURL(new Blob([text], { type: MIME }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
export const detectShareMode = (): ShareMode | null => {
if (typeof navigator === "undefined") return null; // server render
// `canShare` decides on the file's type, not its bytes, so a stand-in probe file is enough.
if (canShareFiles([logFile("probe", "punktfunk-logs.log")])) return "share";
return typeof navigator.clipboard?.writeText === "function" ? "copy" : null;
};
/**
* Hand the logs to the OS share sheet, falling back to the clipboard. Everything up to the
* `navigator.share`/`writeText` call is synchronous on purpose: both APIs require the calling task
* to still be the user's click, and an `await` in between would forfeit that on Safari.
*/
export const shareLogs = async (
text: string,
filename: string,
): Promise<ShareOutcome> => {
const files = [logFile(text, filename)];
if (canShareFiles(files)) {
try {
await navigator.share({ files, title: filename });
return "shared";
} catch (err) {
// A dismissed share sheet is a deliberate cancel, not something to report back.
return err instanceof DOMException && err.name === "AbortError"
? "cancelled"
: "failed";
}
}
try {
await navigator.clipboard.writeText(text);
return "copied";
} catch {
return "failed";
}
};
const logFile = (text: string, filename: string): File =>
new File([text], filename, { type: MIME });
// Callers reach this from a click or from the guarded probe above, so `navigator` is always there.
const canShareFiles = (files: File[]): boolean =>
typeof navigator.canShare === "function" && navigator.canShare({ files });
const p = (n: number, w = 2): string => String(n).padStart(w, "0");
/** Local ISO 8601 with a numeric offset, e.g. `2026-07-30T14:22:31.123+02:00`. */
const stamp = (ts: number): string => {
const d = new Date(ts);
const date = `${p(d.getFullYear(), 4)}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
// getTimezoneOffset() counts minutes *behind* UTC, so east of Greenwich is negative.
const off = -d.getTimezoneOffset();
const sign = off < 0 ? "-" : "+";
const abs = Math.abs(off);
return `${date}T${time}${sign}${p(Math.floor(abs / 60))}:${p(abs % 60)}`;
};
+8
View File
@@ -36,6 +36,8 @@ export default meta;
type Story = StoryObj<typeof meta>;
// The real page layout (LogsView) with the pure viewer card + fixture entries in its slot.
// `shareMode` is probed from the browser on the live page; the stories pin one of each so both the
// desktop (clipboard) and mobile (share sheet) affordance stay covered by the screenshot run.
export const Following: Story = {
args: {
viewer: (
@@ -44,6 +46,9 @@ export const Following: Story = {
follow
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="copy"
dropped={false}
/>
),
@@ -58,6 +63,9 @@ export const PausedWithGap: Story = {
follow={false}
onFollow={noop}
onClear={noop}
onDownload={noop}
onShare={noop}
shareMode="share"
dropped
/>
),