diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index d0d25e560..af915e991 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 c10f150e4..5f5ed4165 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 addefe360..b6f4828da 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 4e0e9c3b0..b627618ca 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 b67d61974..0a26c2b53 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 d2d83c4e3..e017e48d6 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 44ab12099..9f1ae9ab8 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 dd33d7cfb..d0c6176a4 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 6eca8f4c7..768ca4c77 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 ba98919f5..3e8659fb6 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 206a21374..18f649162 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 57ba78691..de1ae9944 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$/, + ); + }); +});