refactor(ui): migrate request logs table onto the shared DataTable (#34343)

* refactor(ui): migrate request logs table onto the shared DataTable

Moves the Request Logs tab off the local view_logs/table.tsx clone and onto the
shared DataTable in server sort, pagination, and filter mode. The container is
split into RequestLogsPanel (data owner: the spend-logs query, the session dedup
and composition pipeline, and the detail drawer), a thin RequestLogsTable, and
RequestLogsTableColumns. The clone itself stays for now because TopModelView and
TopKeyView still consume it

The advanced filter bar moves into the shared DataTableFilterDrawer, so filters
commit on Apply and render as removable chips. That makes the per-keystroke
debounce in the query hook redundant, and the hook now takes ColumnFiltersState,
PaginationState, and SortingState directly instead of carrying its own filter
shape. Reset still restores the default 24 hour window alongside the filters

Adds shared/PaginatedSearchSelect, a Base UI combobox with server-side search and
infinite scroll, and uses it for the Key Alias and Model filters. That retires the
three logs-only antd pickers (PaginatedKeyAliasSelect, PaginatedModelSelect,
FilterTeamDropdown) and the FilterComponent molecule they plugged into. The shared
TeamDropdown is deliberately untouched: six other surfaces still render it, five of
them as a bare child of an antd Form.Item that injects value/onChange implicitly

* test(ui): pin team-scoped key alias filtering in the logs filter drawer

The Key Alias filter narrows its options to the team selected in the same
drawer, a cross-filter dependency carried over from the antd picker it
replaced. Nothing covered it: the live QA pass explicitly did not exercise
it either, so it was the one behaviour in this migration that could regress
silently

Asserts the selected team id reaches useInfiniteKeyAliases, that the lookup
stays unscoped when no team is picked, and that the scope does not leak into
the Model lookup, which shares the same combobox but takes no team
This commit is contained in:
yuneng-jiang 2026-07-23 10:32:08 -07:00 committed by GitHub
parent 3c2403a562
commit 0a4333580f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2092 additions and 3614 deletions

View file

@ -2349,11 +2349,6 @@
"count": 1
}
},
"src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/LicenseExpiryBanner.tsx": {
"no-restricted-imports": {
"count": 1
@ -2364,14 +2359,6 @@
"count": 1
}
},
"src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": {
"max-nested-callbacks": {
"count": 12
@ -3511,17 +3498,6 @@
"count": 1
}
},
"src/components/molecules/filter.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/molecules/message_manager.tsx": {
"local/filename-pascal-case": {
"count": 1
@ -4404,17 +4380,6 @@
"count": 2
}
},
"src/components/view_logs/LogsTableToolbar.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-nested-ternary": {
"count": 4
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/ToolsSection/FormattedToolView.tsx": {
"no-restricted-imports": {
"count": 1
@ -4443,9 +4408,6 @@
"src/components/view_logs/columns.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/view_logs/index.tsx": {
@ -4454,9 +4416,6 @@
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/view_logs/log_filter_logic.tsx": {

View file

@ -1,249 +0,0 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { PaginatedKeyAliasSelect } from "./PaginatedKeyAliasSelect";
const mockFetchNextPage = vi.fn();
vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({
useInfiniteKeyAliases: vi.fn(),
}));
vi.mock("@tanstack/react-pacer/debouncer", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
useDebouncedState: (initial: string) => {
const [value, setValue] = React.useState(initial);
return [value, setValue];
},
};
});
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
const mockUseInfiniteKeyAliases = vi.mocked(useInfiniteKeyAliases);
const mockPagesWithAliases = {
pages: [
{
aliases: ["alias-1", "alias-2"],
total_count: 2,
current_page: 1,
total_pages: 1,
size: 50,
},
],
};
const mockEmptyPages = {
pages: [{ aliases: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }],
};
describe("PaginatedKeyAliasSelect", () => {
const mockOnChange = vi.fn();
const defaultHookReturn = {
data: mockPagesWithAliases,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseInfiniteKeyAliases.mockReturnValue(defaultHookReturn as any);
});
it("should render", () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByText("Select a key alias")).toBeInTheDocument();
});
it("should display custom placeholder when provided", () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} placeholder="Choose alias" />);
expect(screen.getByText("Choose alias")).toBeInTheDocument();
});
it("should display alias options when data is loaded", async () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "alias-2" })).toBeInTheDocument();
});
});
it("should call onChange when user selects an alias", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
const option = await screen.findByTitle("alias-1");
await user.click(option);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith("alias-1");
});
});
it("should show loading state when isLoading is true", () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
isLoading: true,
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("should pass pageSize to useInfiniteKeyAliases", () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} pageSize={25} />);
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined, undefined);
});
it("should pass search to useInfiniteKeyAliases when user types", async () => {
const user = userEvent.setup();
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
await user.keyboard("my-alias");
await waitFor(() => {
expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias", undefined);
});
});
it("should have scroll container for infinite loading when hasNextPage is true", async () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
hasNextPage: true,
isFetchingNextPage: false,
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
});
const scrollableContainer = document.querySelector(".ant-select-dropdown .rc-virtual-list-holder");
expect(scrollableContainer).toBeInTheDocument();
});
it("should deduplicate aliases with the same value across pages", async () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
data: {
pages: [
{
aliases: ["alias-1", "alias-1"],
total_count: 2,
current_page: 1,
total_pages: 1,
size: 50,
},
],
},
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
const options = screen.queryAllByRole("option", { name: "alias-1" });
expect(options.length).toBe(1);
});
});
it("should skip empty aliases", async () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
data: {
pages: [
{
aliases: ["valid-alias", "", null],
total_count: 3,
current_page: 1,
total_pages: 1,
size: 50,
},
],
},
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "valid-alias" })).toBeInTheDocument();
const allOptions = screen.queryAllByRole("option");
expect(allOptions.length).toBe(1);
});
});
it("should respect allowClear prop", () => {
renderWithProviders(<PaginatedKeyAliasSelect value="alias-1" onChange={mockOnChange} allowClear={false} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should respect disabled prop", () => {
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} disabled />);
const combobox = screen.getByRole("combobox");
expect(combobox.closest(".ant-select")).toHaveClass("ant-select-disabled");
});
it("should not call fetchNextPage when hasNextPage is false", async () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
hasNextPage: false,
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
await userEvent.click(screen.getByRole("combobox"));
await waitFor(() => {
expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument();
});
expect(mockFetchNextPage).not.toHaveBeenCalled();
});
it("should show no aliases found when data is empty", async () => {
mockUseInfiniteKeyAliases.mockReturnValue({
...defaultHookReturn,
data: mockEmptyPages,
} as any);
renderWithProviders(<PaginatedKeyAliasSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByText("No key aliases found")).toBeInTheDocument();
});
});
});

View file

@ -1,107 +0,0 @@
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { Select } from "antd";
import { useMemo, useState, type UIEvent } from "react";
export interface PaginatedKeyAliasSelectProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
pageSize?: number;
allowClear?: boolean;
disabled?: boolean;
allFilters?: { [key: string]: string };
}
const SCROLL_THRESHOLD = 0.8;
export const PaginatedKeyAliasSelect = ({
value,
onChange,
placeholder = "Select a key alias",
style,
pageSize = 50,
allowClear = true,
disabled = false,
allFilters,
}: PaginatedKeyAliasSelectProps) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_WAIT_MS,
});
const teamId = allFilters?.["Team ID"] || undefined;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases(
pageSize,
debouncedSearch || undefined,
teamId,
);
const options = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: { label: string; value: string }[] = [];
for (const page of data.pages) {
for (const alias of page.aliases) {
if (!alias || seen.has(alias)) continue;
seen.add(alias);
result.push({ label: alias, value: alias });
}
}
return result;
}, [data]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (value: string) => {
setSearchInput(value);
setDebouncedSearch(value);
};
const handleChange = (v: string | null) => {
onChange?.(v ?? "");
};
return (
<Select
value={value || undefined}
onChange={handleChange}
placeholder={placeholder}
style={{ width: "100%", ...style }}
allowClear={allowClear}
disabled={disabled}
showSearch
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No key aliases found"}
options={options}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
);
};

View file

@ -1,293 +0,0 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { PaginatedModelSelect } from "./PaginatedModelSelect";
const mockFetchNextPage = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
vi.mock("@tanstack/react-pacer/debouncer", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
useDebouncedState: (initial: string) => {
const [value, setValue] = React.useState(initial);
return [value, setValue];
},
};
});
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
const mockUseInfiniteModelInfo = vi.mocked(useInfiniteModelInfo);
const mockPagesWithModels = {
pages: [
{
data: [
{ model_name: "GPT-4", model_info: { id: "model-1" } },
{ model_name: "Claude-3", model_info: { id: "model-2" } },
],
total_count: 2,
current_page: 1,
total_pages: 1,
size: 50,
},
],
};
const mockEmptyPages = {
pages: [{ data: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }],
};
describe("PaginatedModelSelect", () => {
const mockOnChange = vi.fn();
const defaultHookReturn = {
data: mockPagesWithModels,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseInfiniteModelInfo.mockReturnValue(defaultHookReturn as any);
});
it("should render", () => {
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByText("Select a model")).toBeInTheDocument();
});
it("should display custom placeholder when provided", () => {
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} placeholder="Choose model" />);
expect(screen.getByText("Choose model")).toBeInTheDocument();
});
it("should display model options when data is loaded", async () => {
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Claude-3 (model-2)" })).toBeInTheDocument();
});
});
it("should call onChange when user selects a model", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
const visibleOption = await screen.findByTitle("GPT-4 (model-1)");
await user.click(visibleOption);
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith("model-1");
});
});
it("should display selected value when value prop is provided", async () => {
renderWithProviders(<PaginatedModelSelect value="model-1" onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument();
});
});
it("should show loading state when isLoading is true", () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
isLoading: true,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
it("should pass pageSize to useInfiniteModelInfo", () => {
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} pageSize={25} />);
expect(mockUseInfiniteModelInfo).toHaveBeenCalledWith(25, undefined);
});
it("should pass search to useInfiniteModelInfo when user types", async () => {
const user = userEvent.setup();
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await user.click(combobox);
await user.keyboard("gpt");
await waitFor(() => {
expect(mockUseInfiniteModelInfo).toHaveBeenCalledWith(50, "gpt");
});
});
it("should have scroll container for infinite loading when hasNextPage is true", async () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
hasNextPage: true,
isFetchingNextPage: false,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument();
});
const scrollableContainer = document.querySelector(".ant-select-dropdown .rc-virtual-list-holder");
expect(scrollableContainer).toBeInTheDocument();
expect(scrollableContainer).toHaveAttribute("style");
});
it("should deduplicate models with same id across pages", async () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
data: {
pages: [
{
data: [
{ model_name: "GPT-4", model_info: { id: "model-1" } },
{ model_name: "GPT-4 Dupe", model_info: { id: "model-1" } },
],
total_count: 2,
current_page: 1,
total_pages: 1,
size: 50,
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
const model1Options = screen.queryAllByRole("option", { name: /model-1/ });
expect(model1Options.length).toBe(1);
});
});
it("should skip models without model_info id", async () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
data: {
pages: [
{
data: [
{ model_name: "Valid Model", model_info: { id: "valid-id" } },
{ model_name: "No ID", model_info: null },
{ model_name: "Empty ID", model_info: { id: "" } },
],
total_count: 3,
current_page: 1,
total_pages: 1,
size: 50,
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "Valid Model (valid-id)" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "No ID" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Empty ID" })).not.toBeInTheDocument();
});
});
it("should show model ID only when model_name is empty", async () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
data: {
pages: [
{
data: [{ model_name: "", model_info: { id: "id-only" } }],
total_count: 1,
current_page: 1,
total_pages: 1,
size: 50,
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
const combobox = screen.getByRole("combobox");
await userEvent.click(combobox);
await waitFor(() => {
expect(screen.getByRole("option", { name: "id-only" })).toBeInTheDocument();
});
});
it("should respect allowClear prop", () => {
renderWithProviders(<PaginatedModelSelect value="model-1" onChange={mockOnChange} allowClear={false} />);
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
it("should respect disabled prop", () => {
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} disabled />);
const combobox = screen.getByRole("combobox");
expect(combobox.closest(".ant-select")).toHaveClass("ant-select-disabled");
});
it("should not call fetchNextPage when hasNextPage is false", async () => {
mockUseInfiniteModelInfo.mockReturnValue({
...defaultHookReturn,
hasNextPage: false,
} as any);
renderWithProviders(<PaginatedModelSelect onChange={mockOnChange} />);
await userEvent.click(screen.getByRole("combobox"));
await waitFor(() => {
expect(screen.getByRole("option", { name: "GPT-4 (model-1)" })).toBeInTheDocument();
});
expect(mockFetchNextPage).not.toHaveBeenCalled();
});
});

View file

@ -1,140 +0,0 @@
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { Select, Space, Typography } from "antd";
import { useMemo, useState, type UIEvent } from "react";
const { Text } = Typography;
export interface PaginatedModelSelectProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
pageSize?: number;
allowClear?: boolean;
disabled?: boolean;
}
const SCROLL_THRESHOLD = 0.8;
export const PaginatedModelSelect = ({
value,
onChange,
placeholder = "Select a model",
style,
pageSize = 50,
allowClear = true,
disabled = false,
}: PaginatedModelSelectProps) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_WAIT_MS,
});
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteModelInfo(
pageSize,
debouncedSearch || undefined,
);
const options = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: { label: string; value: string; modelName: string; modelId: string }[] = [];
for (const page of data.pages) {
for (const model of page.data) {
const modelId = model.model_info?.id ?? "";
const modelName = model.model_name ?? "";
// Dedupe by id - skip models without id (can't uniquely identify)
if (!modelId || seen.has(modelId)) continue;
seen.add(modelId);
result.push({
label: modelName ? `${modelName} (${modelId})` : modelId,
value: modelId,
modelName,
modelId,
});
}
}
return result;
}, [data]);
const optionRender = (option: { data: { modelName: string; modelId: string; label: string } }) => {
const { modelName, modelId } = option.data;
return (
<>
{modelName ? (
<Space direction="vertical">
<Space direction="horizontal">
<Text strong>Model name:</Text>
<Text ellipsis>{modelName}</Text>
</Space>
<Text ellipsis type="secondary">
Model ID: {modelId}
</Text>
</Space>
) : (
<Text ellipsis type="secondary">
Model ID: {modelId}
</Text>
)}
</>
);
};
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (value: string) => {
setSearchInput(value);
setDebouncedSearch(value);
};
const handleChange = (v: string | string[] | null) => {
const normalized = typeof v === "string" ? v : Array.isArray(v) ? v[0] ?? "" : "";
onChange?.(normalized);
};
return (
<Select
value={value || undefined}
onChange={handleChange}
placeholder={placeholder}
style={{ width: "100%", ...style }}
allowClear={allowClear}
disabled={disabled}
showSearch
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No models found"}
options={options}
optionRender={optionRender}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
);
};

View file

@ -1,9 +0,0 @@
import React from "react";
import TeamDropdown from "./team_dropdown";
import type { FilterOptionCustomComponentProps } from "../molecules/filter";
const FilterTeamDropdown: React.FC<FilterOptionCustomComponentProps> = ({ value, onChange }) => (
<TeamDropdown value={value} onChange={onChange} />
);
export default FilterTeamDropdown;

View file

