Skip to content
Open
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
41 changes: 38 additions & 3 deletions pages/api-key-usage/ApiKeyUsagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import { TextInput } from "@code-proxy/ui";
import { ThemeToggleButton } from "@code-proxy/ui";
import type { SearchableCheckboxMultiSelectOption } from "@code-proxy/ui";
import type { TimeRange } from "@features/monitor-widgets/monitor-constants";
import { LogContentModal } from "@features/log-content-viewer";
import { ModelTag } from "@features/model-tags";
import { fetchPublicLogs, fetchPublicUsageSummary } from "../api-key-lookup/api";
import {
fetchPublicLogContent,
fetchPublicLogs,
fetchPublicUsageSummary,
} from "../api-key-lookup/api";
import {
LookupResultsToolbar,
type ApiKeyLookupTab,
Expand Down Expand Up @@ -205,6 +210,11 @@ export function ApiKeyUsagePage() {
const [selectedModels, setSelectedModels] = useState<MultiSelectFilterState<string>>(null);
const [selectedStatuses, setSelectedStatuses] =
useState<MultiSelectFilterState<StatusFilterValue>>(null);
const [contentModal, setContentModal] = useState<{
id: number;
model: string;
tab: "input" | "output";
} | null>(null);

const abortControllerRef = useRef<AbortController | null>(null);
const fetchIdRef = useRef(0);
Expand All @@ -216,13 +226,28 @@ export function ApiKeyUsagePage() {
const summaryAbortControllerRef = useRef<AbortController | null>(null);
const summaryFetchIdRef = useRef(0);

const handleContentClick = useCallback(
(id: number, tab: "input" | "output", model: string) => setContentModal({ id, tab, model }),
[],
);
const fetchLogContentPart = useCallback(
(id: number, part: "input" | "output", options?: { signal?: AbortSignal }) =>
fetchPublicLogContent({
id,
apiKey: queriedKey,
part,
signal: options?.signal,
}),
[queriedKey],
);

const logColumns = useMemo(
() =>
buildRequestLogsColumns((key) => t(key), undefined, undefined, {
buildRequestLogsColumns((key) => t(key), handleContentClick, undefined, {
identityColumn: "none",
hideChannel: true,
}),
[t],
[handleContentClick, t],
);

const modelOptions = useMemo<SearchableCheckboxMultiSelectOption[]>(() => {
Expand Down Expand Up @@ -293,6 +318,7 @@ export function ApiKeyUsagePage() {
});
setSelectedModels(null);
setSelectedStatuses(null);
setContentModal(null);
setApiKeyName("");
setQuotaLimits(null);
setQuotaScopes([]);
Expand Down Expand Up @@ -637,6 +663,15 @@ export function ApiKeyUsagePage() {
)}
</main>

<LogContentModal
open={contentModal !== null}
logId={contentModal?.id ?? null}
displayModel={contentModal?.model}
initialTab={contentModal?.tab}
onClose={() => setContentModal(null)}
fetchPartFn={fetchLogContentPart}
/>

<Modal
open={keyModalOpen}
title={t("apikey_usage.modal_title")}
Expand Down
52 changes: 50 additions & 2 deletions pages/api-key-usage/__tests__/ApiKeyUsagePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,40 @@ import { ToastProvider } from "@code-proxy/ui";
import { ApiKeyUsagePage } from "../ApiKeyUsagePage";

const mocks = vi.hoisted(() => ({
fetchPublicLogContent: vi.fn(),
fetchPublicLogs: vi.fn(),
fetchPublicUsageSummary: vi.fn(),
}));

vi.mock("../../api-key-lookup/api", () => ({
fetchPublicLogContent: mocks.fetchPublicLogContent,
fetchPublicLogs: mocks.fetchPublicLogs,
fetchPublicUsageSummary: mocks.fetchPublicUsageSummary,
}));

vi.mock("@features/log-content-viewer", () => ({
LogContentModal: ({
open,
logId,
initialTab,
fetchPartFn,
}: {
open: boolean;
logId: number | null;
initialTab?: "input" | "output";
fetchPartFn?: (id: number, part: "input" | "output") => Promise<unknown>;
}) =>
open && logId ? (
<button
type="button"
data-testid="load-log-content"
onClick={() => fetchPartFn?.(logId, initialTab ?? "input")}
>
load content
</button>
) : null,
}));

vi.mock("../../api-key-lookup/components/QuickImportTabContent", () => ({
QuickImportTabContent: ({ apiKey }: { apiKey: string }) => (
<div data-testid="quick-import-stub">quick-import:{apiKey}</div>
Expand All @@ -37,8 +62,15 @@ describe("ApiKeyUsagePage", () => {
await i18n.changeLanguage("zh-CN");
window.history.replaceState({}, "", "/manage/apikey-usage");
window.localStorage.clear();
mocks.fetchPublicLogContent.mockReset();
mocks.fetchPublicLogs.mockReset();
mocks.fetchPublicUsageSummary.mockReset();
mocks.fetchPublicLogContent.mockResolvedValue({
id: 1,
model: "gpt-test",
part: "input",
content: "hello",
});
mocks.fetchPublicLogs.mockResolvedValue({
items: [
{
Expand All @@ -52,7 +84,7 @@ describe("ApiKeyUsagePage", () => {
cached_tokens: 0,
total_tokens: 3,
cost: 0,
has_content: false,
has_content: true,
},
],
total: 1,
Expand Down Expand Up @@ -102,7 +134,9 @@ describe("ApiKeyUsagePage", () => {
expect(screen.getByText("快捷导入")).toBeInTheDocument();
expect(screen.getByText("张军宝")).toBeInTheDocument();
expect(screen.queryByText("使用统计")).not.toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: /KEY 名称|Key name/i })).not.toBeInTheDocument();
expect(
screen.queryByRole("columnheader", { name: /KEY 名称|Key name/i }),
).not.toBeInTheDocument();
await waitFor(() => {
expect(mocks.fetchPublicUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: "sk-demo" }),
Expand Down Expand Up @@ -140,4 +174,18 @@ describe("ApiKeyUsagePage", () => {
expect(await screen.findByText("张军宝")).toBeInTheDocument();
expect(screen.queryByTestId("apikey-usage-empty")).not.toBeInTheDocument();
});

test("loads key-scoped request content from the logs table", async () => {
renderPage();

await userEvent.type(screen.getByPlaceholderText(/输入 API 密钥|Enter API Key/i), "sk-demo");
await userEvent.click(screen.getByTestId("apikey-usage-submit"));

await userEvent.click(await screen.findByTitle(/查看输入|View input/i));
await userEvent.click(await screen.findByTestId("load-log-content"));

expect(mocks.fetchPublicLogContent).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, apiKey: "sk-demo", part: "input" }),
);
});
});