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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}{" "}
|
||||
|
||||
@@ -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)}`;
|
||||
};
|
||||
@@ -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
|
||||
/>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user