@ -1,615 +0,0 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import FilterComponent, { FilterOption } from "./filter";
describe("FilterComponent", () => {
const mockOnApplyFilters = vi.fn();
const mockOnResetFilters = vi.fn();
const defaultOptions: FilterOption[] = [
{
name: "teamId",
label: "Team ID",
options: [
{ label: "Team 1", value: "team1" },
{ label: "Team 2", value: "team2" },
],
},
{
name: "status",
label: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
],
},
{
name: "userId",
label: "User ID",
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should render", () => {
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument();
});
it("should display custom button label", () => {
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
buttonLabel="Custom Filters"
/>,
);
expect(screen.getByRole("button", { name: "Custom Filters" })).toBeInTheDocument();
});
it("should toggle filters visibility when filter button is clicked", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
expect(screen.queryByPlaceholderText("Enter User ID...")).not.toBeInTheDocument();
await user.click(filterButton);
await waitFor(() => {
expect(screen.getByPlaceholderText("Enter User ID...")).toBeInTheDocument();
});
await user.click(filterButton);
await waitFor(() => {
expect(screen.queryByPlaceholderText("Enter User ID...")).not.toBeInTheDocument();
});
});
it("should call onResetFilters when reset button is clicked", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
initialValues={{ teamId: "team1", status: "active" }}
/>,
);
const resetButton = screen.getByRole("button", { name: "Reset Filters" });
await user.click(resetButton);
await waitFor(() => {
expect(mockOnResetFilters).toHaveBeenCalledTimes(1);
});
});
it("renders filters in the caller-supplied order", async () => {
const user = userEvent.setup({ delay: null });
const options: FilterOption[] = [
{ name: "model", label: "Model" },
{ name: "teamId", label: "Team ID" },
{ name: "status", label: "Status" },
{ name: "userId", label: "User ID" },
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
const labels = screen.getAllByText(/^(Team ID|Status|User ID|Model)$/);
expect(labels.map((l) => l.textContent)).toEqual(["Model", "Team ID", "Status", "User ID"]);
});
});
it("should handle input filter changes", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
const userIdInput = screen.getByPlaceholderText("Enter User ID...");
await user.type(userIdInput, "user123");
await waitFor(() => {
expect(mockOnApplyFilters).toHaveBeenCalledWith({ userId: "user123" });
});
});
it("should display initial values in filters", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
initialValues={{ teamId: "team1", userId: "user123" }}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement;
expect(userIdInput.value).toBe("user123");
});
});
it("should handle select dropdown filter changes", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
const teamIdLabel = screen.getByText("Team ID");
const teamIdSection = teamIdLabel.closest("div");
const teamIdSelect = within(teamIdSection!).getByRole("combobox");
await user.click(teamIdSelect);
await waitFor(() => {
expect(screen.getByText("Team 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Team 1"));
await waitFor(() => {
expect(mockOnApplyFilters).toHaveBeenCalledWith({ teamId: "team1" });
});
});
it("should handle searchable filter with search function", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([
{ label: "Result 1", value: "result1" },
{ label: "Result 2", value: "result2" },
]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
const modelLabel = screen.getByText("Model");
const modelSection = modelLabel.closest("div");
const modelSelect = within(modelSection!).getByRole("combobox");
await user.click(modelSelect);
await waitFor(() => {
expect(screen.getByText("Result 1")).toBeInTheDocument();
expect(screen.getByText("Result 2")).toBeInTheDocument();
});
});
it("should debounce search input for searchable filters", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
vi.clearAllMocks();
const modelLabel = screen.getByText("Model");
const modelSection = modelLabel.closest("div");
const modelSelect = within(modelSection!).getByRole("combobox");
await user.click(modelSelect);
await user.type(modelSelect, "test");
expect(mockSearchFn).not.toHaveBeenCalled();
await waitFor(
() => {
expect(mockSearchFn).toHaveBeenCalledWith("test");
},
{ timeout: 500 },
);
});
it("should show loading state when searching", async () => {
const user = userEvent.setup({ delay: null });
let resolveSearch: (value: Array<{ label: string; value: string }>) => void;
const mockSearchFn = vi.fn().mockImplementation(
() =>
new Promise<Array<{ label: string; value: string }>>((resolve) => {
resolveSearch = resolve;
}),
);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
const modelLabel = screen.getByText("Model");
const modelSection = modelLabel.closest("div");
const modelSelect = within(modelSection!).getByRole("combobox");
await user.click(modelSelect);
await user.type(modelSelect, "test");
await waitFor(
() => {
expect(screen.getByText("Loading...")).toBeInTheDocument();
},
{ timeout: 500 },
);
resolveSearch!([{ label: "Result", value: "result" }]);
await waitFor(() => {
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
});
it("shows a loading state (not an empty list) while a searchable filter's data is still loading", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
loading: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
await user.click(screen.getByRole("button", { name: "Filters" }));
const modelLabel = screen.getByText("Model");
const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox");
await user.click(modelSelect);
await waitFor(() => {
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
expect(screen.queryByText("No results found")).not.toBeInTheDocument();
// It must not cache an empty initial-options list while the source is still loading.
expect(mockSearchFn).not.toHaveBeenCalled();
});
it("loads initial options once a searchable filter's data finishes loading", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Team A", value: "team-a" }]);
const baseOption: FilterOption = { name: "model", label: "Model", isSearchable: true, searchFn: mockSearchFn };
const { rerender } = renderWithProviders(
<FilterComponent
options={[{ ...baseOption, loading: true }]}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
await user.click(screen.getByRole("button", { name: "Filters" }));
expect(mockSearchFn).not.toHaveBeenCalled();
rerender(
<FilterComponent
options={[{ ...baseOption, loading: false }]}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
});
it("should handle search errors gracefully", async () => {
const user = userEvent.setup({ delay: null });
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const mockSearchFn = vi.fn().mockRejectedValue(new Error("Search failed"));
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
const modelLabel = screen.getByText("Model");
const modelSection = modelLabel.closest("div");
const modelSelect = within(modelSection!).getByRole("combobox");
await user.click(modelSelect);
await user.type(modelSelect, "test");
await waitFor(
() => {
expect(consoleErrorSpy).toHaveBeenCalledWith("Error searching:", expect.any(Error));
expect(screen.getByText("No results found")).toBeInTheDocument();
},
{ timeout: 500 },
);
consoleErrorSpy.mockRestore();
});
it("should load initial options when dropdown opens for searchable filter", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Initial Result", value: "initial" }]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
vi.clearAllMocks();
const modelLabel = screen.getByText("Model");
const modelSection = modelLabel.closest("div");
const modelSelect = within(modelSection!).getByRole("combobox");
await user.click(modelSelect);
await waitFor(() => {
expect(screen.getByText("Initial Result")).toBeInTheDocument();
});
});
it("renders caller-supplied options that match no predefined filter name (LIT-3151)", async () => {
const user = userEvent.setup({ delay: null });
const options: FilterOption[] = [
{
name: "unknownFilter",
label: "Unknown Filter",
},
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
expect(screen.getByText("Unknown Filter")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Enter Unknown Filter...")).toBeInTheDocument();
});
});
it("renders every Tool Policies filter when none match a predefined name (LIT-3151)", async () => {
const user = userEvent.setup({ delay: null });
const options: FilterOption[] = [
{ name: "Input Policy", label: "Input Policy", options: [{ label: "Trusted", value: "trusted" }] },
{ name: "Output Policy", label: "Output Policy", options: [{ label: "Blocked", value: "blocked" }] },
{ name: "Team Name", label: "Team Name", options: [] },
{ name: "Key Name", label: "Key Name", options: [] },
];
renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
await user.click(screen.getByRole("button", { name: "Filters" }));
await waitFor(() => {
const labels = screen.getAllByText(/^(Input Policy|Output Policy|Team Name|Key Name)$/);
expect(labels.map((l) => l.textContent)).toEqual(["Input Policy", "Output Policy", "Team Name", "Key Name"]);
});
});
it("should call onApplyFilters with updated values when multiple filters change", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
const userIdInput = screen.getByPlaceholderText("Enter User ID...");
await user.type(userIdInput, "user123");
await waitFor(() => {
expect(mockOnApplyFilters).toHaveBeenCalledWith({ userId: "user123" });
});
const teamIdLabel = screen.getByText("Team ID");
const teamIdSection = teamIdLabel.closest("div");
const teamIdSelect = within(teamIdSection!).getByRole("combobox");
await user.click(teamIdSelect);
await waitFor(() => {
expect(screen.getByText("Team 1")).toBeInTheDocument();
});
await user.click(screen.getByText("Team 1"));
await waitFor(() => {
expect(mockOnApplyFilters).toHaveBeenCalledWith({
userId: "user123",
teamId: "team1",
});
});
});
it("cancels a pending debounced search when the component unmounts mid-type", async () => {
const user = userEvent.setup({ delay: null });
const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]);
const options: FilterOption[] = [
{
name: "model",
label: "Model",
isSearchable: true,
searchFn: mockSearchFn,
},
];
const { unmount } = renderWithProviders(
<FilterComponent options={options} onApplyFilters={mockOnApplyFilters} onResetFilters={mockOnResetFilters} />,
);
await user.click(screen.getByRole("button", { name: "Filters" }));
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("");
});
vi.clearAllMocks();
const modelLabel = screen.getByText("Model");
const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox");
await user.click(modelSelect);
await user.type(modelSelect, "test");
expect(mockSearchFn).not.toHaveBeenCalled();
unmount();
await new Promise((resolve) => setTimeout(resolve, 400));
expect(mockSearchFn).not.toHaveBeenCalled();
});
it("should reset all filter values when reset button is clicked", async () => {
const user = userEvent.setup({ delay: null });
renderWithProviders(
<FilterComponent
options={defaultOptions}
onApplyFilters={mockOnApplyFilters}
onResetFilters={mockOnResetFilters}
initialValues={{ teamId: "team1", userId: "user123" }}
/>,
);
const filterButton = screen.getByRole("button", { name: "Filters" });
await user.click(filterButton);
await waitFor(() => {
const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement;
expect(userIdInput.value).toBe("user123");
});
const resetButton = screen.getByRole("button", { name: "Reset Filters" });
await user.click(resetButton);
await waitFor(() => {
const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement;
expect(userIdInput.value).toBe("");
});
});
});

View file

@ -1,221 +0,0 @@
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { FilterIcon } from "@heroicons/react/outline";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Button, Input, Select } from "antd";
import React, { useCallback, useEffect, useState } from "react";
export interface FilterOptionCustomComponentProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
allFilters?: { [key: string]: string };
}
export interface FilterOption {
name: string;
label?: string;
isSearchable?: boolean;
searchFn?: (searchText: string) => Promise<Array<{ label: string; value: string }>>;
options?: Array<{ label: string; value: string }>;
customComponent?: React.ComponentType<FilterOptionCustomComponentProps>;
loading?: boolean;
}
interface FilterValues {
[key: string]: string;
}
interface FilterComponentProps {
options: FilterOption[];
onApplyFilters: (filters: FilterValues) => void;
initialValues?: FilterValues;
buttonLabel?: string;
onResetFilters: () => void;
}
const FilterComponent: React.FC<FilterComponentProps> = ({
options,
onApplyFilters,
onResetFilters,
initialValues = {},
buttonLabel = "Filters",
}) => {
const [showFilters, setShowFilters] = useState<boolean>(false);
const [tempValues, setTempValues] = useState<FilterValues>(initialValues);
const [searchOptionsMap, setSearchOptionsMap] = useState<{
[key: string]: Array<{ label: string; value: string }>;
}>({});
const [searchLoadingMap, setSearchLoadingMap] = useState<{
[key: string]: boolean;
}>({});
const [searchInputValueMap, setSearchInputValueMap] = useState<{
[key: string]: string;
}>({});
const [initialOptionsLoaded, setInitialOptionsLoaded] = useState<{
[key: string]: boolean;
}>({});
const debouncedSearch = useDebouncedCallback(
async (value: string, option: FilterOption) => {
if (!option.isSearchable || !option.searchFn) return;
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true }));
try {
const results = await option.searchFn(value);
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results }));
} catch (error) {
console.error("Error searching:", error);
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: [] }));
} finally {
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
}
},
{ wait: DEBOUNCE_WAIT_MS },
);
// Load initial options for searchable filters
const loadInitialOptions = useCallback(
async (option: FilterOption) => {
if (!option.isSearchable || !option.searchFn || option.loading || initialOptionsLoaded[option.name]) return;
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true }));
setInitialOptionsLoaded((prev) => ({ ...prev, [option.name]: true }));
try {
// Load initial options with empty search to get some default results
const results = await option.searchFn("");
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results }));
} catch (error) {
console.error("Error loading initial options:", error);
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: [] }));
} finally {
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
}
},
[initialOptionsLoaded],
);
// Load initial options when filters are shown
useEffect(() => {
if (showFilters) {
options.forEach((option) => {
if (option.isSearchable && !initialOptionsLoaded[option.name]) {
loadInitialOptions(option);
}
});
}
}, [showFilters, options, loadInitialOptions, initialOptionsLoaded]);
const handleFilterChange = (name: string, value: string) => {
const newValues = {
...tempValues,
[name]: value,
};
setTempValues(newValues);
onApplyFilters(newValues);
};
const resetFilters = () => {
const emptyValues: FilterValues = {};
options.forEach((option) => {
emptyValues[option.name] = "";
});
setTempValues(emptyValues);
onResetFilters();
};
// Handle dropdown open to load initial options
const handleDropdownVisibleChange = (open: boolean, option: FilterOption) => {
if (open && option.isSearchable && !initialOptionsLoaded[option.name]) {
loadInitialOptions(option);
}
};
return (
<div className="w-full">
<div className="flex items-center gap-2 mb-6">
<Button
icon={<FilterIcon className="h-4 w-4" />}
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
{buttonLabel}
</Button>
<Button onClick={resetFilters}>Reset Filters</Button>
</div>
{showFilters && (
<div className="grid grid-cols-3 gap-x-6 gap-y-4 mb-6">
{options.map((option) => {
const isOptionLoading = searchLoadingMap[option.name] || option.loading;
return (
<div key={option.name} className="flex flex-col gap-2">
<label className="text-sm text-gray-600">{option.label || option.name}</label>
{option.isSearchable ? (
<Select
showSearch
className="w-full"
placeholder={`Search ${option.label || option.name}...`}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
onOpenChange={(open) => handleDropdownVisibleChange(open, option)}
onSearch={(value) => {
setSearchInputValueMap((prev) => ({
...prev,
[option.name]: value,
}));
if (option.searchFn) {
debouncedSearch(value, option);
}
}}
filterOption={false}
loading={isOptionLoading}
options={searchOptionsMap[option.name] || []}
allowClear
notFoundContent={isOptionLoading ? "Loading..." : "No results found"}
/>
) : option.options ? (
<Select
className="w-full"
placeholder={`Select ${option.label || option.name}...`}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
allowClear
>
{option.options.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
) : option.customComponent ? (
(() => {
const CustomComponent = option.customComponent;
return (
<CustomComponent
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value ?? "")}
placeholder={`Select ${option.label || option.name}...`}
allFilters={tempValues}
/>
);
})()
) : (
<Input
className="w-full"
placeholder={`Enter ${option.label || option.name}...`}
value={tempValues[option.name] || ""}
onChange={(e) => handleFilterChange(option.name, e.target.value)}
allowClear
/>
)}
</div>
);
})}
</div>
)}
</div>
);
};
export default FilterComponent;

View file

@ -0,0 +1,165 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { PaginatedSearchSelect } from "./PaginatedSearchSelect";
import type { SearchSelectOption } from "./SearchSelect";
const OPTIONS: SearchSelectOption[] = [
{ label: "alias-alpha", value: "alias-alpha" },
{ label: "alias-beta", value: "alias-beta" },
{ label: "gamma-key", value: "gamma-key" },
];
function renderSelect(overrides: Partial<React.ComponentProps<typeof PaginatedSearchSelect>> = {}) {
const props: React.ComponentProps<typeof PaginatedSearchSelect> = {
options: OPTIONS,
onValueChange: vi.fn(),
onSearchChange: vi.fn(),
onLoadMore: vi.fn(),
...overrides,
};
render(<PaginatedSearchSelect {...props} />);
return props;
}
function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true });
Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true });
Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true });
}
describe("PaginatedSearchSelect", () => {
it("reports the typed query to the server instead of filtering locally", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange });
const input = screen.getByRole("combobox");
await user.click(input);
await user.type(input, "gamma");
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));
expect(await screen.findByText("alias-alpha")).toBeInTheDocument();
});
it("does not re-query the server when an item is selected", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
function Controlled() {
const [value, setValue] = useState("");
return (
<PaginatedSearchSelect
options={OPTIONS}
value={value}
onValueChange={setValue}
onSearchChange={onSearchChange}
onLoadMore={vi.fn()}
/>
);
}
render(<Controlled />);
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("alias-beta"));
expect(screen.getByRole("combobox")).toHaveValue("alias-beta");
await new Promise((resolve) => setTimeout(resolve, 400));
expect(onSearchChange).not.toHaveBeenCalled();
});
it("still reports a cleared input so the unfiltered page comes back", async () => {
const user = userEvent.setup();
const onSearchChange = vi.fn();
renderSelect({ onSearchChange, value: "alias-alpha" });
await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement);
await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith(""));
});
it("requests the next page once the list is scrolled near the bottom", async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
renderSelect({ onLoadMore, hasNextPage: true });
await user.click(screen.getByRole("combobox"));
const list = await screen.findByTestId("paginated-search-select-list");
setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).not.toHaveBeenCalled();
setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
it("does not request more pages when there is no next page or one is already in flight", async () => {
const user = userEvent.setup();
const onLoadMore = vi.fn();
const { unmount } = render(
<PaginatedSearchSelect
options={OPTIONS}
onValueChange={vi.fn()}
onSearchChange={vi.fn()}
onLoadMore={onLoadMore}
hasNextPage={false}
/>,
);
await user.click(screen.getByRole("combobox"));
let list = await screen.findByTestId("paginated-search-select-list");
setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).not.toHaveBeenCalled();
unmount();
renderSelect({ onLoadMore, hasNextPage: true, isFetchingNextPage: true });
await user.click(screen.getByRole("combobox"));
list = await screen.findByTestId("paginated-search-select-list");
setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 });
fireEvent.scroll(list);
expect(onLoadMore).not.toHaveBeenCalled();
});
it("keeps showing a selected value that is absent from the current page of options", () => {
renderSelect({ options: [], value: "alias-from-an-earlier-page" });
expect(screen.getByRole("combobox")).toHaveValue("alias-from-an-earlier-page");
});
it("reports the selected option's value", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
renderSelect({ onValueChange });
await user.click(screen.getByRole("combobox"));
await user.click(await screen.findByText("alias-beta"));
expect(onValueChange).toHaveBeenCalledWith("alias-beta");
});
it("surfaces loading and fetching-more affordances", async () => {
const user = userEvent.setup();
const { unmount } = render(
<PaginatedSearchSelect
options={[]}
onValueChange={vi.fn()}
onSearchChange={vi.fn()}
onLoadMore={vi.fn()}
isLoading
loadingText="Loading key aliases…"
/>,
);
await user.click(screen.getByRole("combobox"));
expect(await screen.findByText("Loading key aliases…")).toBeInTheDocument();
unmount();
renderSelect({ isFetchingNextPage: true });
await user.click(screen.getByRole("combobox"));
expect(await screen.findByTestId("paginated-search-select-loading-more")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,119 @@
"use client";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { Loader2 } from "lucide-react";
import { useMemo, type UIEvent } from "react";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import type { SearchSelectOption } from "./SearchSelect";
const SCROLL_THRESHOLD = 0.8;
const SEARCH_REASONS: ReadonlySet<string> = new Set(["input-change", "input-clear", "clear-press"]);
interface PaginatedSearchSelectProps {
options: SearchSelectOption[];
value?: string;
onValueChange: (value: string) => void;
onSearchChange: (query: string) => void;
onLoadMore: () => void;
hasNextPage?: boolean;
isLoading?: boolean;
isFetchingNextPage?: boolean;
placeholder?: string;
emptyText?: string;
loadingText?: string;
disabled?: boolean;
className?: string;
}
export function PaginatedSearchSelect({
options,
value,
onValueChange,
onSearchChange,
onLoadMore,
hasNextPage = false,
isLoading = false,
isFetchingNextPage = false,
placeholder = "Search…",
emptyText = "No results",
loadingText = "Loading…",
disabled = false,
className,
}: PaginatedSearchSelectProps) {
const selected = useMemo<SearchSelectOption | null>(() => {
if (value === undefined || value === "") return null;
return options.find((option) => option.value === value) ?? { label: value, value };
}, [options, value]);
const items = useMemo<SearchSelectOption[]>(() => {
if (selected === null) return options;
if (options.some((option) => option.value === selected.value)) return options;
return [selected, ...options];
}, [options, selected]);
const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS });
const handleInputValueChange = (next: string, reason: string) => {
if (!SEARCH_REASONS.has(reason)) return;
debouncedSearch(next);
};
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (target.scrollHeight === 0) return;
const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
onLoadMore();
}
};
return (
<Combobox
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onValueChange(item?.value ?? "")}
onInputValueChange={(next, eventDetails) => handleInputValueChange(next, eventDetails.reason)}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
itemToStringLabel={(item: SearchSelectOption) => item.label}
filter={null}
disabled={disabled}
>
<ComboboxInput
placeholder={placeholder}
showClear={value !== undefined && value !== ""}
className={`w-full ${className ?? ""}`}
/>
<ComboboxContent>
<ComboboxEmpty>{isLoading ? loadingText : emptyText}</ComboboxEmpty>
<ComboboxList onScroll={handleScroll} data-testid="paginated-search-select-list">
{(item: SearchSelectOption) => (
<ComboboxItem key={item.value} value={item}>
<span className="flex min-w-0 flex-col">
<span className="truncate">{item.label}</span>
{item.sublabel != null && item.sublabel !== "" && (
<span className="truncate text-xs text-muted-foreground">{item.sublabel}</span>
)}
</span>
</ComboboxItem>
)}
</ComboboxList>
{isFetchingNextPage && (
<div className="flex justify-center py-2" data-testid="paginated-search-select-loading-more">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</ComboboxContent>
</Combobox>
);
}

