From a7178280f00336caaffadedf57ab405bab97f8f8 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Wed, 27 May 2026 19:06:28 -0400 Subject: [PATCH] feat(export): wire History + Logs export JSON handlers (#1354) (#1367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(export): wire History + Logs export JSON handlers (#1354) Replaces the `todoNoop` placeholders in App.tsx with real handlers that serialize the current entries and trigger a browser download via the existing `downloadJsonFile` helper. - Adds `buildExportFilename(kind, serverId, now?)` next to the download helper. Produces sortable, filesystem-safe names like `inspector-history-alpha-2026-03-17T10-00-42.123Z.json` (URL-encodes server ids that contain slashes / spaces / colons, swaps `:` for `-` in the ISO timestamp so the file is safe on Windows). - Wires `onExportHistory` (full MessageEntry list) and `onExportLogs` (the LogEntryData list the screen renders). - Routes the existing Network export through the same filename helper for consistency. Unit tests cover the filename builder, including the no-server-id fallback, special-character encoding, and the default-`now` shape. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(export): tighten buildExportFilename signature + docs - Introduce an `ExportKind = "history" | "logs" | "network"` union and use it as the type of the `kind` parameter. Typos like "hisotry" now fail at compile time and the supported export kinds are documented inline. - Clarify the JSDoc: the segment is omitted when `serverId` is falsy (undefined OR empty string), matching the existing `serverId ? ... : undefined` check. New test pins the empty-string branch. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(logging): remove Copy All button from LogStreamPanel Export now produces the same JSON the Copy All button used to dump to the clipboard, so the button is redundant. History and Network panels have no equivalent — dropping Copy All also brings Logs into parity. Removes the prop from LogStreamPanel + LoggingScreen + InspectorView + App.tsx, and updates the matching tests/stories. Tests now assert the button is *not* in the document so a future re-introduction would have to update the assertion deliberately. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- clients/web/src/App.tsx | 30 ++++++++++++--- .../LogStreamPanel/LogStreamPanel.stories.tsx | 1 - .../LogStreamPanel/LogStreamPanel.test.tsx | 13 +------ .../groups/LogStreamPanel/LogStreamPanel.tsx | 9 ----- .../LoggingScreen/LoggingScreen.stories.tsx | 1 - .../LoggingScreen/LoggingScreen.test.tsx | 20 ++-------- .../screens/LoggingScreen/LoggingScreen.tsx | 3 -- .../InspectorView/InspectorView.stories.tsx | 1 - .../InspectorView/InspectorView.test.tsx | 1 - .../views/InspectorView/InspectorView.tsx | 3 -- clients/web/src/lib/downloadFile.ts | 30 +++++++++++++++ clients/web/src/test/lib/downloadFile.test.ts | 37 ++++++++++++++++++- 12 files changed, 96 insertions(+), 53 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index d0d25e56..af915e99 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -51,7 +51,7 @@ import { } from "./components/groups/ServerConfigModal/ServerConfigModal"; import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal"; -import { downloadJsonFile } from "./lib/downloadFile"; +import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { createWebEnvironment } from "./lib/environmentFactory"; // OAuth redirect URL provider — points at the dev backend's `/oauth/callback` @@ -679,8 +679,27 @@ function App() { const onExportNetwork = useCallback(() => { if (fetchRequests.length === 0) return; - downloadJsonFile("network.json", JSON.stringify(fetchRequests, null, 2)); - }, [fetchRequests]); + downloadJsonFile( + buildExportFilename("network", activeServerId), + JSON.stringify(fetchRequests, null, 2), + ); + }, [fetchRequests, activeServerId]); + + const onExportHistory = useCallback(() => { + if (messages.length === 0) return; + downloadJsonFile( + buildExportFilename("history", activeServerId), + JSON.stringify(messages, null, 2), + ); + }, [messages, activeServerId]); + + const onExportLogs = useCallback(() => { + if (logs.length === 0) return; + downloadJsonFile( + buildExportFilename("logs", activeServerId), + JSON.stringify(logs, null, 2), + ); + }, [logs, activeServerId]); // Action stubs — these UI affordances exist but require additional // wiring (server CRUD, history pinning, app sandbox round-trip, log @@ -916,10 +935,9 @@ function App() { onRefreshTasks={onRefreshTasks} onSetLogLevel={onSetLogLevel} onClearLogs={onClearLogs} - onExportLogs={todoNoop} - onCopyAllLogs={todoNoop} + onExportLogs={onExportLogs} onClearHistory={onClearHistory} - onExportHistory={todoNoop} + onExportHistory={onExportHistory} onReplayHistory={todoNoop} onTogglePinHistory={todoNoop} onClearNetwork={onClearNetwork} diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx index c10f150e..5f5ed416 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx @@ -21,7 +21,6 @@ const meta: Meta = { }, autoScroll: true, onToggleAutoScroll: fn(), - onCopyAll: fn(), onClear: fn(), onExport: fn(), }, diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx index addefe36..b6f4828d 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.test.tsx @@ -45,7 +45,6 @@ const baseProps = { visibleLevels: allVisible, autoScroll: true, onToggleAutoScroll: vi.fn(), - onCopyAll: vi.fn(), onClear: vi.fn(), onExport: vi.fn(), }; @@ -57,8 +56,8 @@ describe("LogStreamPanel", () => { expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Copy All" }), - ).toBeInTheDocument(); + screen.queryByRole("button", { name: "Copy All" }), + ).not.toBeInTheDocument(); }); it("renders log entries when provided", () => { @@ -150,14 +149,6 @@ describe("LogStreamPanel", () => { expect(onExport).toHaveBeenCalledTimes(1); }); - it("invokes onCopyAll when Copy All is clicked", async () => { - const user = userEvent.setup(); - const onCopyAll = vi.fn(); - renderWithMantine(); - await user.click(screen.getByRole("button", { name: "Copy All" })); - expect(onCopyAll).toHaveBeenCalledTimes(1); - }); - it("invokes onToggleAutoScroll when the auto-scroll checkbox is clicked", async () => { const user = userEvent.setup(); const onToggleAutoScroll = vi.fn(); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index 4e0e9c3b..b627618c 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -19,7 +19,6 @@ export interface LogStreamPanelProps { visibleLevels: Record; autoScroll: boolean; onToggleAutoScroll: () => void; - onCopyAll: () => void; onClear: () => void; onExport: () => void; } @@ -64,7 +63,6 @@ export function LogStreamPanel({ visibleLevels, autoScroll, onToggleAutoScroll, - onCopyAll, onClear, onExport, }: LogStreamPanelProps) { @@ -97,13 +95,6 @@ export function LogStreamPanel({ > Export - {filteredEntries.length > 0 ? ( diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx index b67d6197..0a26c2b5 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.stories.tsx @@ -15,7 +15,6 @@ const meta: Meta = { onExport: fn(), autoScroll: true, onToggleAutoScroll: fn(), - onCopyAll: fn(), }, }; diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index d2d83c4e..e017e48d 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -11,7 +11,6 @@ const baseProps = { onExport: vi.fn(), autoScroll: true, onToggleAutoScroll: vi.fn(), - onCopyAll: vi.fn(), }; describe("LoggingScreen", () => { @@ -45,24 +44,13 @@ describe("LoggingScreen", () => { expect(onClear).toHaveBeenCalledTimes(1); }); - it("invokes onCopyAll when Copy All is clicked", async () => { - const user = userEvent.setup(); - const onCopyAll = vi.fn(); - const entries = [ - { receivedAt: new Date(), params: { level: "info" as const, data: "x" } }, - ]; - renderWithMantine( - , - ); - await user.click(screen.getByRole("button", { name: "Copy All" })); - expect(onCopyAll).toHaveBeenCalledTimes(1); - }); - - it("disables Clear / Export / Copy All when there are no entries", () => { + it("disables Clear / Export when there are no entries", () => { renderWithMantine(); expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Copy All" })).toBeDisabled(); + expect( + screen.queryByRole("button", { name: "Copy All" }), + ).not.toBeInTheDocument(); }); it("toggles a single level on level button click", async () => { diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index 44ab1209..9f1ae9ab 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -13,7 +13,6 @@ export interface LoggingScreenProps { onExport: () => void; autoScroll: boolean; onToggleAutoScroll: () => void; - onCopyAll: () => void; } const ALL_LEVELS_VISIBLE: Record = { @@ -63,7 +62,6 @@ export function LoggingScreen({ onExport, autoScroll, onToggleAutoScroll, - onCopyAll, }: LoggingScreenProps) { const [filterText, setFilterText] = useState(""); const [visibleLevels, setVisibleLevels] = @@ -99,7 +97,6 @@ export function LoggingScreen({ visibleLevels={visibleLevels} autoScroll={autoScroll} onToggleAutoScroll={onToggleAutoScroll} - onCopyAll={onCopyAll} onClear={onClear} onExport={onExport} /> diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index dd33d7cf..d0c6176a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -350,7 +350,6 @@ const meta: Meta = { onSetLogLevel: fn(), onClearLogs: fn(), onExportLogs: fn(), - onCopyAllLogs: fn(), onClearHistory: fn(), onExportHistory: fn(), onReplayHistory: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 6eca8f4c..768ca4c7 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -79,7 +79,6 @@ function makeProps( onSetLogLevel: vi.fn(), onClearLogs: vi.fn(), onExportLogs: vi.fn(), - onCopyAllLogs: vi.fn(), onClearHistory: vi.fn(), onExportHistory: vi.fn(), onReplayHistory: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index ba98919f..3e8659fb 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -199,7 +199,6 @@ export interface InspectorViewProps { onSetLogLevel: (level: LoggingLevel) => void; onClearLogs: () => void; onExportLogs: () => void; - onCopyAllLogs: () => void; onClearHistory: () => void; onExportHistory: () => void; @@ -270,7 +269,6 @@ export function InspectorView({ onSetLogLevel, onClearLogs, onExportLogs, - onCopyAllLogs, onClearHistory, onExportHistory, onReplayHistory, @@ -453,7 +451,6 @@ export function InspectorView({ onExport={onExportLogs} autoScroll={autoScroll} onToggleAutoScroll={() => setAutoScroll((prev) => !prev)} - onCopyAll={onCopyAllLogs} /> diff --git a/clients/web/src/lib/downloadFile.ts b/clients/web/src/lib/downloadFile.ts index 206a2137..18f64916 100644 --- a/clients/web/src/lib/downloadFile.ts +++ b/clients/web/src/lib/downloadFile.ts @@ -30,3 +30,33 @@ export function downloadJsonFile(filename: string, json: string): void { URL.revokeObjectURL(url); } } + +/** + * The categories of in-memory data the Inspector can export. Tightening + * `kind` to this union catches typos at call sites and documents the + * stable on-disk filename prefix. + */ +export type ExportKind = "history" | "logs" | "network"; + +/** + * Build a sortable export filename in the shape + * `inspector---.json`. The timestamp uses + * the standard ISO-8601 form with `:` swapped for `-` so the result is + * safe on Windows (which disallows `:` in filenames). Server id is + * passed through `encodeURIComponent` for the same reason — config ids + * are user-supplied and may contain slashes / spaces / colons. + * + * When `serverId` is falsy (undefined or empty) the segment is omitted; + * the rest of the filename still uniquely identifies the export by kind + * + time. + */ +export function buildExportFilename( + kind: ExportKind, + serverId: string | undefined, + now: Date = new Date(), +): string { + const iso = now.toISOString().replace(/:/g, "-"); + const id = serverId ? encodeURIComponent(serverId) : undefined; + const segments = ["inspector", kind, ...(id ? [id] : []), iso]; + return `${segments.join("-")}.json`; +} diff --git a/clients/web/src/test/lib/downloadFile.test.ts b/clients/web/src/test/lib/downloadFile.test.ts index 57ba7869..de1ae994 100644 --- a/clients/web/src/test/lib/downloadFile.test.ts +++ b/clients/web/src/test/lib/downloadFile.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { downloadJsonFile } from "../../lib/downloadFile"; +import { downloadJsonFile, buildExportFilename } from "../../lib/downloadFile"; describe("downloadJsonFile", () => { const originalCreate = URL.createObjectURL; @@ -92,3 +92,38 @@ describe("downloadJsonFile", () => { expect(text).toBe('{"x":1}'); }); }); + +describe("buildExportFilename", () => { + const fixedNow = new Date("2026-03-17T10:00:42.123Z"); + + it("includes kind, server id, and ISO timestamp with `:` swapped for `-`", () => { + expect(buildExportFilename("history", "alpha", fixedNow)).toBe( + "inspector-history-alpha-2026-03-17T10-00-42.123Z.json", + ); + }); + + it("omits the server-id segment when serverId is undefined", () => { + expect(buildExportFilename("logs", undefined, fixedNow)).toBe( + "inspector-logs-2026-03-17T10-00-42.123Z.json", + ); + }); + + it("omits the server-id segment when serverId is an empty string", () => { + expect(buildExportFilename("logs", "", fixedNow)).toBe( + "inspector-logs-2026-03-17T10-00-42.123Z.json", + ); + }); + + it("encodes server ids that contain filesystem-unsafe characters", () => { + expect(buildExportFilename("network", "my server/v2", fixedNow)).toBe( + "inspector-network-my%20server%2Fv2-2026-03-17T10-00-42.123Z.json", + ); + }); + + it("defaults `now` to the current time when not provided", () => { + const name = buildExportFilename("history", "alpha"); + expect(name).toMatch( + /^inspector-history-alpha-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.json$/, + ); + }); +});