Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const meta: Meta<typeof LogStreamPanel> = {
},
autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
onClear: fn(),
onExport: fn(),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const baseProps = {
visibleLevels: allVisible,
autoScroll: true,
onToggleAutoScroll: vi.fn(),
onCopyAll: vi.fn(),
onClear: vi.fn(),
onExport: vi.fn(),
};
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export interface LogStreamPanelProps {
visibleLevels: Record<LoggingLevel, boolean>;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
onClear: () => void;
onExport: () => void;
}
Expand Down Expand Up @@ -64,7 +63,6 @@ export function LogStreamPanel({
visibleLevels,
autoScroll,
onToggleAutoScroll,
onCopyAll,
onClear,
onExport,
}: LogStreamPanelProps) {
Expand Down Expand Up @@ -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 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const meta: Meta<typeof LoggingScreen> = {
onExport: fn(),
autoScroll: true,
onToggleAutoScroll: fn(),
onCopyAll: fn(),
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const baseProps = {
onExport: vi.fn(),
autoScroll: true,
onToggleAutoScroll: vi.fn(),
onCopyAll: vi.fn(),
};

describe("LoggingScreen", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export interface LoggingScreenProps {
onExport: () => void;
autoScroll: boolean;
onToggleAutoScroll: () => void;
onCopyAll: () => void;
}

const ALL_LEVELS_VISIBLE: Record<LoggingLevel, boolean> = {
Expand Down Expand Up @@ -63,7 +62,6 @@ export function LoggingScreen({
onExport,
autoScroll,
onToggleAutoScroll,
onCopyAll,
}: LoggingScreenProps) {
const [filterText, setFilterText] = useState("");
const [visibleLevels, setVisibleLevels] =
Expand Down Expand Up @@ -99,7 +97,6 @@ export function LoggingScreen({
visibleLevels={visibleLevels}
autoScroll={autoScroll}
onToggleAutoScroll={onToggleAutoScroll}
onCopyAll={onCopyAll}
onClear={onClear}
onExport={onExport}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ const meta: Meta<typeof InspectorView> = {
onSetLogLevel: fn(),
onClearLogs: fn(),
onExportLogs: fn(),
onCopyAllLogs: fn(),
onClearHistory: fn(),
onExportHistory: fn(),
onReplayHistory: fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,6 @@ export interface InspectorViewProps {
onSetLogLevel: (level: LoggingLevel) => void;
onClearLogs: () => void;
onExportLogs: () => void;
onCopyAllLogs: () => void;

onClearHistory: () => void;
onExportHistory: () => void;
Expand Down Expand Up @@ -270,7 +269,6 @@ export function InspectorView({
onSetLogLevel,
onClearLogs,
onExportLogs,
onCopyAllLogs,
onClearHistory,
onExportHistory,
onReplayHistory,
Expand Down Expand Up @@ -453,7 +451,6 @@ export function InspectorView({
onExport={onExportLogs}
autoScroll={autoScroll}
onToggleAutoScroll={() => setAutoScroll((prev) => !prev)}
onCopyAll={onCopyAllLogs}
/>
</ScreenStage>
<ScreenStage active={activeTab === "History"}>
Expand Down
30 changes: 30 additions & 0 deletions clients/web/src/lib/downloadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
37 changes: 36 additions & 1 deletion clients/web/src/test/lib/downloadFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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