View file

@ -1,14 +1,18 @@
"use client";
import moment from "moment";
import { useEffect, useRef, useState } from "react";
import { SyncOutlined } from "@ant-design/icons";
import { Button, Switch } from "antd";
import { CalendarDays } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { QUICK_SELECT_OPTIONS } from "./constants";
import { getTimeRangeDisplay } from "./logs_utils";
import type { PaginatedResponse } from "./log_filter_logic";
interface LogsTableToolbarProps {
searchTerm: string;
onSearchChange: (value: string) => void;
startTime: string;
onStartTimeChange: (value: string) => void;
endTime: string;
@ -19,18 +23,11 @@ interface LogsTableToolbarProps {
onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void;
isLiveTail: boolean;
onIsLiveTailChange: (value: boolean) => void;
currentPage: number;
onCurrentPageChange: (updater: number | ((prev: number) => number)) => void;
pageSize: number;
isLoading: boolean;
isButtonLoading: boolean;
onRefetch: () => void;
filteredLogs: PaginatedResponse;
onResetToFirstPage: () => void;
onResetFilters: () => void;
}
export function LogsTableToolbar({
searchTerm,
onSearchChange,
startTime,
onStartTimeChange,
endTime,
@ -41,26 +38,23 @@ export function LogsTableToolbar({
onSelectedTimeIntervalChange,
isLiveTail,
onIsLiveTailChange,
currentPage,
onCurrentPageChange,
pageSize,
isLoading,
isButtonLoading,
onRefetch,
filteredLogs,
onResetToFirstPage,
onResetFilters,
}: LogsTableToolbarProps) {
const [quickSelectOpen, setQuickSelectOpen] = useState(false);
const quickSelectRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) {
setQuickSelectOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const applyQuickSelect = (option: { label: string; value: number; unit: string }) => {
onResetToFirstPage();
onEndTimeChange(moment().format("YYYY-MM-DDTHH:mm"));
onStartTimeChange(
moment()
.subtract(option.value, option.unit as moment.unitOfTime.DurationConstructor)
.format("YYYY-MM-DDTHH:mm"),
);
onSelectedTimeIntervalChange({ value: option.value, unit: option.unit });
onIsCustomDateChange(false);
setQuickSelectOpen(false);
};
const selectedOption = QUICK_SELECT_OPTIONS.find(
(option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit,
@ -68,178 +62,83 @@ export function LogsTableToolbar({
const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label;
return (
<>
<div className="border-b px-6 py-4 w-full max-w-full box-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
<div className="flex flex-wrap items-center gap-3 w-full max-w-full box-border">
<div className="relative w-64 min-w-0 shrink-0">
<input
type="text"
placeholder="Search by Request ID"
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
<svg
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<div className="flex items-center gap-2 min-w-0 shrink">
<div className="relative z-50" ref={quickSelectRef}>
<button
onClick={() => setQuickSelectOpen(!quickSelectOpen)}
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{displayLabel}
</button>
{quickSelectOpen && (
<div className="absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50">
<div className="space-y-1">
{QUICK_SELECT_OPTIONS.map((option) => (
<button
key={option.label}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${displayLabel === option.label ? "bg-blue-50 text-blue-600" : ""}`}
onClick={() => {
onCurrentPageChange(1);
onEndTimeChange(moment().format("YYYY-MM-DDTHH:mm"));
onStartTimeChange(
moment()
.subtract(option.value, option.unit as any)
.format("YYYY-MM-DDTHH:mm"),
);
onSelectedTimeIntervalChange({ value: option.value, unit: option.unit });
onIsCustomDateChange(false);
setQuickSelectOpen(false);
}}
>
{option.label}
</button>
))}
<div className="border-t my-2" />
<button
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${isCustomDate ? "bg-blue-50 text-blue-600" : ""}`}
onClick={() => onIsCustomDateChange(!isCustomDate)}
>
Custom Range
</button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Live Tail</span>
<Switch checked={isLiveTail} defaultChecked={true} onChange={onIsLiveTailChange} />
</div>
<div className="flex flex-wrap items-center gap-2">
<Popover open={quickSelectOpen} onOpenChange={setQuickSelectOpen}>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="gap-2">
<CalendarDays className="size-4" />
{displayLabel}
</Button>
}
/>
<PopoverContent align="start" className="w-64 p-2">
<div className="space-y-1">
{QUICK_SELECT_OPTIONS.map((option) => (
<Button
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={onRefetch}
disabled={isButtonLoading}
title="Fetch data"
key={option.label}
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => applyQuickSelect(option)}
>
{isButtonLoading ? "Fetching" : "Fetch"}
{option.label}
</Button>
</div>
{isCustomDate && (
<div className="flex items-center gap-2">
<div>
<input
type="datetime-local"
value={startTime}
onChange={(e) => {
onStartTimeChange(e.target.value);
onCurrentPageChange(1);
}}
className="px-3 py-2 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<span className="text-gray-500">to</span>
<div>
<input
type="datetime-local"
value={endTime}
onChange={(e) => {
onEndTimeChange(e.target.value);
onCurrentPageChange(1);
}}
className="px-3 py-2 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
)}
</div>
<div className="flex items-center space-x-4">
<span
className="text-sm text-gray-700 whitespace-nowrap"
title={
!isLoading && filteredLogs?.total_is_capped
? `Showing the first ${filteredLogs.total.toLocaleString()} results. Narrow the date range or add filters to see more.`
: undefined
}
))}
<div className="my-2 border-t" />
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => onIsCustomDateChange(!isCustomDate)}
>
Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "}
{isLoading ? "..." : filteredLogs ? Math.min(currentPage * pageSize, filteredLogs.total) : 0} of{" "}
{isLoading ? "..." : filteredLogs ? filteredLogs.total : 0}
{!isLoading && filteredLogs?.total_is_capped ? "+" : ""} results
</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-700 min-w-[90px]">
Page {isLoading ? "..." : currentPage} of{" "}
{isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1}
{!isLoading && filteredLogs?.total_is_capped ? "+" : ""}
</span>
<button
onClick={() => onCurrentPageChange((p: number) => Math.max(1, p - 1))}
disabled={isLoading || currentPage === 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => onCurrentPageChange((p: number) => Math.min(filteredLogs.total_pages || 1, p + 1))}
disabled={isLoading || currentPage === (filteredLogs.total_pages || 1)}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
Custom Range
</Button>
</div>
</div>
</div>
{isLiveTail && currentPage === 1 && (
<div className="mb-4 px-4 py-2 bg-green-50 border border-green-200 rounded-md flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
</div>
<button onClick={() => onIsLiveTailChange(false)} className="text-sm text-green-600 hover:text-green-800">
Stop
</button>
</PopoverContent>
</Popover>
{isCustomDate && (
<div className="flex items-center gap-2">
<Input
type="datetime-local"
className="w-auto"
value={startTime}
onChange={(event) => {
onStartTimeChange(event.target.value);
onResetToFirstPage();
}}
/>
<span className="text-sm text-muted-foreground">to</span>
<Input
type="datetime-local"
className="w-auto"
value={endTime}
onChange={(event) => {
onEndTimeChange(event.target.value);
onResetToFirstPage();
}}
/>
</div>
)}
</>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Live Tail</span>
<Switch checked={isLiveTail} onCheckedChange={onIsLiveTailChange} aria-label="Live Tail" />
</div>
<Button variant="outline" size="sm" onClick={onResetFilters}>
Reset Filters
</Button>
</div>
);
}
export function LiveTailBanner({ onStop }: { onStop: () => void }) {
return (
<div className="mb-4 flex items-center justify-between rounded-md border border-green-200 bg-green-50 px-4 py-2">
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
<button type="button" onClick={onStop} className="text-sm text-green-600 hover:text-green-800">
Stop
</button>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import { LOG_FILTER_IDS } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({
useInfiniteKeyAliases: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useInfiniteModelInfo: vi.fn(),
}));
vi.mock("../networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("../networking")>();
return { ...actual, allEndUsersCall: vi.fn().mockResolvedValue([]) };
});
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
const emptyInfiniteQuery = {
data: { pages: [], pageParams: [] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
};
function renderFilters(filters: Record<string, string> = {}) {
const set = vi.fn();
renderWithProviders(
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} accessToken="test-token" />,
);
return { set };
}
describe("RequestLogsFilters", () => {
beforeEach(() => {
vi.clearAllMocks();
testQueryClient.clear();
vi.mocked(useInfiniteKeyAliases).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteKeyAliases>,
);
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteModelInfo>,
);
});
it("renders every backend-supported filter field", async () => {
renderFilters();
for (const label of [
"Team ID",
"Status",
"Key Alias",
"End User",
"Error Code",
"Error Message",
"Key Hash",
"Session ID",
"Model",
"Public model / search tool",
]) {
expect(await screen.findByText(label)).toBeInTheDocument();
}
});
it("scopes the Key Alias lookup to the selected team", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });
await waitFor(() => expect(useInfiniteKeyAliases).toHaveBeenCalled());
expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, "team-42");
});
it("leaves the Key Alias lookup unscoped when no team is selected", async () => {
renderFilters();
await waitFor(() => expect(useInfiniteKeyAliases).toHaveBeenCalled());
expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, undefined);
});
it("does not leak the team scope into the Model lookup", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });
await waitFor(() => expect(useInfiniteModelInfo).toHaveBeenCalled());
expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined);
});
});

View file

@ -0,0 +1,319 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases";
import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { DataTableFilterField } from "@/components/shared/DataTable";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { Team } from "../key_team_helpers/key_list";
import { allEndUsersCall } from "../networking";
import { ERROR_CODE_OPTIONS } from "./constants";
import { LOG_FILTER_IDS } from "./log_filter_logic";
const ALL_VALUE = "all";
const PAGE_SIZE = 50;
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
const emptyToUndefined = (value: string): string | undefined => (value === "" ? undefined : value);
function TeamFilterField({
value,
onChange,
teams,
}: {
value: string;
onChange: (value: string | undefined) => void;
teams: Team[];
}) {
const options = useMemo<SearchSelectOption[]>(
() =>
teams.map((team) => ({
label: team.team_alias || team.team_id,
value: team.team_id,
sublabel: team.team_id,
})),
[teams],
);
return (
<DataTableFilterField label="Team ID">
<SearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
placeholder="Search or select a team"
emptyText="No teams found"
/>
</DataTableFilterField>
);
}
function KeyAliasFilterField({
value,
onChange,
teamId,
}: {
value: string;
onChange: (value: string | undefined) => void;
teamId: string;
}) {
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases(
PAGE_SIZE,
emptyToUndefined(search),
emptyToUndefined(teamId),
);
const options = useMemo<SearchSelectOption[]>(() => {
const seen = new Set<string>();
return (data?.pages ?? []).flatMap((page) =>
page.aliases.flatMap((alias) => {
if (!alias || seen.has(alias)) return [];
seen.add(alias);
return [{ label: alias, value: alias }];
}),
);
}, [data]);
return (
<DataTableFilterField label="Key Alias">
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search a key alias"
emptyText="No key aliases found"
/>
</DataTableFilterField>
);
}
function ModelFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) {
const [search, setSearch] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteModelInfo(
PAGE_SIZE,
emptyToUndefined(search),
);
const options = useMemo<SearchSelectOption[]>(() => {
const seen = new Set<string>();
return (data?.pages ?? []).flatMap((page) =>
page.data.flatMap((model) => {
const modelId = model.model_info?.id ?? "";
const modelName = model.model_name ?? "";
if (!modelId || seen.has(modelId)) return [];
seen.add(modelId);
return [{ label: modelName || modelId, value: modelId, sublabel: `Model ID: ${modelId}` }];
}),
);
}, [data]);
return (
<DataTableFilterField label="Model">
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
isLoading={isLoading}
isFetchingNextPage={isFetchingNextPage}
placeholder="Search a model"
emptyText="No models found"
/>
</DataTableFilterField>
);
}
function EndUserFilterField({
value,
onChange,
accessToken,
}: {
value: string;
onChange: (value: string | undefined) => void;
accessToken: string;
}) {
const { data } = useQuery<string[]>({
queryKey: ["logFilterEndUsers", accessToken],
queryFn: async () => {
const endUsers = await allEndUsersCall(accessToken);
return (endUsers ?? []).flatMap((endUser: { user_id?: string }) =>
typeof endUser.user_id === "string" ? [endUser.user_id] : [],
);
},
enabled: accessToken !== "",
});
const options = useMemo<SearchSelectOption[]>(
() => (data ?? []).map((userId) => ({ label: userId, value: userId })),
[data],
);
return (
<DataTableFilterField label="End User">
<SearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(emptyToUndefined(next))}
placeholder="Search an end user"
emptyText="No end users found"
/>
</DataTableFilterField>
);
}
function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) {
const [query, setQuery] = useState("");
const options = useMemo<SearchSelectOption[]>(() => {
const trimmed = query.trim();
const lowered = trimmed.toLowerCase();
const matches = ERROR_CODE_OPTIONS.filter((option) => option.label.toLowerCase().includes(lowered));
if (trimmed === "" || ERROR_CODE_OPTIONS.some((option) => option.value === trimmed)) return matches;
return [...matches, { label: `Use custom code: ${trimmed}`, value: trimmed }];
}, [query]);
const selected = useMemo<SearchSelectOption | null>(() => {
if (value === "") return null;
return ERROR_CODE_OPTIONS.find((option) => option.value === value) ?? { label: value, value };
}, [value]);
const items = useMemo<SearchSelectOption[]>(() => {
if (selected === null) return options;
if (options.some((option) => option.value === selected.value)) return options;
return [selected, ...options];
}, [options, selected]);
return (
<DataTableFilterField label="Error Code">
<Combobox
items={items}
value={selected}
onValueChange={(item: SearchSelectOption | null) => onChange(emptyToUndefined(item?.value ?? ""))}
onInputValueChange={setQuery}
isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
itemToStringLabel={(item: SearchSelectOption) => item.label}
filter={null}
>
<ComboboxInput placeholder="Select or type an error code" showClear={value !== ""} className="w-full" />
<ComboboxContent>
<ComboboxEmpty>No error codes found</ComboboxEmpty>
<ComboboxList data-testid="error-code-filter-list">
{(item: SearchSelectOption) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</DataTableFilterField>
);
}
interface RequestLogsFiltersProps {
get: (columnId: string) => unknown;
set: (columnId: string, value: unknown) => void;
teams: Team[];
accessToken: string;
}
export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogsFiltersProps) {
const valueOf = (id: string): string => asString(get(id));
const setter = (id: string) => (next: string | undefined) => set(id, next);
return (
<>
<TeamFilterField
value={valueOf(LOG_FILTER_IDS.TEAM_ID)}
onChange={setter(LOG_FILTER_IDS.TEAM_ID)}
teams={teams}
/>
<DataTableFilterField label="Status">
<Select
value={valueOf(LOG_FILTER_IDS.STATUS) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.STATUS)}
onValueChange={(next) => set(LOG_FILTER_IDS.STATUS, next === null || next === ALL_VALUE ? undefined : next)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>All Statuses</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
</SelectContent>
</Select>
</DataTableFilterField>
<KeyAliasFilterField
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}
teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)}
/>
<EndUserFilterField
value={valueOf(LOG_FILTER_IDS.END_USER)}
onChange={setter(LOG_FILTER_IDS.END_USER)}
accessToken={accessToken}
/>
<ErrorCodeFilterField value={valueOf(LOG_FILTER_IDS.ERROR_CODE)} onChange={setter(LOG_FILTER_IDS.ERROR_CODE)} />
<DataTableFilterField label="Error Message">
<Input
value={valueOf(LOG_FILTER_IDS.ERROR_MESSAGE)}
onChange={(event) => set(LOG_FILTER_IDS.ERROR_MESSAGE, emptyToUndefined(event.target.value))}
placeholder="Enter error message…"
/>
</DataTableFilterField>
<DataTableFilterField label="Key Hash">
<Input
value={valueOf(LOG_FILTER_IDS.KEY_HASH)}
onChange={(event) => set(LOG_FILTER_IDS.KEY_HASH, emptyToUndefined(event.target.value))}
placeholder="Enter key hash…"
/>
</DataTableFilterField>
<DataTableFilterField label="Session ID">
<Input
value={valueOf(LOG_FILTER_IDS.SESSION_ID)}
onChange={(event) => set(LOG_FILTER_IDS.SESSION_ID, emptyToUndefined(event.target.value))}
placeholder="Enter session ID…"
/>
</DataTableFilterField>
<ModelFilterField value={valueOf(LOG_FILTER_IDS.MODEL_ID)} onChange={setter(LOG_FILTER_IDS.MODEL_ID)} />
<DataTableFilterField label="Public model / search tool">
<Input
value={valueOf(LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL)}
onChange={(event) => set(LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, emptyToUndefined(event.target.value))}
placeholder="Enter public model or search tool…"
/>
</DataTableFilterField>
</>
);
}

