* 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:
parent
4641339cc3
commit
a7178280f0
12 changed files with 96 additions and 53 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const meta: Meta<typeof LogStreamPanel> = {
|
|||
},
|
||||
autoScroll: true,
|
||||
onToggleAutoScroll: fn(),
|
||||
onCopyAll: fn(),
|
||||
onClear: fn(),
|
||||
onExport: fn(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const meta: Meta<typeof LoggingScreen> = {
|
|||
onExport: fn(),
|
||||
autoScroll: true,
|
||||
onToggleAutoScroll: fn(),
|
||||
onCopyAll: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -350,7 +350,6 @@ const meta: Meta<typeof InspectorView> = {
|
|||
onSetLogLevel: fn(),
|
||||
onClearLogs: fn(),
|
||||
onExportLogs: fn(),
|
||||
onCopyAllLogs: fn(),
|
||||
onClearHistory: fn(),
|
||||
onExportHistory: fn(),
|
||||
onReplayHistory: fn(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue