feat(export): wire History + Logs export JSON handlers (#1354) (#1367)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-27 19:06:28 -04:00 • committed by GitHub
commit a7178280f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 96 additions and 53 deletions

View file

@ -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}

View file

@ -21,7 +21,6 @@ const meta: Meta<typeof LogStreamPanel> = {
},
autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
onClear: fn(),
onExport: fn(),
},

View file

@ -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(<LogStreamPanel {...baseProps} onCopyAll={onCopyAll} />);
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();

View file

@ -19,7 +19,6 @@ export interface LogStreamPanelProps {
visibleLevels: Record<LoggingLevel, boolean>;
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
</Button>
<Button
variant="default"
onClick={onCopyAll}
disabled={entries.length === 0}
>
Copy All
</Button>
</Group>
</Group>
{filteredEntries.length > 0 ? (

View file

@ -15,7 +15,6 @@ const meta: Meta<typeof LoggingScreen> = {
onExport: fn(),
autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
},
};

View file

@ -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(
<LoggingScreen {...baseProps} entries={entries} onCopyAll={onCopyAll} />,
);
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(<LoggingScreen {...baseProps} />);
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 () => {

View file

@ -13,7 +13,6 @@ export interface LoggingScreenProps {
onExport: () => void;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}
const ALL_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
@ -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}
/>

View file

@ -350,7 +350,6 @@ const meta: Meta<typeof InspectorView> = {
onSetLogLevel: fn(),
onClearLogs: fn(),
onExportLogs: fn(),
onCopyAllLogs: fn(),
onClearHistory: fn(),
onExportHistory: fn(),
onReplayHistory: fn(),

View file

@ -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(),

View file

@ -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}
/>
</ScreenStage>
<ScreenStage active={activeTab === "History"}>

View file

@ -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-<kind>-<server-id>-<ISO timestamp>.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`;
}

View file

@ -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$/,
);
});
});