View file

@ -0,0 +1,202 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import type { LogEntry } from "./columns";
import RequestLogsPanel from "./RequestLogsPanel";
vi.mock("../networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("../networking")>();
return {
...actual,
uiSpendLogsCall: vi.fn(),
keyInfoV1Call: vi.fn().mockResolvedValue({ info: {} }),
allEndUsersCall: vi.fn().mockResolvedValue([]),
};
});
vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
vi.mock("./LogDetailsDrawer", () => ({
LogDetailsDrawer: function LogDetailsDrawerMock({ open }: { open: boolean }) {
return <div data-testid="log-details-drawer">{open ? "open" : "closed"}</div>;
},
}));
import { uiSpendLogsCall } from "../networking";
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4o",
model_id: "model-1",
call_type: "acompletion",
spend: 0.01,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:14Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
const respondWith = (data: LogEntry[]) =>
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data,
total: data.length,
page: 1,
page_size: 50,
total_pages: 1,
});
const defaultProps = {
accessToken: "test-token",
token: "test-token",
userRole: "Admin",
userID: "user-1",
isActive: true,
};
const row = (requestId: string) => document.querySelector(`[data-row-id="${requestId}"]`);
const lastCall = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0];
describe("RequestLogsPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
testQueryClient.clear();
respondWith([]);
});
describe("multi-call session collapsing", () => {
const sessionRows = [
logEntry({ request_id: "req-mcp", call_type: "call_mcp_tool", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }),
];
it("collapses a multi-call session to a single representative row", async () => {
respondWith(sessionRows);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-mcp") ?? row("req-llm") ?? row("req-llm-2")).not.toBeNull());
const rendered = ["req-mcp", "req-llm", "req-llm-2"].filter((id) => row(id) !== null);
expect(rendered).toHaveLength(1);
});
it("prefers an LLM call over an MCP call as the session's representative", async () => {
respondWith(sessionRows);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-llm")).not.toBeNull());
expect(row("req-mcp")).toBeNull();
});
it("shows the session's call count and composition on the representative row", async () => {
respondWith(sessionRows);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-llm")).not.toBeNull());
expect(within(row("req-llm") as HTMLElement).getByText("3")).toBeInTheDocument();
});
it("leaves single-call rows untouched", async () => {
respondWith([
logEntry({ request_id: "req-solo-a", session_id: "sess-a", session_total_count: 1 }),
logEntry({ request_id: "req-solo-b" }),
]);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-solo-a")).not.toBeNull());
expect(row("req-solo-b")).not.toBeNull();
});
});
describe("client-side search", () => {
it("narrows the visible rows without refetching", async () => {
const user = userEvent.setup();
respondWith([
logEntry({ request_id: "req-alpha", model: "gpt-4o" }),
logEntry({ request_id: "req-beta", model: "claude-opus" }),
]);
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(row("req-alpha")).not.toBeNull());
const callsBefore = vi.mocked(uiSpendLogsCall).mock.calls.length;
await user.type(screen.getByTestId("datatable-search"), "alpha");
await waitFor(() => expect(row("req-beta")).toBeNull());
expect(row("req-alpha")).not.toBeNull();
expect(vi.mocked(uiSpendLogsCall).mock.calls.length).toBe(callsBefore);
});
});
describe("time range", () => {
it("requests a ~15 minute window when Last 15 Minutes is picked", async () => {
const user = userEvent.setup();
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" }));
await waitFor(() => {
const call = lastCall();
if (!call) throw new Error("no call");
const diff = moment
.utc(call.end_date, "YYYY-MM-DD HH:mm:ss")
.diff(moment.utc(call.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds");
expect(diff).toBeGreaterThanOrEqual(15 * 60);
expect(diff).toBeLessThanOrEqual(16 * 60);
});
});
it("restores the default 24 hour window when filters are reset", async () => {
const user = userEvent.setup();
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" }));
await waitFor(() => expect(screen.getByRole("button", { name: /Last 15 Minutes/i })).toBeInTheDocument());
await user.click(screen.getByRole("button", { name: "Reset Filters" }));
expect(await screen.findByRole("button", { name: /Last 24 Hours/i })).toBeInTheDocument();
await user.click(screen.getByTestId("datatable-refresh"));
await waitFor(() => {
const call = lastCall();
if (!call) throw new Error("no call");
const diff = moment
.utc(call.end_date, "YYYY-MM-DD HH:mm:ss")
.diff(moment.utc(call.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds");
expect(diff).toBeGreaterThanOrEqual(24 * 60 * 60);
});
});
});
describe("live tail", () => {
it("shows the auto-refresh banner on the first page and hides it once stopped", async () => {
const user = userEvent.setup();
renderWithProviders(<RequestLogsPanel {...defaultProps} />);
expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Stop" }));
expect(screen.queryByText("Auto-refreshing every 15 seconds")).toBeNull();
});
});
});

View file

@ -0,0 +1,264 @@
"use client";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import moment from "moment";
import { useCallback, useEffect, useMemo, useState } from "react";
import { internalUserRoles } from "../../utils/roles";
import type { KeyResponse } from "../key_team_helpers/key_list";
import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import type { LogEntry } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { DEFAULT_LOGS_SORTING, useLogFilterLogic } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar";
import { RequestLogsTable } from "./RequestLogsTable";
const PAGE_SIZE = 50;
const DEFAULT_INTERVAL = { value: 24, unit: "hours" };
interface RequestLogsPanelProps {
accessToken: string;
token: string;
userRole: string;
userID: string;
isActive: boolean;
}
interface SessionComposition {
llm: number;
agent: number;
mcp: number;
}
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const [searchTerm, setSearchTerm] = useState("");
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [startTime, setStartTime] = useState<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
const [endTime, setEndTime] = useState<string>(moment().format("YYYY-MM-DDTHH:mm"));
const [isCustomDate, setIsCustomDate] = useState(false);
const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>(DEFAULT_INTERVAL);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
const storedValue = sessionStorage.getItem("isLiveTail");
return storedValue !== null ? JSON.parse(storedValue) : true;
});
useEffect(() => {
sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail));
}, [isLiveTail]);
const filterByCurrentUser = internalUserRoles.includes(userRole);
const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
accessToken,
token,
userRole,
userID,
columnFilters,
filterByCurrentUser,
activeTab: isActive ? "request logs" : "inactive",
isLiveTail,
startTime,
endTime,
pagination,
isCustomDate,
sorting,
});
const keyInfoQueryOptions: UseQueryOptions<KeyResponse | null> = {
queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken],
queryFn: async () => {
if (selectedKeyIdInfoView === null) return null;
const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView);
return {
...keyData["info"],
token: selectedKeyIdInfoView,
api_key: selectedKeyIdInfoView,
};
},
enabled: selectedKeyIdInfoView !== null,
};
const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions);
const rows = useMemo<LogEntry[]>(() => {
const searchedLogs = filteredLogs.data.filter((log) => {
if (!searchTerm) return true;
return (
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user !== undefined && log.user.includes(searchTerm))
);
});
const sessionCompositionById = searchedLogs.reduce<Record<string, SessionComposition>>((acc, log) => {
if (!log.session_id) return acc;
if (!acc[log.session_id]) {
acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 };
}
if (MCP_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].mcp += 1;
} else if (AGENT_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].agent += 1;
} else {
acc[log.session_id].llm += 1;
}
return acc;
}, {});
const sessionRepresentativeMap = new Map<string, { requestId: string; isMcp: boolean }>();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const existing = sessionRepresentativeMap.get(log.session_id);
if (!existing || (existing.isMcp && !isMcp)) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
}
}
return searchedLogs
.map((log) => {
const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined;
return {
...log,
session_llm_count: sessionComposition?.llm ?? undefined,
session_mcp_count: sessionComposition?.mcp ?? undefined,
session_agent_count: sessionComposition?.agent ?? undefined,
};
})
.filter((log) => {
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
});
}, [filteredLogs.data, searchTerm]);
const handleSortingChange = useCallback<OnChangeFn<SortingState>>((updaterOrValue) => {
setSorting(updaterOrValue);
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const resetToFirstPage = useCallback(() => {
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleResetFilters = useCallback(() => {
setColumnFilters([]);
setSearchTerm("");
setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
setIsCustomDate(false);
setSelectedTimeInterval(DEFAULT_INTERVAL);
resetToFirstPage();
}, [resetToFirstPage]);
const handleRowClick = useCallback((log: LogEntry) => {
const isMultiCallSession = log.session_id !== undefined && (log.session_total_count || 1) > 1;
setSelectedSessionId(isMultiCallSession ? log.session_id ?? null : null);
setSelectedLog(log);
setIsDrawerOpen(true);
}, []);
const handleSessionClick = useCallback(
(sessionId: string) => {
if (!sessionId) return;
const log = rows.find((candidate) => candidate.session_id === sessionId) ?? null;
setSelectedSessionId(sessionId);
setSelectedLog(log);
setIsDrawerOpen(true);
},
[rows],
);
const handleKeyHashClick = useCallback((keyHash: string) => {
setSelectedKeyIdInfoView(keyHash);
}, []);
if (selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView) {
return (
<KeyInfoView
keyId={selectedKeyIdInfoView}
keyData={selectedKeyInfo}
teams={allTeams ?? []}
onClose={() => setSelectedKeyIdInfoView(null)}
backButtonText="Back to Logs"
/>
);
}
return (
<>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
</div>
{isLiveTail && pagination.pageIndex === 0 && <LiveTailBanner onStop={() => setIsLiveTail(false)} />}
<RequestLogsTable
data={rows}
rowCount={filteredLogs.total}
isLoading={logsQuery.isLoading}
isRefreshing={logsQuery.isFetching}
pagination={pagination}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={handleSortingChange}
columnFilters={columnFilters}
onColumnFiltersChange={handleColumnFiltersChange}
searchValue={searchTerm}
onSearchChange={setSearchTerm}
onRefresh={() => void logsQuery.refetch()}
onRowClick={handleRowClick}
onKeyHashClick={handleKeyHashClick}
onSessionClick={handleSessionClick}
teams={allTeams ?? []}
accessToken={accessToken}
toolbarChildren={
<LogsTableToolbar
startTime={startTime}
onStartTimeChange={setStartTime}
endTime={endTime}
onEndTimeChange={setEndTime}
isCustomDate={isCustomDate}
onIsCustomDateChange={setIsCustomDate}
selectedTimeInterval={selectedTimeInterval}
onSelectedTimeIntervalChange={setSelectedTimeInterval}
isLiveTail={isLiveTail}
onIsLiveTailChange={setIsLiveTail}
onResetToFirstPage={resetToFirstPage}
onResetFilters={handleResetFilters}
/>
}
/>
<LogDetailsDrawer
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedSessionId(null);
}}
logEntry={selectedLog}
sessionId={selectedSessionId}
accessToken={accessToken}
allLogs={rows}
onSelectLog={setSelectedLog}
startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")}
/>
</>
);
}

View file

@ -0,0 +1,131 @@
"use client";
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import { ScrollText } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components/shared/DataTable";
import type { Team } from "../key_team_helpers/key_list";
import type { LogEntry } from "./columns";
import { LOG_FILTER_LABELS } from "./log_filter_logic";
import { RequestLogsFilters } from "./RequestLogsFilters";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
interface RequestLogsTableProps {
data: LogEntry[];
rowCount: number;
isLoading: boolean;
isRefreshing: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
columnFilters: ColumnFiltersState;
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>;
searchValue: string;
onSearchChange: (value: string) => void;
onRefresh: () => void;
onRowClick: (log: LogEntry) => void;
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (sessionId: string) => void;
teams: Team[];
accessToken: string;
toolbarChildren?: ReactNode;
}
function RequestLogsEmptyState({ filtered }: { filtered: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<ScrollText className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">{filtered ? "No matching requests" : "No requests yet"}</div>
<div className="max-w-xs text-center text-sm text-muted-foreground">
{filtered
? "No requests match your filters for this time range."
: "Requests proxied through LiteLLM will appear here."}
</div>
</div>
);
}
export function RequestLogsTable({
data,
rowCount,
isLoading,
isRefreshing,
pagination,
onPaginationChange,
sorting,
onSortingChange,
columnFilters,
onColumnFiltersChange,
searchValue,
onSearchChange,
onRefresh,
onRowClick,
onKeyHashClick,
onSessionClick,
teams,
accessToken,
toolbarChildren,
}: RequestLogsTableProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => {
const deps = { onKeyHashClick, onSessionClick };
return getRequestLogsTableColumns(deps);
}, [onKeyHashClick, onSessionClick]);
const isFiltered = columnFilters.length > 0 || searchValue !== "";
return (
<DataTable
data={data}
columns={columns}
getRowId={(row) => row.request_id}
sortingMode="server"
sorting={sorting}
onSortingChange={onSortingChange}
paginationMode="server"
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={rowCount}
filterMode="server"
columnFilters={columnFilters}
onColumnFiltersChange={onColumnFiltersChange}
isLoading={isLoading}
loadingMessage="Loading request logs…"
noDataMessage={<RequestLogsEmptyState filtered={isFiltered} />}
size="compact"
onRowClick={onRowClick}
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={searchValue}
onSearchChange={onSearchChange}
searchPlaceholder="Search by Request ID"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={LOG_FILTER_LABELS}
showViewOptions={false}
>
{toolbarChildren}
</DataTableToolbar>
<DataTableFilterDrawer
table={table}
open={filtersOpen}
onOpenChange={setFiltersOpen}
title="Filters"
description="Narrow down request logs"
>
{({ get, set }) => <RequestLogsFilters get={get} set={set} teams={teams} accessToken={accessToken} />}
</DataTableFilterDrawer>
</>
)}
/>
);
}

View file

@ -0,0 +1,136 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
import type { LogEntry } from "./columns";
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4o",
model_id: "model-1",
call_type: "acompletion",
spend: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:14Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
const noopDeps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() };
function renderRows(rows: LogEntry[], deps = noopDeps) {
render(
<DataTable
data={rows}
columns={getRequestLogsTableColumns(deps)}
getRowId={(row) => row.request_id}
size="compact"
/>,
);
}
describe("Cost column", () => {
it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "req-zero", spend: 0 })]);
for (const dash of screen.getAllByText("-")) {
await user.hover(dash);
}
expect(screen.queryByText("$0")).not.toBeInTheDocument();
});
it("shows the full-precision raw value in the tooltip for a real spend", async () => {
const user = userEvent.setup();
renderRows([logEntry({ request_id: "req-spend", spend: 0.00012345678 })]);
await user.hover(screen.getByText("$0.000123"));
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
});
it("shows the summed session total, not the representative call's spend, for a multi-round session", () => {
renderRows([
logEntry({
request_id: "req-session",
spend: 0.01,
session_id: "sess-1",
session_total_count: 3,
session_total_spend: 0.06,
}),
]);
expect(screen.getByText("$0.060000")).toBeInTheDocument();
expect(screen.queryByText("$0.010000")).not.toBeInTheDocument();
expect(screen.getByText("session total")).toBeInTheDocument();
});
});
describe("row action cells", () => {
it("reports the key hash through the injected dependency rather than a row field", async () => {
const user = userEvent.setup();
const deps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() };
renderRows([logEntry({ request_id: "req-key", metadata: { user_api_key: "sk-hash-9" } })], deps);
await user.click(screen.getByText("sk-hash-9"));
expect(deps.onKeyHashClick).toHaveBeenCalledWith("sk-hash-9");
});
it("reports the session id from the session cell", async () => {
const user = userEvent.setup();
const deps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() };
renderRows([logEntry({ request_id: "req-sess", session_id: "sess-42" })], deps);
await user.click(screen.getByText("sess-42"));
expect(deps.onSessionClick).toHaveBeenCalledWith("sess-42");
});
});
describe("sortable headers", () => {
it("exposes sort controls only for the backend-sortable fields", () => {
renderRows([logEntry({})]);
for (const field of ["startTime", "spend", "request_duration_ms", "ttft_ms", "model", "total_tokens"]) {
expect(screen.getByTestId(`sort-trigger-${field}`)).toBeInTheDocument();
}
for (const field of ["request_id", "session_id", "status", "type", "end_user"]) {
expect(screen.queryByTestId(`sort-trigger-${field}`)).toBeNull();
}
});
});
describe("TTFT column", () => {
it("renders '-' when the completion start equals the end time, since TTFT is meaningless there", () => {
renderRows([
logEntry({
request_id: "req-nonstream",
endTime: "2026-07-07T09:50:14Z",
completionStartTime: "2026-07-07T09:50:14Z",
}),
]);
expect(screen.queryByText("1.00")).not.toBeInTheDocument();
});
it("renders seconds when streaming produced a real first token", () => {
renderRows([
logEntry({
request_id: "req-stream",
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:16Z",
completionStartTime: "2026-07-07T09:50:14Z",
}),
]);
expect(screen.getByText("1.00")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,317 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import { getSpendString } from "@/utils/dataUtils";
import { getProviderLogoAndName } from "../provider_info_helpers";
import type { LogEntry } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
export interface RequestLogsTableColumnsDeps {
onKeyHashClick: (keyHash: string) => void;
onSessionClick: (sessionId: string) => void;
}
const readMetaString = (metadata: Record<string, unknown> | undefined, key: string): string | undefined => {
const value = metadata?.[key];
return typeof value === "string" && value !== "" ? value : undefined;
};
const readMcpLogoUrl = (metadata: Record<string, unknown> | undefined): string | undefined => {
const mcpMetadata = metadata?.["mcp_tool_call_metadata"];
if (typeof mcpMetadata !== "object" || mcpMetadata === null) return undefined;
const url = (mcpMetadata as Record<string, unknown>)["mcp_server_logo_url"];
return typeof url === "string" && url !== "" ? url : undefined;
};
const getLogoUrl = (row: LogEntry, provider: string): string =>
readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : "");
function TruncatedText({ value }: { value: string | undefined }) {
const display = value ?? "-";
return <CellTooltip content={display} trigger={<span className="max-w-[15ch] truncate block">{display}</span>} />;
}
export const getRequestLogsTableColumns = ({
onKeyHashClick,
onSessionClick,
}: RequestLogsTableColumnsDeps): ColumnDef<LogEntry>[] => [
{
id: "startTime",
accessorKey: "startTime",
header: ({ column }) => <DataTableSortHeader column={column} title="Time" variant="dropdown-tristate" />,
size: 200,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.startTime} />,
},
{
id: "type",
header: "Type",
size: 90,
enableSorting: false,
meta: { skeleton: "badge" },
cell: ({ row }) => {
const log = row.original;
const sessionCount = log.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const isAgent = AGENT_CALL_TYPES.includes(log.call_type);
const sessionLlmCount = log.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount);
const sessionAgentCount = log.session_agent_count ?? (isAgent ? sessionCount : 0);
const sessionMcpCount = log.session_mcp_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return <McpBadge />;
if (isAgent && sessionCount <= 1) return <AgentBadge />;
if (sessionCount <= 1) return <LlmBadge />;
const sessionTypeBadge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<SparkleIcon />
<span>{sessionCount}</span>
{sessionAgentCount > 0 && (
<>
<span className="text-blue-300">·</span>
<AgentIcon size={10} />
</>
)}
{sessionMcpCount > 0 && (
<>
<span className="text-blue-300">·</span>
<WrenchIcon />
</>
)}
</span>
);
const tooltipParts = [
sessionLlmCount > 0 && `${sessionLlmCount} LLM`,
sessionAgentCount > 0 && `${sessionAgentCount} Agent`,
sessionMcpCount > 0 && `${sessionMcpCount} MCP`,
].filter(Boolean);
return <CellTooltip content={tooltipParts.join(" • ")} trigger={sessionTypeBadge} />;
},
},
{
id: "status",
header: "Status",
size: 100,
enableSorting: false,
meta: { skeleton: "badge" },
cell: ({ row }) => {
const status = readMetaString(row.original.metadata, "status") ?? "Success";
const isSuccess = status.toLowerCase() !== "failure";
return <StatusBadge tone={isSuccess ? "success" : "error"} label={isSuccess ? "Success" : "Failure"} />;
},
},
{
id: "session_id",
accessorKey: "session_id",
header: "Session ID",
size: 120,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.session_id} onClick={onSessionClick} />,
},
{
id: "request_id",
accessorKey: "request_id",
header: "Request ID",
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.request_id} variant="plain" />,
},
{
id: "spend",
accessorKey: "spend",
header: ({ column }) => <DataTableSortHeader column={column} title="Cost" variant="dropdown-tristate" />,
size: 110,
enableSorting: true,
meta: { numeric: true, skeleton: "twoLine" },
cell: ({ row }) => {
const log = row.original;
const mcpCount = log.mcp_tool_call_count || 0;
const mcpSpend = log.mcp_tool_call_spend || 0;
const isMultiCallSession = (log.session_total_count || 1) > 1;
const spend = isMultiCallSession && log.session_total_spend != null ? log.session_total_spend : log.spend;
const money = (
<span>
<MoneyCell value={spend} decimals={6} />
</span>
);
return (
<div className="flex flex-col items-end">
{spend ? <CellTooltip content={`$${String(spend)}`} trigger={money} /> : money}
{isMultiCallSession && <span className="text-[10px] text-gray-400">session total</span>}
{mcpCount > 0 && mcpSpend > 0 && (
<span className="text-[10px] text-amber-600">
incl. {getSpendString(mcpSpend)} from {mcpCount} MCP
</span>
)}
</div>
);
},
},
{
id: "request_duration_ms",
accessorKey: "request_duration_ms",
header: ({ column }) => <DataTableSortHeader column={column} title="Duration (s)" variant="dropdown-tristate" />,
enableSorting: true,
meta: { numeric: true },
cell: ({ row }) => {
const ms = row.original.request_duration_ms;
if (ms == null) return <span>-</span>;
return (
<CellTooltip
content={`${ms}ms`}
trigger={<span className="max-w-[15ch] truncate inline-block">{(ms / 1000).toFixed(2)}</span>}
/>
);
},
},
{
id: "ttft_ms",
accessorKey: "completionStartTime",
header: ({ column }) => <DataTableSortHeader column={column} title="TTFT (s)" variant="dropdown-tristate" />,
enableSorting: true,
meta: { numeric: true },
cell: ({ row }) => {
const log = row.original;
const completionStartTime = log.completionStartTime;
if (!completionStartTime) return <span>-</span>;
if (completionStartTime === log.endTime) return <span>-</span>;
const ttftMs = new Date(completionStartTime).getTime() - new Date(log.startTime).getTime();
if (ttftMs <= 0) return <span>-</span>;
return (
<CellTooltip
content={`${ttftMs}ms`}
trigger={<span className="max-w-[15ch] truncate inline-block">{(ttftMs / 1000).toFixed(2)}</span>}
/>
);
},
},
{
id: "team_alias",
header: "Team Name",
size: 150,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={readMetaString(row.original.metadata, "user_api_key_team_alias")} />,
},
{
id: "key_hash",
header: "Key Hash",
size: 110,
enableSorting: false,
cell: ({ row }) => (
<IdCell value={readMetaString(row.original.metadata, "user_api_key")} variant="plain" onClick={onKeyHashClick} />
),
},
{
id: "key_alias",
header: "Key Alias",
size: 150,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={readMetaString(row.original.metadata, "user_api_key_alias")} />,
},
{
id: "model",
accessorKey: "model",
header: ({ column }) => <DataTableSortHeader column={column} title="Model" variant="dropdown-tristate" />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
const log = row.original;
const provider = log.custom_llm_provider;
const modelName = log.model ?? "";
return (
<div className="flex items-center space-x-2">
{provider && (
<img
src={getLogoUrl(log, provider)}
alt=""
className="w-4 h-4"
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
)}
<CellTooltip content={modelName} trigger={<span className="max-w-[15ch] truncate block">{modelName}</span>} />
</div>
);
},
},
{
id: "total_tokens",
accessorKey: "total_tokens",
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" variant="dropdown-tristate" />,
size: 140,
enableSorting: true,
meta: { numeric: true },
cell: ({ row }) => {
const log = row.original;
return (
<span className="text-sm">
{String(log.total_tokens || "0")}
<span className="text-gray-400 text-xs ml-1">
({String(log.prompt_tokens || "0")}+{String(log.completion_tokens || "0")})
</span>
</span>
);
},
},
{
id: "user",
accessorKey: "user",
header: "Internal User",
size: 150,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={row.original.user} />,
},
{
id: "end_user",
accessorKey: "end_user",
header: "End User",
size: 140,
enableSorting: false,
cell: ({ row }) => <TruncatedText value={row.original.end_user} />,
},
{
id: "request_tags",
accessorKey: "request_tags",
header: "Tags",
size: 150,
enableSorting: false,
meta: { skeleton: "chips" },
cell: ({ row }) => {
const tags = row.original.request_tags;
if (!tags || Object.keys(tags).length === 0) return "-";
const tagEntries = Object.entries(tags);
const [firstTagKey, firstTagValue] = tagEntries[0];
const remainingCount = tagEntries.length - 1;
return (
<div className="flex flex-wrap gap-1">
<CellTooltip
content={
<div className="flex flex-col gap-1">
{tagEntries.map(([key, value]) => (
<span key={key}>
{key}: {String(value)}
</span>
))}
</div>
}
trigger={
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">
{firstTagKey}: {String(firstTagValue)}
{remainingCount > 0 && ` +${remainingCount}`}
</span>
}
/>
</div>
);
},
},
];

View file

@ -1,70 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { createColumns, type LogEntry } from "./columns";
import { DataTable } from "./table";
const logEntry = (overrides: Partial<LogEntry>): LogEntry => ({
request_id: "req-1",
api_key: "key-1",
team_id: "team-1",
model: "gpt-4o",
model_id: "model-1",
call_type: "acompletion",
spend: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
startTime: "2026-07-07T09:50:13Z",
endTime: "2026-07-07T09:50:14Z",
cache_hit: "false",
messages: [],
response: {},
...overrides,
});
describe("Cost column", () => {
it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[logEntry({ request_id: "req-zero", spend: 0 })]}
columns={createColumns()}
getRowId={(r) => r.request_id}
/>,
);
for (const dash of screen.getAllByText("-")) {
await user.hover(dash);
}
expect(screen.queryByText("$0")).not.toBeInTheDocument();
});
it("shows the full-precision raw value in the tooltip for a real spend", async () => {
const user = userEvent.setup();
render(
<DataTable
data={[logEntry({ request_id: "req-spend", spend: 0.00012345678 })]}
columns={createColumns()}
getRowId={(r) => r.request_id}
/>,
);
const formatted = screen.getByText("$0.000123");
await user.hover(formatted);
expect(await screen.findByText("$0.00012345678")).toBeInTheDocument();
});
it("shows the summed session total, not the representative call's spend, for a multi-round session", () => {
const overrides: Partial<LogEntry> = {
request_id: "req-session",
spend: 0.01,
session_id: "sess-1",
session_total_count: 3,
session_total_spend: 0.06,
};
render(<DataTable data={[logEntry(overrides)]} columns={createColumns()} getRowId={(r) => r.request_id} />);
expect(screen.getByText("$0.060000")).toBeInTheDocument();
expect(screen.queryByText("$0.010000")).not.toBeInTheDocument();
expect(screen.getByText("session total")).toBeInTheDocument();
});
});

View file

@ -1,13 +1,3 @@
import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells";
import { getSpendString } from "@/utils/dataUtils";
import type { ColumnDef } from "@tanstack/react-table";
import { Tooltip } from "antd";
import React from "react";
import { getProviderLogoAndName } from "../provider_info_helpers";
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
/** API sort field mapping for /spend/logs/ui endpoint */
export const LOGS_SORT_FIELD_MAP = {
startTime: "startTime",
@ -20,22 +10,6 @@ export const LOGS_SORT_FIELD_MAP = {
export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
export interface LogsSortProps {
sortBy: LogsSortField;
sortOrder: "asc" | "desc";
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
}
// Helper to get the appropriate logo URL
const getLogoUrl = (row: LogEntry, provider: string) => {
// Check if mcp_tool_call_metadata exists and contains mcp_server_logo_url
if (row.metadata?.mcp_tool_call_metadata?.mcp_server_logo_url) {
return row.metadata.mcp_tool_call_metadata.mcp_server_logo_url;
}
// Fall back to default provider logo
return provider ? getProviderLogoAndName(provider).logo : "";
};
export type LogEntry = {
request_id: string;
api_key: string;
@ -72,470 +46,4 @@ export type LogEntry = {
session_llm_count?: number;
session_mcp_count?: number;
session_agent_count?: number;
onKeyHashClick?: (keyHash: string) => void;
onSessionClick?: (sessionId: string) => void;
};
const SortableHeader = ({
label,
field,
sortBy,
sortOrder,
onSortChange,
}: {
label: string;
field: LogsSortField;
sortBy: LogsSortField;
sortOrder: "asc" | "desc";
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
}) => (
<div className="flex items-center gap-1">
<span>{label}</span>
<TableHeaderSortDropdown
sortState={sortBy === field ? sortOrder : false}
onSortChange={(newState) => {
if (newState === false) {
onSortChange("startTime", "desc");
} else {
onSortChange(field, newState);
}
}}
/>
</div>
);
export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[] => [
{
header: sortProps
? () => (
<SortableHeader
label="Time"
field="startTime"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Time",
accessorKey: "startTime",
size: 200,
cell: (info: any) => <DateCell value={info.getValue()} />,
},
{
header: "Type",
id: "type",
size: 90,
cell: (info: any) => {
const row = info.row.original;
const sessionCount = row.session_total_count || 1;
const isMcp = MCP_CALL_TYPES.includes(row.call_type);
const isAgent = AGENT_CALL_TYPES.includes(row.call_type);
const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount);
const sessionAgentCount = row.session_agent_count ?? (isAgent ? sessionCount : 0);
const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0);
if (isMcp) return <McpBadge />;
if (isAgent && sessionCount <= 1) return <AgentBadge />;
if (sessionCount <= 1) return <LlmBadge />;
// Multi-call session — show total count, plus Agent/MCP indicators when mixed.
const sessionTypeBadge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 border border-blue-200 rounded-full text-[11px] font-medium whitespace-nowrap">
<SparkleIcon />
<span>{sessionCount}</span>
{sessionAgentCount > 0 && (
<>
<span className="text-blue-300">·</span>
<AgentIcon size={10} />
</>
)}
{sessionMcpCount > 0 && (
<>
<span className="text-blue-300">·</span>
<WrenchIcon />
</>
)}
</span>
);
const tooltipParts = [
sessionLlmCount > 0 && `${sessionLlmCount} LLM`,
sessionAgentCount > 0 && `${sessionAgentCount} Agent`,
sessionMcpCount > 0 && `${sessionMcpCount} MCP`,
].filter(Boolean);
return <Tooltip title={tooltipParts.join(" • ")}>{sessionTypeBadge}</Tooltip>;
},
},
{
header: "Status",
accessorKey: "metadata.status",
size: 100,
cell: (info: any) => {
const status = info.getValue() || "Success";
const isSuccess = status.toLowerCase() !== "failure";
return <StatusBadge tone={isSuccess ? "success" : "error"} label={isSuccess ? "Success" : "Failure"} />;
},
},
{
header: "Session ID",
accessorKey: "session_id",
size: 120,
cell: (info: any) => <IdCell value={info.getValue()} onClick={info.row.original.onSessionClick} />,
},
{
header: "Request ID",
accessorKey: "request_id",
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" />,
},
{
header: sortProps
? () => (
<SortableHeader
label="Cost"
field="spend"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Cost",
accessorKey: "spend",
size: 110,
meta: { numeric: true },
cell: (info: any) => {
const row = info.row.original;
const mcpCount = row.mcp_tool_call_count || 0;
const mcpSpend = row.mcp_tool_call_spend || 0;
const isMultiCallSession = (row.session_total_count || 1) > 1;
const spend = isMultiCallSession && row.session_total_spend != null ? row.session_total_spend : info.getValue();
return (
<div className="flex flex-col items-end">
<Tooltip title={spend ? `$${String(spend)}` : undefined}>
<span>
<MoneyCell value={spend} decimals={6} />
</span>
</Tooltip>
{isMultiCallSession && <span className="text-[10px] text-gray-400">session total</span>}
{mcpCount > 0 && mcpSpend > 0 && (
<span className="text-[10px] text-amber-600">
incl. {getSpendString(mcpSpend)} from {mcpCount} MCP
</span>
)}
</div>
);
},
},
{
header: sortProps
? () => (
<SortableHeader
label="Duration (s)"
field="request_duration_ms"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Duration (s)",
accessorKey: "request_duration_ms",
meta: { numeric: true },
cell: (info: any) => {
const ms = info.getValue();
if (ms == null) return <span>-</span>;
const seconds = (ms / 1000).toFixed(2);
return (
<Tooltip title={`${ms}ms`}>
<span className="max-w-[15ch] truncate inline-block">{seconds}</span>
</Tooltip>
);
},
},
{
header: sortProps
? () => (
<SortableHeader
label="TTFT (s)"
field="ttft_ms"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "TTFT (s)",
accessorKey: "completionStartTime",
meta: { numeric: true },
cell: (info: any) => {
const row = info.row.original;
const completionStartTime = info.getValue();
if (!completionStartTime) return <span>-</span>;
// For non-streaming, completionStartTime == endTime so TTFT is not meaningful
if (completionStartTime === row.endTime) return <span>-</span>;
const ttftMs = new Date(completionStartTime).getTime() - new Date(row.startTime).getTime();
if (ttftMs <= 0) return <span>-</span>;
const ttftSeconds = (ttftMs / 1000).toFixed(2);
return (
<Tooltip title={`${ttftMs}ms`}>
<span className="max-w-[15ch] truncate inline-block">{ttftSeconds}</span>
</Tooltip>
);
},
},
{
header: "Team Name",
accessorKey: "metadata.user_api_key_team_alias",
size: 150,
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "-")}>
<span className="max-w-[15ch] truncate block">{String(info.getValue() || "-")}</span>
</Tooltip>
),
},
{
header: "Key Hash",
accessorKey: "metadata.user_api_key",
size: 110,
cell: (info: any) => <IdCell value={info.getValue()} variant="plain" onClick={info.row.original.onKeyHashClick} />,
},
{
header: "Key Alias",
accessorKey: "metadata.user_api_key_alias",
size: 150,
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "-")}>
<span className="max-w-[15ch] truncate block">{String(info.getValue() || "-")}</span>
</Tooltip>
),
},
{
header: sortProps
? () => (
<SortableHeader
label="Model"
field="model"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Model",
accessorKey: "model",
size: 200,
cell: (info: any) => {
const row = info.row.original;
const provider = row.custom_llm_provider;
const modelName = String(info.getValue() || "");
return (
<div className="flex items-center space-x-2">
{provider && (
<img
src={getLogoUrl(row, provider)}
alt=""
className="w-4 h-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<Tooltip title={modelName}>
<span className="max-w-[15ch] truncate block">{modelName}</span>
</Tooltip>
</div>
);
},
},
{
header: sortProps
? () => (
<SortableHeader
label="Tokens"
field="total_tokens"
sortBy={sortProps.sortBy}
sortOrder={sortProps.sortOrder}
onSortChange={sortProps.onSortChange}
/>
)
: "Tokens",
accessorKey: "total_tokens",
size: 140,
meta: { numeric: true },
cell: (info: any) => {
const row = info.row.original;
return (
<span className="text-sm">
{String(row.total_tokens || "0")}
<span className="text-gray-400 text-xs ml-1">
({String(row.prompt_tokens || "0")}+{String(row.completion_tokens || "0")})
</span>
</span>
);
},
},
{
header: "Internal User",
accessorKey: "user",
size: 150,
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "-")}>
<span className="max-w-[15ch] truncate block">{String(info.getValue() || "-")}</span>
</Tooltip>
),
},
{
header: "End User",
accessorKey: "end_user",
size: 140,
cell: (info: any) => (
<Tooltip title={String(info.getValue() || "-")}>
<span className="max-w-[15ch] truncate block">{String(info.getValue() || "-")}</span>
</Tooltip>
),
},
{
header: "Tags",
accessorKey: "request_tags",
size: 150,
cell: (info: any) => {
const tags = info.getValue();
if (!tags || Object.keys(tags).length === 0) return "-";
const tagEntries = Object.entries(tags);
const firstTag = tagEntries[0];
const remainingTags = tagEntries.slice(1);
return (
<div className="flex flex-wrap gap-1">
<Tooltip
title={
<div className="flex flex-col gap-1">
{tagEntries.map(([key, value]) => (
<span key={key}>
{key}: {String(value)}
</span>
))}
</div>
}
>
<span className="px-2 py-1 bg-gray-100 rounded-full text-xs">
{firstTag[0]}: {String(firstTag[1])}
{remainingTags.length > 0 && ` +${remainingTags.length}`}
</span>
</Tooltip>
</div>
);
},
},
];
/** Default columns without sort (for backward compatibility) */
export const columns = createColumns();
const formatMessage = (message: any): string => {
if (!message) return "N/A";
if (typeof message === "string") return message;
if (typeof message === "object") {
// Handle the {text, type} object specifically
if (message.text) return message.text;
if (message.content) return message.content;
return JSON.stringify(message);
}
return String(message);
};
// Add this new component for displaying request/response with copy buttons
export const RequestResponsePanel = ({ request, response }: { request: any; response: any }) => {
const requestStr = typeof request === "object" ? JSON.stringify(request, null, 2) : String(request || "{}");
const responseStr = typeof response === "object" ? JSON.stringify(response, null, 2) : String(response || "{}");
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="rounded-lg border border-gray-200 bg-gray-50">
<div className="flex justify-between items-center p-3 border-b border-gray-200">
<h3 className="text-sm font-medium">Request</h3>
<button
onClick={() => copyToClipboard(requestStr)}
className="p-1 hover:bg-gray-200 rounded-sm"
title="Copy request"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<pre className="p-4 overflow-auto text-xs font-mono h-64 whitespace-pre-wrap wrap-break-word">{requestStr}</pre>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50">
<div className="flex justify-between items-center p-3 border-b border-gray-200">
<h3 className="text-sm font-medium">Response</h3>
<button
onClick={() => copyToClipboard(responseStr)}
className="p-1 hover:bg-gray-200 rounded-sm"
title="Copy response"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<pre className="p-4 overflow-auto text-xs font-mono h-64 whitespace-pre-wrap wrap-break-word">
{responseStr}
</pre>
</div>
</div>
);
};
// New component for collapsible JSON display
const CollapsibleJsonCell = ({ jsonData }: { jsonData: any }) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const jsonString = JSON.stringify(jsonData, null, 2);
if (!jsonData || Object.keys(jsonData).length === 0) {
return <span>-</span>;
}
return (
<div>
<button onClick={() => setIsExpanded(!isExpanded)} className="text-blue-500 hover:text-blue-700 text-xs">
{isExpanded ? "Hide JSON" : "Show JSON"} ({Object.keys(jsonData).length} fields)
</button>
{isExpanded && (
<pre className="mt-2 p-2 bg-gray-50 border rounded-sm text-xs overflow-auto max-h-60">{jsonString}</pre>
)}
</div>
);
};

View file

@ -1,82 +0,0 @@
import FilterTeamDropdown from "../common_components/FilterTeamDropdown";
import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect";
import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect";
import { FilterOption } from "../molecules/filter";
import { allEndUsersCall } from "../networking";
import { ERROR_CODE_OPTIONS } from "./constants";
import { FILTER_KEYS } from "./log_filter_logic";
export function getLogFilterOptions(accessToken: string): FilterOption[] {
return [
{
name: "Team ID",
label: "Team ID",
customComponent: FilterTeamDropdown,
},
{
name: "Status",
label: "Status",
isSearchable: false,
options: [
{ label: "Success", value: "success" },
{ label: "Failure", value: "failure" },
],
},
{
name: "Key Alias",
label: "Key Alias",
customComponent: PaginatedKeyAliasSelect,
},
{
name: "End User",
label: "End User",
isSearchable: true,
searchFn: async (searchText: string) => {
const data = await allEndUsersCall(accessToken);
const users = data?.map((u: any) => u.user_id) || [];
const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase()));
return filtered.map((u: string) => ({ label: u, value: u }));
},
},
{
name: "Error Code",
label: "Error Code",
isSearchable: true,
searchFn: async (searchText: string) => {
if (!searchText) return ERROR_CODE_OPTIONS;
const lower = searchText.toLowerCase();
const filtered = ERROR_CODE_OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower));
const isExactValue = ERROR_CODE_OPTIONS.some((opt) => opt.value === searchText.trim());
if (!isExactValue && searchText.trim()) {
filtered.push({ label: `Use custom code: ${searchText.trim()}`, value: searchText.trim() });
}
return filtered;
},
},
{
name: "Error Message",
label: "Error Message",
isSearchable: false,
},
{
name: "Key Hash",
label: "Key Hash",
isSearchable: false,
},
{
name: FILTER_KEYS.SESSION_ID,
label: "Session ID",
isSearchable: false,
},
{
name: "Model",
label: "Model",
customComponent: PaginatedModelSelect,
},
{
name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
label: "Public model / search tool",
isSearchable: false,
},
];
}

View file

@ -1,106 +1,60 @@
import { screen, waitFor } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import SpendLogsTable from "./index";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogsCall } from "../networking";
import { useLogFilterLogic } from "./log_filter_logic";
const mockHandleFilterResetFromHook = vi.fn();
vi.mock("./log_filter_logic", async (importOriginal) => {
const actual = await importOriginal<typeof import("./log_filter_logic")>();
return {
...actual,
useLogFilterLogic: vi.fn(() => ({
logsQuery: { isLoading: false, isFetching: false, refetch: vi.fn() },
filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 },
allTeams: [],
handleFilterChange: vi.fn(),
handleFilterReset: mockHandleFilterResetFromHook,
})),
};
});
vi.mock("../networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("../networking")>();
return {
...actual,
uiSpendLogsCall: vi.fn().mockResolvedValue({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0,
}),
keyListCall: vi.fn().mockResolvedValue({ keys: [] }),
keyInfoV1Call: vi.fn().mockResolvedValue({ info: {} }),
allEndUsersCall: vi.fn().mockResolvedValue([]),
};
});
vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([]),
vi.mock("./RequestLogsPanel", () => ({
default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) {
return <div data-testid="request-logs-panel">{isActive ? "active" : "inactive"}</div>;
},
}));
vi.mock("./AuditLogsPanel", () => ({
default: function AuditLogsPanelMock({ isActive }: { isActive: boolean }) {
return <div data-testid="audit-logs-panel">{isActive ? "active" : "inactive"}</div>;
},
}));
vi.mock("../DeletedKeysPage/DeletedKeysPage", () => ({
default: function DeletedKeysPageMock() {
return <div data-testid="deleted-keys-page" />;
},
}));
vi.mock("../DeletedTeamsPage/DeletedTeamsPage", () => ({
default: function DeletedTeamsPageMock() {
return <div data-testid="deleted-teams-page" />;
},
}));
const defaultProps = {
accessToken: "test-token",
token: "test-token",
userRole: "Admin",
userID: "user-1",
premiumUser: false,
};
describe("SpendLogsTable", () => {
const defaultProps = {
accessToken: "test-token",
token: "test-token",
userRole: "Admin",
userID: "user-1",
premiumUser: false,
};
it("renders the four log tabs", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
beforeEach(() => {
vi.clearAllMocks();
// Clear sessionStorage to avoid isLiveTail state from previous tests
sessionStorage.clear();
for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) {
expect(screen.getByRole("tab", { name: label })).toBeInTheDocument();
}
});
it("should call handleFilterResetFromHook when Reset Filters is clicked", async () => {
it("marks only the visible tab's panel active so background tabs do not query", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
const resetButton = screen.getByRole("button", { name: "Reset Filters" });
await user.click(resetButton);
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active");
await waitFor(() => {
expect(mockHandleFilterResetFromHook).toHaveBeenCalledTimes(1);
});
});
await user.click(screen.getByRole("tab", { name: "Audit Logs" }));
it("should reset custom date range to default when Reset Filters is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
// Open the time range quick select dropdown (button shows current range like "Last 24 Hours")
const quickSelectButton = screen.getByRole("button", {
name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i,
});
await user.click(quickSelectButton);
// Click "Custom Range" to enable custom date selection
const customRangeButton = await screen.findByRole("button", { name: "Custom Range" });
await user.click(customRangeButton);
// Custom date inputs should now be visible (start and end datetime-local inputs)
const datetimeInputs = document.querySelectorAll('input[type="datetime-local"]');
expect(datetimeInputs.length).toBeGreaterThanOrEqual(2);
// Click Reset Filters - this should reset the custom date range and hide custom inputs
const resetButton = screen.getByRole("button", { name: "Reset Filters" });
await user.click(resetButton);
await waitFor(() => {
expect(mockHandleFilterResetFromHook).toHaveBeenCalled();
});
// After reset, custom date inputs should be hidden (isCustomDate reset to false)
await waitFor(() => {
const inputsAfterReset = document.querySelectorAll('input[type="datetime-local"]');
expect(inputsAfterReset.length).toBe(0);
});
expect(await screen.findByTestId("audit-logs-panel")).toHaveTextContent("active");
expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive");
});
describe("auth-not-ready guard", () => {
@ -108,73 +62,14 @@ describe("SpendLogsTable", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} accessToken={null} />);
expect(document.querySelector(".ant-spin")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument();
expect(screen.queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument();
});
it("renders the table (no spinner) once all credentials are present", () => {
it("renders the tabs (no spinner) once all credentials are present", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} />);
expect(document.querySelector(".ant-spin")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
});
});
describe("Quick Select time range", () => {
// uiSpendLogsCall fires from the real useLogFilterLogic query, so restore it here.
beforeEach(async () => {
const actual = await vi.importActual<typeof import("./log_filter_logic")>("./log_filter_logic");
vi.mocked(useLogFilterLogic).mockImplementation(actual.useLogFilterLogic);
});
const waitForWindowSeconds = async (minMinutes: number) => {
let diff = -1;
await waitFor(() => {
const lastCall = vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0];
if (!lastCall) throw new Error("uiSpendLogsCall was not called");
diff = moment
.utc(lastCall.end_date, "YYYY-MM-DD HH:mm:ss")
.diff(moment.utc(lastCall.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds");
// start_date is rounded down to the minute boundary, end_date is the
// current wall-clock at queryFn time. The dropped sub-minute fraction
// on start_date can push the diff up to (minMinutes+1)*60 seconds
// exactly (e.g. click at HH:MM:59.9 → start floors to HH:MM:00 and
// queryFn fires just past HH:(MM+1):00), so allow equality on the
// upper bound.
expect(diff).toBeGreaterThanOrEqual(minMinutes * 60);
expect(diff).toBeLessThanOrEqual((minMinutes + 1) * 60);
});
return diff;
};
it("should pass a ~1-minute window to uiSpendLogsCall when 'Last Minute' is selected", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
await waitForWindowSeconds(1);
});
it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" }));
await waitForWindowSeconds(15);
});
it("should update the time-range button label to 'Last Minute' after selecting it", async () => {
const user = userEvent.setup();
renderWithProviders(<SpendLogsTable {...defaultProps} />);
await user.click(screen.getByRole("button", { name: /Last 24 Hours/i }));
await user.click(await screen.findByRole("button", { name: "Last Minute" }));
expect(screen.getByRole("button", { name: "Last Minute" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Last 24 Hours/i })).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument();
});
});
});

View file

@ -1,21 +1,9 @@
import moment from "moment";
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { useState } from "react";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { internalUserRoles } from "../../utils/roles";
import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage";
import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage";
import { KeyResponse } from "../key_team_helpers/key_list";
import FilterComponent from "../molecules/filter";
import { keyInfoV1Call } from "../networking";
import KeyInfoView from "../templates/key_info_view";
import AuditLogsPanel from "./AuditLogsPanel";
import { createColumns, LogEntry, type LogsSortField } from "./columns";
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants";
import { getLogFilterOptions } from "./filter_options";
import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic";
import { LogDetailsDrawer } from "./LogDetailsDrawer";
import { LogsTableToolbar } from "./LogsTableToolbar";
import { DataTable } from "./table";
import RequestLogsPanel from "./RequestLogsPanel";
import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner";
interface SpendLogsTableProps {
@ -27,190 +15,8 @@ interface SpendLogsTableProps {
}
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(50);
// New state variables for Start and End Time
const [startTime, setStartTime] = useState<string>(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
const [endTime, setEndTime] = useState<string>(moment().format("YYYY-MM-DDTHH:mm"));
const [isCustomDate, setIsCustomDate] = useState(false);
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole));
const [activeTab, setActiveTab] = useState("request logs");
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<LogsSortField>("startTime");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({
value: 24,
unit: "hours",
});
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
const storedValue = sessionStorage.getItem("isLiveTail");
// default to true if nothing is stored
return storedValue !== null ? JSON.parse(storedValue) : true;
});
useEffect(() => {
sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail));
}, [isLiveTail]);
useEffect(() => {
const fetchKeyInfo = async () => {
if (selectedKeyIdInfoView && accessToken) {
const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView);
const keyResponse: KeyResponse = {
...keyData["info"],
token: selectedKeyIdInfoView,
api_key: selectedKeyIdInfoView,
};
setSelectedKeyInfo(keyResponse);
}
};
fetchKeyInfo();
}, [selectedKeyIdInfoView, accessToken]);
useEffect(() => {
if (userRole && internalUserRoles.includes(userRole)) {
setFilterByCurrentUser(true);
}
}, [userRole]);
const {
logsQuery,
filteredLogs,
allTeams,
handleFilterChange,
handleFilterReset: handleFilterResetFromHook,
} = useLogFilterLogic({
accessToken,
token,
userRole,
userID,
filters,
setFilters,
filterByCurrentUser: !!filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
endTime,
pageSize,
isCustomDate,
setCurrentPage,
sortBy,
sortOrder,
currentPage,
});
const handleFilterReset = useCallback(() => {
handleFilterResetFromHook();
setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
setEndTime(moment().format("YYYY-MM-DDTHH:mm"));
setIsCustomDate(false);
setSelectedTimeInterval({ value: 24, unit: "hours" });
setCurrentPage(1);
}, [handleFilterResetFromHook]);
const handleSortChange = useCallback((newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setCurrentPage(1);
}, []);
const columns = useMemo(
() => createColumns({ sortBy, sortOrder, onSortChange: handleSortChange }),
[sortBy, sortOrder, handleSortChange],
);
const filteredData = useMemo(() => {
const searchedLogs = filteredLogs.data.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm));
// No need for additional filtering since we're now handling this in the API call
return matchesSearch;
});
const sessionCompositionById = searchedLogs.reduce<Record<string, { llm: number; agent: number; mcp: number }>>(
(acc, log) => {
if (!log.session_id) return acc;
if (!acc[log.session_id]) {
acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 };
}
if (MCP_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].mcp += 1;
} else if (AGENT_CALL_TYPES.includes(log.call_type)) {
acc[log.session_id].agent += 1;
} else {
acc[log.session_id].llm += 1;
}
return acc;
},
{},
);
// Build a single-pass map of session_id → representative request_id.
// Prefers an LLM row over an MCP row as the representative.
const sessionRepresentativeMap = new Map<string, { requestId: string; isMcp: boolean }>();
for (const log of searchedLogs) {
if (!log.session_id || (log.session_total_count || 1) <= 1) continue;
const isMcp = MCP_CALL_TYPES.includes(log.call_type);
const existing = sessionRepresentativeMap.get(log.session_id);
if (!existing || (existing.isMcp && !isMcp)) {
sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp });
}
}
return (
searchedLogs
.map((log) => {
const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined;
return {
...log,
request_duration_ms: log.request_duration_ms,
session_llm_count: sessionComposition?.llm ?? undefined,
session_mcp_count: sessionComposition?.mcp ?? undefined,
session_agent_count: sessionComposition?.agent ?? undefined,
onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash),
onSessionClick: (sessionId: string) => {
if (sessionId) {
setSelectedSessionId(sessionId);
setSelectedLog(log);
setIsDrawerOpen(true);
}
},
};
})
// Deduplicate multi-call sessions using the pre-built map (O(1) per row).
.filter((log) => {
if (!log.session_id || (log.session_total_count || 1) <= 1) return true;
return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id;
})
);
}, [filteredLogs.data, searchTerm]);
// Keep the Fetch button busy until the table has actually committed the new
// rows. `keepPreviousData` leaves logsQuery.isLoading false on refetch, so
// without this the button clears while stale rows are still on screen.
const deferredData = useDeferredValue(filteredData);
const isStale = deferredData !== filteredData;
const isButtonLoading = logsQuery.isFetching || isStale;
const isRefiltering = logsQuery.isPlaceholderData;
const isLogsLoading = logsQuery.isLoading || isRefiltering;
if (!accessToken || !token || !userRole || !userID) {
return (
<div className="flex items-center justify-center h-64">
@ -219,20 +25,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
);
}
const handleRowClick = (log: LogEntry) => {
// Multi-call session row: open in the same right-side drawer (session mode)
if (log.session_id && (log.session_total_count || 1) > 1) {
setSelectedSessionId(log.session_id);
setSelectedLog(log);
setIsDrawerOpen(true);
return;
}
// Single-call row: open the detail drawer
setSelectedSessionId(null);
setSelectedLog(log);
setIsDrawerOpen(true);
};
return (
<div className="w-full p-6 overflow-x-hidden box-border">
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "request logs" : "audit logs")}>
@ -244,56 +36,13 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
</TabList>
<TabPanels>
<TabPanel>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">Request Logs</h1>
</div>
{selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? (
<KeyInfoView
keyId={selectedKeyIdInfoView}
keyData={selectedKeyInfo}
teams={allTeams ?? []}
onClose={() => setSelectedKeyIdInfoView(null)}
backButtonText="Back to Logs"
/>
) : (
<>
<FilterComponent
options={getLogFilterOptions(accessToken)}
onApplyFilters={handleFilterChange}
onResetFilters={handleFilterReset}
/>
<div className="bg-white rounded-lg shadow-sm w-full max-w-full box-border">
<LogsTableToolbar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
startTime={startTime}
onStartTimeChange={setStartTime}
endTime={endTime}
onEndTimeChange={setEndTime}
isCustomDate={isCustomDate}
onIsCustomDateChange={setIsCustomDate}
selectedTimeInterval={selectedTimeInterval}
onSelectedTimeIntervalChange={setSelectedTimeInterval}
isLiveTail={isLiveTail}
onIsLiveTailChange={setIsLiveTail}
currentPage={currentPage}
onCurrentPageChange={setCurrentPage}
pageSize={pageSize}
isLoading={isLogsLoading}
isButtonLoading={isButtonLoading}
onRefetch={() => logsQuery.refetch()}
filteredLogs={filteredLogs}
/>
<DataTable
columns={columns}
data={deferredData}
getRowId={(row) => row.request_id}
onRowClick={handleRowClick}
isLoading={isLogsLoading}
/>
</div>
</>
)}
<RequestLogsPanel
accessToken={accessToken}
token={token}
userRole={userRole}
userID={userID}
isActive={activeTab === "request logs"}
/>
</TabPanel>
<TabPanel>
<AuditLogsPanel
@ -313,21 +62,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
</TabPanel>
</TabPanels>
</TabGroup>
{/* Log Details Drawer */}
<LogDetailsDrawer
open={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setSelectedSessionId(null);
}}
logEntry={selectedLog}
sessionId={selectedSessionId}
accessToken={accessToken}
allLogs={filteredData}
onSelectLog={setSelectedLog}
startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")}
/>
</div>
);
}

View file

@ -1,14 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { ReactNode, useState } from "react";
import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table";
import { renderHook, waitFor } from "@testing-library/react";
import moment from "moment";
import React, { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LogsSortField } from "./columns";
import {
defaultFilters,
DEFAULT_LOGS_SORTING,
getFilterValue,
getLiveTailRefetchInterval,
LIVE_TAIL_INTERVAL_MS,
LOG_FILTER_IDS,
useLogFilterLogic,
type LogFilterState,
type PaginatedResponse,
} from "./log_filter_logic";
@ -30,31 +32,33 @@ const emptyResponse: PaginatedResponse = {
total_pages: 0,
};
const FIRST_PAGE: PaginationState = { pageIndex: 0, pageSize: 50 };
const defaultProps = {
accessToken: "test-token" as string | null,
token: "test-token" as string | null,
userRole: "Admin" as string | null,
userID: "user-1" as string | null,
columnFilters: [] as ColumnFiltersState,
filterByCurrentUser: false,
activeTab: "request logs",
isLiveTail: false,
startTime: "2025-01-01T00:00:00",
endTime: "2025-01-01T23:59:59",
pagination: FIRST_PAGE,
isCustomDate: true,
sortBy: "startTime" as LogsSortField,
sortOrder: "desc" as "asc" | "desc",
currentPage: 1,
sorting: DEFAULT_LOGS_SORTING,
};
type HookOverrides = Partial<Omit<Parameters<typeof useLogFilterLogic>[0], "filters" | "setFilters">>;
type HookOverrides = Partial<Parameters<typeof useLogFilterLogic>[0]>;
const lastCallParams = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0];
describe("useLogFilterLogic", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse);
});
@ -63,602 +67,169 @@ describe("useLogFilterLogic", () => {
React.createElement(QueryClientProvider, { client: queryClient }, children);
function renderFilterHook(overrides: HookOverrides = {}) {
const setCurrentPage = overrides.setCurrentPage ?? vi.fn();
const rendered = renderHook(
() => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const hook = useLogFilterLogic({
...defaultProps,
...overrides,
filters,
setFilters,
setCurrentPage,
});
return { ...hook, filters, setFilters };
},
{ wrapper },
);
return { ...rendered, setCurrentPage };
return renderHook(() => useLogFilterLogic({ ...defaultProps, ...overrides }), { wrapper });
}
describe("return shape", () => {
it("exposes filteredLogs, allTeams, handleFilterChange, handleFilterReset", () => {
const { result } = renderFilterHook();
expect(result.current.filteredLogs).toBeDefined();
expect(result.current).toHaveProperty("allTeams");
expect(result.current.handleFilterChange).toBeInstanceOf(Function);
expect(result.current.handleFilterReset).toBeInstanceOf(Function);
});
});
describe("handleFilterReset", () => {
it("restores filters to defaults after changes", () => {
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" });
});
expect(result.current.filters["Team ID"]).toBe("team-1");
expect(result.current.filters["Status"]).toBe("success");
act(() => {
result.current.handleFilterReset();
});
expect(result.current.filters["Team ID"]).toBe("");
expect(result.current.filters["Status"]).toBe("");
});
it("calls setCurrentPage(1)", () => {
const setCurrentPage = vi.fn();
const { result } = renderFilterHook({ setCurrentPage });
act(() => {
result.current.handleFilterReset();
});
expect(setCurrentPage).toHaveBeenCalledWith(1);
});
it("triggers a fetch with all filter params undefined", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse);
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
act(() => {
result.current.handleFilterReset();
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({
params: expect.objectContaining({
team_id: undefined,
api_key: undefined,
request_id: undefined,
user_id: undefined,
end_user: undefined,
status_filter: undefined,
model_id: undefined,
key_alias: undefined,
error_code: undefined,
error_message: undefined,
}),
}),
);
},
{ timeout: 500 },
);
});
});
describe("handleFilterChange", () => {
it("calls setCurrentPage(1) when filters change", () => {
const setCurrentPage = vi.fn();
const { result } = renderFilterHook({ setCurrentPage });
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1" });
});
expect(setCurrentPage).toHaveBeenCalledWith(1);
});
it("merges partial updates without clobbering other filter keys", () => {
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Team ID": "team-a" });
});
expect(result.current.filters["Team ID"]).toBe("team-a");
act(() => {
result.current.handleFilterChange({ Model: "gpt-4" });
});
expect(result.current.filters["Team ID"]).toBe("team-a");
expect(result.current.filters["Model"]).toBe("gpt-4");
});
it("does not call setCurrentPage when filters are identical", async () => {
const setCurrentPage = vi.fn();
const { result } = renderFilterHook({ setCurrentPage });
act(() => {
result.current.handleFilterChange({ "Team ID": "team-1" });
});
await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), { timeout: 500 });
setCurrentPage.mockClear();
await act(async () => {
result.current.handleFilterChange({ "Team ID": "team-1" });
await new Promise((resolve) => setTimeout(resolve, 350));
});
expect(setCurrentPage).not.toHaveBeenCalled();
});
});
describe("query params — filter keys", () => {
const filterCases: Array<{
filterKey: keyof LogFilterState;
paramName: string;
value: string;
}> = [
{ filterKey: "Team ID", paramName: "team_id", value: "team-a" },
{ filterKey: "Key Hash", paramName: "api_key", value: "key-x" },
{ filterKey: "Request ID", paramName: "request_id", value: "req-xyz" },
{ filterKey: "Session ID", paramName: "session_id", value: "sess-42" },
{ filterKey: "User ID", paramName: "user_id", value: "user-123" },
{ filterKey: "End User", paramName: "end_user", value: "user-a" },
{ filterKey: "Status", paramName: "status_filter", value: "error" },
{ filterKey: "Model", paramName: "model_id", value: "gpt-4" },
{ filterKey: "Public model / search tool", paramName: "model", value: "tavily-marketing" },
{ filterKey: "Error Code", paramName: "error_code", value: "429" },
{ filterKey: "Error Message", paramName: "error_message", value: "rate limit exceeded" },
describe("column filters map onto backend query params", () => {
const cases: ReadonlyArray<{ id: string; value: string; param: string }> = [
{ id: LOG_FILTER_IDS.KEY_HASH, value: "sk-hash-1", param: "api_key" },
{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-1", param: "team_id" },
{ id: LOG_FILTER_IDS.REQUEST_ID, value: "req-1", param: "request_id" },
{ id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" },
{ id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" },
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
{ id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" },
{ id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" },
{ id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" },
];
it.each(filterCases)(
"forwards $filterKey as params.$paramName to uiSpendLogsCall",
async ({ filterKey, paramName, value }) => {
const { result } = renderFilterHook();
it.each(cases)("sends $id as $param", async ({ id, value, param }) => {
renderFilterHook({ columnFilters: [{ id, value }] });
act(() => {
result.current.handleFilterChange({ [filterKey]: value } as Partial<LogFilterState>);
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ [param]: value });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ [paramName]: value }),
}),
);
},
{ timeout: 500 },
);
},
);
});
describe("query params — date & sort", () => {
it("passes start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => {
const { result } = renderFilterHook({
startTime: "2025-01-15T00:00:00Z",
endTime: "2025-01-15T23:59:59Z",
isCustomDate: true,
sortBy: "spend" as LogsSortField,
sortOrder: "asc",
it("omits params for filters that are absent, blank, or whitespace-only", async () => {
renderFilterHook({
columnFilters: [
{ id: LOG_FILTER_IDS.TEAM_ID, value: " " },
{ id: LOG_FILTER_IDS.KEY_HASH, value: "" },
],
});
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
start_date: "2025-01-15 00:00:00",
end_date: "2025-01-15 23:59:59",
params: expect.objectContaining({
sort_by: "spend",
sort_order: "asc",
}),
}),
);
},
{ timeout: 500 },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
const params = lastCallParams()?.params;
expect(params?.team_id).toBeUndefined();
expect(params?.api_key).toBeUndefined();
expect(params?.error_code).toBeUndefined();
});
});
describe("debounce", () => {
it("calls uiSpendLogsCall after the debounce elapses for text filters", async () => {
const { result } = renderFilterHook();
describe("paging, dates, and sort", () => {
it("sends a 1-based page derived from pageIndex", async () => {
renderFilterHook({ pagination: { pageIndex: 2, pageSize: 25 } });
act(() => {
result.current.handleFilterChange({ "Key Hash": "hash-1" });
});
await waitFor(
() =>
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ api_key: "hash-1" }),
}),
),
{ timeout: 500 },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()).toMatchObject({ page: 3, page_size: 25 });
});
it("does not call uiSpendLogsCall with a text filter before the debounce elapses", async () => {
const { result } = renderFilterHook();
it("passes start_date, end_date, sort_by, and sort_order", async () => {
renderFilterHook({ sorting: [{ id: "spend", desc: false }] });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
vi.mocked(uiSpendLogsCall).mockClear();
act(() => {
result.current.handleFilterChange({ "Key Hash": "hash-1" });
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(uiSpendLogsCall).not.toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ api_key: "hash-1" }),
}),
);
await waitFor(
() =>
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ api_key: "hash-1" }),
}),
),
{ timeout: 500 },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
const call = lastCallParams();
expect(call?.start_date).toBe(moment(defaultProps.startTime).utc().format("YYYY-MM-DD HH:mm:ss"));
expect(call?.end_date).toBe(moment(defaultProps.endTime).utc().format("YYYY-MM-DD HH:mm:ss"));
expect(call?.params).toMatchObject({ sort_by: "spend", sort_order: "asc" });
});
it("applies dropdown filter changes without waiting for the debounce", async () => {
const { result } = renderFilterHook();
it("falls back to the default sort when the sorting state is empty", async () => {
renderFilterHook({ sorting: [] });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
vi.mocked(uiSpendLogsCall).mockClear();
act(() => {
result.current.handleFilterChange({ "Team ID": "team-instant" });
});
await waitFor(
() =>
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ team_id: "team-instant" }),
}),
),
{ timeout: 100 },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ sort_by: "startTime", sort_order: "desc" });
});
// Guards the TEXT_FILTER_KEYS fix: this free-text filter must debounce, not fire per keystroke.
it("debounces the 'Public model / search tool' text filter", async () => {
const { result } = renderFilterHook();
it("ignores a sort id the backend does not support", async () => {
renderFilterHook({ sorting: [{ id: "request_id", desc: false }] as SortingState });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
vi.mocked(uiSpendLogsCall).mockClear();
act(() => {
result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" });
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(uiSpendLogsCall).not.toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ model: "tavily-marketing" }),
}),
);
await waitFor(
() =>
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ model: "tavily-marketing" }),
}),
),
{ timeout: 500 },
);
});
});
describe("handleFilterReset", () => {
it("flushes the text-filter debounce so a pending typed value is not sent", async () => {
const { result } = renderFilterHook();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
vi.mocked(uiSpendLogsCall).mockClear();
act(() => {
result.current.handleFilterChange({ "Key Hash": "pending-hash" });
});
act(() => {
result.current.handleFilterReset();
});
await new Promise((resolve) => setTimeout(resolve, 400));
for (const call of vi.mocked(uiSpendLogsCall).mock.calls) {
expect(call[0].params?.api_key).toBeUndefined();
}
});
});
describe("backend filtered logs", () => {
it("returns the query payload as filteredLogs when backend filters are active", async () => {
const backendLog = { request_id: "backend-req" };
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: [backendLog],
total: 1,
page: 1,
page_size: 50,
total_pages: 1,
} as PaginatedResponse);
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(result.current.filteredLogs.data).toHaveLength(1);
expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req");
},
{ timeout: 500 },
);
});
it("returns empty data when the API returns an empty payload", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse);
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
expect(result.current.filteredLogs.data).toHaveLength(0);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ sort_by: "startTime" });
});
});
describe("refetch triggers", () => {
it("refetches when sortBy changes", async () => {
const { rerender } = renderHook(
(props: { sortBy: LogsSortField }) => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
return useLogFilterLogic({
...defaultProps,
filters,
setFilters,
setCurrentPage: vi.fn(),
sortBy: props.sortBy,
});
},
{ wrapper, initialProps: { sortBy: "startTime" } },
);
it.each([
["sorting", { sorting: [{ id: "spend", desc: true }] as SortingState }],
["pagination", { pagination: { pageIndex: 1, pageSize: 50 } }],
["startTime", { startTime: "2025-02-02T00:00:00" }],
["columnFilters", { columnFilters: [{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-2" }] }],
])("refetches when %s changes", async (_label, nextProps) => {
const { rerender } = renderHook((props: HookOverrides) => useLogFilterLogic({ ...defaultProps, ...props }), {
wrapper,
initialProps: {},
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 });
rerender({ sortBy: "spend" });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 });
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({
params: expect.objectContaining({ sort_by: "spend" }),
}),
);
});
it("refetches when sortOrder changes", async () => {
const { rerender } = renderHook(
(props: { sortOrder: "asc" | "desc" }) => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
return useLogFilterLogic({
...defaultProps,
filters,
setFilters,
setCurrentPage: vi.fn(),
sortOrder: props.sortOrder,
});
},
{ wrapper, initialProps: { sortOrder: "desc" } },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 });
rerender({ sortOrder: "asc" });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 });
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
expect.objectContaining({
params: expect.objectContaining({ sort_order: "asc" }),
}),
);
});
it("refetches when currentPage changes", async () => {
const { rerender } = renderHook(
(props: { currentPage: number }) => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
return useLogFilterLogic({
...defaultProps,
filters,
setFilters,
setCurrentPage: vi.fn(),
currentPage: props.currentPage,
});
},
{ wrapper, initialProps: { currentPage: 1 } },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 });
rerender({ currentPage: 2 });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 });
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }));
});
it("refetches when startTime changes", async () => {
const { rerender } = renderHook(
(props: { startTime: string }) => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
return useLogFilterLogic({
...defaultProps,
filters,
setFilters,
setCurrentPage: vi.fn(),
startTime: props.startTime,
});
},
{ wrapper, initialProps: { startTime: "2025-01-01T00:00:00Z" } },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 });
rerender({ startTime: "2025-01-02T00:00:00Z" });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 });
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ start_date: "2025-01-02 00:00:00" }));
});
it("refetches with a different end_date when isCustomDate toggles", async () => {
const customEndTime = "2025-01-15T23:59:59Z";
const customEndFormatted = "2025-01-15 23:59:59";
const { rerender } = renderHook(
(props: { isCustomDate: boolean }) => {
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
return useLogFilterLogic({
...defaultProps,
endTime: customEndTime,
filters,
setFilters,
setCurrentPage: vi.fn(),
isCustomDate: props.isCustomDate,
});
},
{ wrapper, initialProps: { isCustomDate: false } },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 });
const firstEndDate = vi.mocked(uiSpendLogsCall).mock.calls[0][0].end_date;
expect(firstEndDate).not.toBe(customEndFormatted);
rerender({ isCustomDate: true });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 });
expect(vi.mocked(uiSpendLogsCall).mock.calls[1][0].end_date).toBe(customEndFormatted);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1));
rerender(nextProps);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2));
});
});
describe("query enablement", () => {
const nullCredentialCases: Array<{ name: string; override: HookOverrides }> = [
{ name: "accessToken", override: { accessToken: null } },
{ name: "token", override: { token: null } },
{ name: "userRole", override: { userRole: null } },
{ name: "userID", override: { userID: null } },
];
it.each(nullCredentialCases)("does not call uiSpendLogsCall when $name is null", async ({ override }) => {
const { result } = renderFilterHook(override);
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await new Promise((resolve) => setTimeout(resolve, 350));
it("does not query when the request logs tab is inactive", async () => {
renderFilterHook({ activeTab: "audit logs" });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(uiSpendLogsCall).not.toHaveBeenCalled();
});
it("does not call uiSpendLogsCall when activeTab is not 'request logs'", async () => {
const { result } = renderFilterHook({ activeTab: "audit logs" });
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await new Promise((resolve) => setTimeout(resolve, 350));
it("does not query when credentials are missing", async () => {
renderFilterHook({ accessToken: null });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(uiSpendLogsCall).not.toHaveBeenCalled();
});
});
describe("filterByCurrentUser", () => {
it("sends user_id: userID when the User ID filter is blank", async () => {
const { result } = renderFilterHook({
it("scopes to the current user when no explicit user filter is set", async () => {
renderFilterHook({ filterByCurrentUser: true });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" });
});
it("lets an explicit user filter win over the current-user scope", async () => {
renderFilterHook({
filterByCurrentUser: true,
userID: "me-123",
columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }],
});
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(
() => {
expect(uiSpendLogsCall).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ user_id: "me-123" }),
}),
);
},
{ timeout: 500 },
);
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ user_id: "someone-else" });
});
});
describe("error handling", () => {
it("does not crash when uiSpendLogsCall throws", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error"));
const { result } = renderFilterHook();
it("returns an empty payload and does not crash when the call fails", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("boom"));
const { result } = renderFilterHook();
act(() => {
result.current.handleFilterChange({ "Key Alias": "alias-1" });
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(result.current.filteredLogs.data).toEqual([]);
expect(result.current.filteredLogs.total).toBe(0);
});
});
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 });
describe("getFilterValue", () => {
it("trims values and treats blank ones as absent", () => {
const filters: ColumnFiltersState = [
{ id: "team_id", value: " team-1 " },
{ id: "key_hash", value: " " },
{ id: "status", value: 42 },
];
expect(result.current.filteredLogs).toBeDefined();
expect(result.current.filteredLogs.data).toEqual([]);
});
expect(getFilterValue(filters, "team_id")).toBe("team-1");
expect(getFilterValue(filters, "key_hash")).toBeUndefined();
expect(getFilterValue(filters, "status")).toBeUndefined();
expect(getFilterValue(filters, "missing")).toBeUndefined();
});
});
describe("getLiveTailRefetchInterval", () => {
it("polls every 15s when live tail is on and on page 1", () => {
expect(getLiveTailRefetchInterval(true, 1)).toBe(LIVE_TAIL_INTERVAL_MS);
it("polls every 15s when live tail is on and on the first page", () => {
expect(getLiveTailRefetchInterval(true, 0)).toBe(LIVE_TAIL_INTERVAL_MS);
});
it("does not poll when live tail is off", () => {
expect(getLiveTailRefetchInterval(false, 1)).toBe(false);
expect(getLiveTailRefetchInterval(false, 0)).toBe(false);
});
it("does not poll when not on page 1, even with live tail on", () => {
expect(getLiveTailRefetchInterval(true, 2)).toBe(false);
it("does not poll past the first page, even with live tail on", () => {
expect(getLiveTailRefetchInterval(true, 1)).toBe(false);
});
});

View file

@ -1,13 +1,11 @@
import moment from "moment";
import { useEffect, useMemo, useState } from "react";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { keepPreviousData, useQuery, type UseQueryOptions } from "@tanstack/react-query";
import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { defaultPageSize } from "../constants";
import type { LogEntry, LogsSortField } from "./columns";
import { LOGS_SORT_FIELD_MAP, type LogEntry, type LogsSortField } from "./columns";
export interface PaginatedResponse {
data: LogEntry[];
@ -18,54 +16,48 @@ export interface PaginatedResponse {
total_is_capped?: boolean;
}
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
export const FILTER_KEYS = {
TEAM_ID: "Team ID",
KEY_HASH: "Key Hash",
REQUEST_ID: "Request ID",
SESSION_ID: "Session ID",
MODEL: "Model",
/** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */
PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool",
USER_ID: "User ID",
END_USER: "End User",
STATUS: "Status",
KEY_ALIAS: "Key Alias",
ERROR_CODE: "Error Code",
ERROR_MESSAGE: "Error Message",
export const LOG_FILTER_IDS = {
TEAM_ID: "team_id",
STATUS: "status",
KEY_ALIAS: "key_alias",
END_USER: "end_user",
ERROR_CODE: "error_code",
ERROR_MESSAGE: "error_message",
KEY_HASH: "key_hash",
SESSION_ID: "session_id",
MODEL_ID: "model_id",
PUBLIC_MODEL_OR_SEARCH_TOOL: "model",
REQUEST_ID: "request_id",
USER_ID: "user_id",
} as const;
export type FilterKey = keyof typeof FILTER_KEYS;
export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>;
export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.END_USER]: "End User",
[LOG_FILTER_IDS.ERROR_CODE]: "Error Code",
[LOG_FILTER_IDS.ERROR_MESSAGE]: "Error Message",
[LOG_FILTER_IDS.KEY_HASH]: "Key Hash",
[LOG_FILTER_IDS.SESSION_ID]: "Session ID",
[LOG_FILTER_IDS.MODEL_ID]: "Model",
[LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool",
};
// Keys whose UI is a free-form text input; only these need debouncing.
const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [
FILTER_KEYS.KEY_HASH,
FILTER_KEYS.ERROR_MESSAGE,
FILTER_KEYS.REQUEST_ID,
FILTER_KEYS.SESSION_ID,
FILTER_KEYS.USER_ID,
FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
];
// Live-tail polls every 15s, but only on page 1 (newest) while live tail is on.
export const LIVE_TAIL_INTERVAL_MS = 15000;
export const getLiveTailRefetchInterval = (isLiveTail: boolean, currentPage: number): number | false =>
isLiveTail && currentPage === 1 ? LIVE_TAIL_INTERVAL_MS : false;
export const defaultFilters: LogFilterState = {
[FILTER_KEYS.TEAM_ID]: "",
[FILTER_KEYS.KEY_HASH]: "",
[FILTER_KEYS.REQUEST_ID]: "",
[FILTER_KEYS.SESSION_ID]: "",
[FILTER_KEYS.MODEL]: "",
[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "",
[FILTER_KEYS.USER_ID]: "",
[FILTER_KEYS.END_USER]: "",
[FILTER_KEYS.STATUS]: "",
[FILTER_KEYS.KEY_ALIAS]: "",
[FILTER_KEYS.ERROR_CODE]: "",
[FILTER_KEYS.ERROR_MESSAGE]: "",
export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false =>
isLiveTail && pageIndex === 0 ? LIVE_TAIL_INTERVAL_MS : false;
export const DEFAULT_LOGS_SORTING: SortingState = [{ id: "startTime", desc: true }];
const isSortField = (id: string): id is LogsSortField => Object.hasOwn(LOGS_SORT_FIELD_MAP, id);
export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => {
const entry = columnFilters.find((filter) => filter.id === columnId);
if (typeof entry?.value !== "string") return undefined;
const trimmed = entry.value.trim();
return trimmed === "" ? undefined : trimmed;
};
export function useLogFilterLogic({
@ -73,63 +65,45 @@ export function useLogFilterLogic({
token,
userRole,
userID,
filters,
setFilters,
columnFilters,
filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
endTime,
pageSize = defaultPageSize,
pagination,
isCustomDate,
setCurrentPage,
sortBy = "startTime",
sortOrder = "desc",
currentPage = 1,
sorting,
}: {
accessToken: string | null;
token: string | null;
userRole: string | null;
userID: string | null;
filters: LogFilterState;
setFilters: React.Dispatch<React.SetStateAction<LogFilterState>>;
columnFilters: ColumnFiltersState;
filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
endTime: string;
pageSize?: number;
pagination: PaginationState;
isCustomDate: boolean;
setCurrentPage: (page: number) => void;
sortBy?: LogsSortField;
sortOrder?: "asc" | "desc";
currentPage?: number;
sorting: SortingState;
}) {
const [debouncedFilters, setDebouncedFilters] = useState(filters);
const debouncer = useDebouncer(setDebouncedFilters, { wait: DEBOUNCE_WAIT_MS });
useEffect(() => {
debouncer.maybeExecute(filters);
}, [filters, debouncer]);
const pageSize = pagination.pageSize || defaultPageSize;
const activeSort = sorting[0] ?? DEFAULT_LOGS_SORTING[0];
const sortBy: LogsSortField = isSortField(activeSort.id) ? activeSort.id : "startTime";
const sortOrder: "asc" | "desc" = activeSort.desc ? "desc" : "asc";
// Live values for dropdown keys, debounced for text keys.
const effectiveFilters = useMemo(() => {
const merged = { ...filters };
for (const k of TEXT_FILTER_KEYS) {
merged[k] = debouncedFilters[k];
}
return merged;
}, [filters, debouncedFilters]);
const logsQuery = useQuery<PaginatedResponse>({
const logsQueryOptions: UseQueryOptions<PaginatedResponse> = {
queryKey: [
"logs",
"table",
currentPage,
pagination.pageIndex,
pageSize,
startTime,
endTime,
isCustomDate,
effectiveFilters,
columnFilters,
filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
@ -150,38 +124,39 @@ export function useLogFilterLogic({
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
const response = await uiSpendLogsCall({
const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID);
return await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page: currentPage,
page: pagination.pageIndex + 1,
page_size: pageSize,
params: {
api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined,
session_id: effectiveFilters[FILTER_KEYS.SESSION_ID] || undefined,
user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined),
end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined,
status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined,
model_id: effectiveFilters[FILTER_KEYS.MODEL] || undefined,
model: effectiveFilters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined,
key_alias: effectiveFilters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: effectiveFilters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: effectiveFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH),
team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID),
request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID),
session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID),
user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined),
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE),
error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE),
sort_by: sortBy,
sort_order: sortOrder,
},
});
return response;
},
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage),
refetchInterval: getLiveTailRefetchInterval(isLiveTail, pagination.pageIndex),
placeholderData: keepPreviousData,
// Only live-tail-poll while the tab is visible.
refetchIntervalInBackground: false,
});
};
const logsQuery = useQuery(logsQueryOptions);
const filteredLogs: PaginatedResponse = logsQuery.data ?? {
data: [],
@ -191,7 +166,7 @@ export function useLogFilterLogic({
total_pages: 0,
};
const { data: allTeams } = useQuery<Team[], Error>({
const allTeamsQueryOptions: UseQueryOptions<Team[], Error> = {
queryKey: ["allTeamsForLogFilters", accessToken],
queryFn: async () => {
if (!accessToken) return [];
@ -199,34 +174,13 @@ export function useLogFilterLogic({
return teamsData || [];
},
enabled: !!accessToken,
});
const handleFilterChange = (newFilters: Partial<LogFilterState>) => {
setFilters((prev) => {
const updatedFilters = { ...prev, ...newFilters };
for (const key of Object.keys(defaultFilters) as Array<keyof LogFilterState>) {
if (!(key in updatedFilters)) {
updatedFilters[key] = defaultFilters[key];
}
}
if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) {
setCurrentPage(1);
}
return updatedFilters as LogFilterState;
});
};
const handleFilterReset = () => {
setFilters(defaultFilters);
setDebouncedFilters(defaultFilters);
setCurrentPage(1);
};
const { data: allTeams } = useQuery(allTeamsQueryOptions);
return {
logsQuery,
filteredLogs,
allTeams,
handleFilterChange,
handleFilterReset,
};
}