[Refactor] UI - Spend Logs: consolidate filter state and extract components (#25847)

* [Refactor] UI - Spend Logs: consolidate filter state, extract components, remove dead code

- Lift filter state into index.tsx and pass to hook (removes selectedX vars + sync useEffect)
- Move main useQuery into useLogFilterLogic hook (removes isMainQueryEnabled toggle)
- Delete dead RequestViewer component (300 lines, replaced by LogDetailsDrawer)
- Extract LogsTableToolbar component (search, date range, pagination, live tail)
- Extract filter options config to filter_options.ts
- Remove dead code: handleRefresh, handleSelectLog, handleCloseDrawer, formatTimeUnit,
  showFilters/showColumnDropdown state, dropdownRef/filtersRef

* Fix PR feedback: use antd Switch instead of Tremor in new file, fix typo

* Collapse dual-path filtering into single React Query

All 10 filter keys now go through the useQuery — the imperative
performSearch / debouncedSearch / backendFilteredLogs path is deleted.
Filter values are debounced via useDebouncedValue(300ms) before hitting
the query key so text inputs don't fire per-keystroke.

Removed: performSearch, debouncedSearch, backendFilteredLogs,
lastSearchTimestamp, hasBackendFilters, clientDerivedFilteredLogs,
the sort/page/time refetch useEffect, and the filteredLogs chooser memo.

* Clean up remaining smells: remove isFetchingDeferred, internalize selectedTimeInterval, fix circular import

- Remove useDeferredValue/isButtonLoading — pass logsQuery.isFetching directly
- Move selectedTimeInterval into LogsTableToolbar as internal state
- Move PaginatedResponse type from index.tsx to log_filter_logic.tsx

* Fix quick-select dropdown overlapping sidebar

* Fix stale quick-select label after Reset Filters

Move selectedTimeInterval back to parent so handleFilterReset can
reset it to the 24-hour default. The toolbar receives it as a prop.

* refactor useLogFilterLogic tests for controlled-hook + backend-query shape

The hook no longer owns filter state or does client-side filtering — it
receives filters/setFilters as props and drives filteredLogs from a
useQuery over uiSpendLogsCall. Reshape the tests around that contract:
introduce a controlled harness that owns filter state, collapse the 10
per-filter assertions into a single it.each over filterKey → API param,
and drop the client-side passthrough tests (the .min test file and the
"return all logs when no filters" / "empty when logs null" cases) that
no longer correspond to any hook behavior.

* cover new useLogFilterLogic invariants: activeTab gate, filterByCurrentUser fallback, debounce negative, partial merge

Follow-up to the test refactor. Adds coverage for invariants the
refactored hook contract introduced but that the first pass didn't
assert:

- query enablement: expand the single accessToken-null case into an
  it.each over all four credential props (accessToken, token, userRole,
  userID), plus a separate test for activeTab !== "request logs"
- filterByCurrentUser: when true with a blank User ID filter, the
  outbound request carries user_id = userID
- debounce: also assert the negative case — no call in the first 100ms
  after a filter change (first waiting out the initial mount fire)
- handleFilterChange: partial updates merge without clobbering other
  filter keys (protects the spread + default-fill semantics)
- handleFilterReset: calls setCurrentPage(1) alongside restoring
  filters

* fix typo dropping the live-tail banner border

Tailwind silently ignores unknown classes, so border-greem-200 was
leaving the auto-refresh banner with only its bg-green-50 fill and no
outline.

* memoize columns and derived table data in SpendLogsTable

The table's columns array, four-pass data pipeline, and sort-change
handler were all being rebuilt on every parent render. That made every
filter click re-instance all 23 TanStack-Table columns, re-run
filter/reduce/map over all rows, and recreate per-row click closures —
all before the intentional 300ms debounce timer even got a chance to
fire.

Local measurement (40 rows, dev mode):

    filter click → query fires: 1957ms → 1217ms (−38%)

Wrap createColumns in useMemo keyed on sortBy/sortOrder, hoist
onSortChange into a useCallback, and move the searchedLogs /
sessionComposition / sessionRepresentativeMap / filteredData derivations
into a single useMemo keyed on filteredLogs.data + searchTerm.

These were pre-existing issues on main — not regressions from the
hook refactor — but the refactor made them user-visible because the
new query debounce put render cost on the critical path.

* apply dropdown filters instantly, debounce only text inputs

Dropdown selects now bypass the 300ms debounce so a click updates the
table immediately. Text inputs (Key Hash, Error Message, Request ID,
User ID) still debounce. handleFilterReset also clears the pending
debounced value so a half-typed text filter can't re-fire after reset.

* fix(ui/spend-logs): restore lost loading/debounce behavior + cover dropped tests

Regressions from the spend-logs-view refactor:
- debounce the 'Public model / search tool' text filter (was firing a
  backend query per keystroke) via TEXT_FILTER_KEYS
- restore Fetch-button smoothing through table repaint using
  useDeferredValue on the rendered data (explicit staleness)
- show AntDLoadingSpinner during the auth-resolve phase instead of a
  blank screen on first load
- only live-tail-poll while the tab is visible
  (refetchIntervalInBackground: false)
- extract getLiveTailRefetchInterval helper for the poll decision

Tests:
- LogDetailContent: retries display (>0 / 0 / absent), overhead-absent
- log_filter_logic: regression guard that the public-model filter
  debounces; getLiveTailRefetchInterval unit tests
- logs_utils: getTimeRangeDisplay quick-select window labels

* test(ui/spend-logs): cover the cold-load auth-not-ready spinner guard

Asserts SpendLogsTable shows a loading spinner (not a blank screen)
while credentials are unresolved, and renders the table once present.
This commit is contained in:
ryan-crabbe-berri 2026-05-19 10:58:48 -07:00 committed by GitHub
parent cff3e0b75e
commit 727a471ae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1303 additions and 2092 deletions

View File

@ -172,12 +172,7 @@ describe("LogDetailContent", () => {
});
it("should display loading state when isLoadingDetails is true", () => {
render(
<LogDetailContent
logEntry={createLogEntry()}
isLoadingDetails={true}
/>,
);
render(<LogDetailContent logEntry={createLogEntry()} isLoadingDetails={true} />);
expect(screen.getByText("Loading request & response data...")).toBeInTheDocument();
});
@ -298,6 +293,37 @@ describe("LogDetailContent", () => {
expect(screen.getByText("42.50 ms")).toBeInTheDocument();
});
it("should not display LiteLLM Overhead when litellm_overhead_time_ms is absent from metadata", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument();
});
const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement;
it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => {
render(
<LogDetailContent
logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 2, max_retries: 3 } })}
/>,
);
expect(within(retriesItem()).getByText("2 / 3")).toBeInTheDocument();
});
it("should display a green 'None' tag for Retries when attempted_retries is 0", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 0 } })} />);
const noneTag = within(retriesItem()).getByText("None");
expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green");
});
it("should display '-' for Retries when attempted_retries is absent from metadata", () => {
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
expect(within(retriesItem()).getByText("-")).toBeInTheDocument();
});
it("should display start and end time in ISO format", () => {
render(
<LogDetailContent

View File

@ -0,0 +1,243 @@
import moment from "moment";
import { useEffect, useRef, useState } from "react";
import { SyncOutlined } from "@ant-design/icons";
import { Button, Switch } from "antd";
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;
onEndTimeChange: (value: string) => void;
isCustomDate: boolean;
onIsCustomDateChange: (value: boolean) => void;
selectedTimeInterval: { value: number; unit: string };
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;
}
export function LogsTableToolbar({
searchTerm,
onSearchChange,
startTime,
onStartTimeChange,
endTime,
onEndTimeChange,
isCustomDate,
onIsCustomDateChange,
selectedTimeInterval,
onSelectedTimeIntervalChange,
isLiveTail,
onIsLiveTailChange,
currentPage,
onCurrentPageChange,
pageSize,
isLoading,
isButtonLoading,
onRefetch,
filteredLogs,
}: 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 selectedOption = QUICK_SELECT_OPTIONS.find(
(option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit,
);
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 flex-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-none 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 flex-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>
<Button
type="default"
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={onRefetch}
disabled={isButtonLoading}
title="Fetch data"
>
{isButtonLoading ? "Fetching" : "Fetch"}
</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-none 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-none 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">
Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "}
{isLoading
? "..."
: filteredLogs
? Math.min(currentPage * pageSize, filteredLogs.total)
: 0}{" "}
of {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} 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}
</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>
</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>
</div>
)}
</>
);
}

View File

@ -0,0 +1,77 @@
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: "Model",
label: "Model",
customComponent: PaginatedModelSelect,
},
{
name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL,
label: "Public model / search tool",
isSearchable: false,
},
{
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: "Key Hash",
label: "Key Hash",
isSearchable: false,
},
{
name: "Error Message",
label: "Error Message",
isSearchable: false,
},
];
}

View File

@ -1,12 +1,8 @@
import { render, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpendLogsTable, { RequestViewer } from "./index";
import type { LogEntry } from "./columns";
import type { Row } from "@tanstack/react-table";
import SpendLogsTable from "./index";
import { renderWithProviders } from "../../../tests/test-utils";
import { uiSpendLogsCall } from "../networking";
const mockHandleFilterResetFromHook = vi.fn();
vi.mock("./log_filter_logic", async (importOriginal) => {
@ -14,14 +10,8 @@ vi.mock("./log_filter_logic", async (importOriginal) => {
return {
...actual,
useLogFilterLogic: vi.fn(() => ({
filters: {},
filteredLogs: {
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 1,
},
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,
@ -50,139 +40,6 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
const baseLogEntry: LogEntry = {
request_id: "chatcmpl-test-id",
api_key: "api-key",
team_id: "team-id",
model: "gpt-4",
model_id: "gpt-4",
call_type: "chat",
spend: 0,
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
startTime: "2025-11-14T00:00:00Z",
endTime: "2025-11-14T00:00:00Z",
cache_hit: "miss",
request_duration_ms: 1000,
messages: [{ role: "user", content: "hello" }],
response: { status: "ok" },
metadata: {
status: "success",
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
request_tags: {},
custom_llm_provider: "openai",
api_base: "https://api.example.com",
};
const createRow = (overrides: Partial<LogEntry> = {}): Row<LogEntry> =>
({
original: {
...baseLogEntry,
...overrides,
},
}) as unknown as Row<LogEntry>;
describe("Request Viewer", () => {
it("renders the request details heading", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.getByText("Request Details")).toBeInTheDocument();
});
it("should truncate the request id if it is longer than 64 characters", () => {
const LONG_REQUEST_ID = "a".repeat(128);
const TRUNCATED_REQUEST_ID = `${"a".repeat(64)}...`;
render(
<RequestViewer
row={createRow({
request_id: LONG_REQUEST_ID,
})}
/>,
);
expect(screen.getByText(TRUNCATED_REQUEST_ID)).toBeInTheDocument();
});
it("should display LiteLLM Overhead when litellm_overhead_time_ms is present in metadata", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
litellm_overhead_time_ms: 150,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("LiteLLM Overhead:")).toBeInTheDocument();
expect(screen.getByText("150 ms")).toBeInTheDocument();
});
it("should not display LiteLLM Overhead when litellm_overhead_time_ms is not present in metadata", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.queryByText("LiteLLM Overhead:")).not.toBeInTheDocument();
});
it("should display retry count when attempted_retries > 0 in metadata", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
attempted_retries: 2,
max_retries: 3,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("2 / 3")).toBeInTheDocument();
});
it("should display green 'None' tag when attempted_retries is 0", () => {
render(
<RequestViewer
row={createRow({
metadata: {
status: "success",
attempted_retries: 0,
max_retries: 3,
additional_usage_values: {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
})}
/>,
);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("None")).toBeInTheDocument();
});
it("should display '-' for Retries when attempted_retries is not present in metadata", () => {
render(<RequestViewer row={createRow()} />);
expect(screen.getByText("Retries:")).toBeInTheDocument();
expect(screen.getByText("-")).toBeInTheDocument();
});
});
describe("SpendLogsTable", () => {
const defaultProps = {
accessToken: "test-token",
@ -215,7 +72,9 @@ describe("SpendLogsTable", () => {
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 });
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
@ -241,51 +100,19 @@ describe("SpendLogsTable", () => {
});
});
describe("Quick Select time range", () => {
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 current time
expect(diff).toBeGreaterThanOrEqual(minMinutes * 60);
expect(diff).toBeLessThan((minMinutes + 1) * 60);
});
return diff;
};
describe("auth-not-ready guard", () => {
it("shows a loading spinner when credentials are not yet resolved", () => {
renderWithProviders(<SpendLogsTable {...defaultProps} accessToken={null} />);
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);
expect(document.querySelector(".ant-spin")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument();
});
it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => {
const user = userEvent.setup();
it("renders the table (no spinner) once all credentials are present", () => {
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(document.querySelector(".ant-spin")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,28 @@
import moment from "moment";
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
import { PaginatedResponse } from ".";
import type { LogsSortField } from "./columns";
import type { LogEntry, LogsSortField } from "./columns";
export interface PaginatedResponse {
data: LogEntry[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
function useDebouncedValue<T>(value: T, delayMs: number): [T, React.Dispatch<React.SetStateAction<T>>] {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return [debounced, setDebounced];
}
/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */
export const FILTER_KEYS = {
@ -28,324 +43,188 @@ export const FILTER_KEYS = {
export type FilterKey = keyof typeof FILTER_KEYS;
export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>;
// 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.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.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 function useLogFilterLogic({
logs,
accessToken,
startTime, // Receive from SpendLogsTable
endTime, // Receive from SpendLogsTable
token,
userRole,
userID,
filters,
setFilters,
filterByCurrentUser,
activeTab,
isLiveTail,
startTime,
endTime,
pageSize = defaultPageSize,
isCustomDate,
setCurrentPage,
userID,
userRole,
sortBy = "startTime",
sortOrder = "desc",
currentPage = 1,
}: {
logs: PaginatedResponse;
accessToken: string | null;
token: string | null;
userRole: string | null;
userID: string | null;
filters: LogFilterState;
setFilters: React.Dispatch<React.SetStateAction<LogFilterState>>;
filterByCurrentUser: boolean | null;
activeTab: string;
isLiveTail: boolean;
startTime: string;
endTime: string;
pageSize?: number;
isCustomDate: boolean;
setCurrentPage: (page: number) => void;
userID: string | null;
userRole: string | null;
sortBy?: LogsSortField;
sortOrder?: "asc" | "desc";
currentPage?: number;
}) {
const defaultFilters = useMemo<LogFilterState>(
() => ({
[FILTER_KEYS.TEAM_ID]: "",
[FILTER_KEYS.KEY_HASH]: "",
[FILTER_KEYS.REQUEST_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]: "",
}),
[],
);
const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300);
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [backendFilteredLogs, setBackendFilteredLogs] = useState<PaginatedResponse | null>(null);
const lastSearchTimestamp = useRef(0);
// 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]);
// Refs that always hold the latest filters and hasBackendFilters values.
// The sort/page/time effect below intentionally omits these from its dep array
// to avoid double-fetches when a filter changes; reading from refs instead of
// the closure prevents stale-closure bugs (e.g. the effect using a snapshot of
// filters taken before the user selected Key Alias).
const filtersRef = useRef(filters);
const hasBackendFiltersRef = useRef(false);
const performSearch = useCallback(
async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
console.log("Filters being sent to API:", filters);
const currentTimestamp = Date.now();
lastSearchTimestamp.current = currentTimestamp;
const logsQuery = useQuery<PaginatedResponse>({
queryKey: [
"logs",
"table",
currentPage,
pageSize,
startTime,
endTime,
isCustomDate,
effectiveFilters,
filterByCurrentUser ? userID : null,
sortBy,
sortOrder,
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = isCustomDate
? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss")
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
try {
const response = await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page,
page_size: pageSize,
params: {
api_key: filters[FILTER_KEYS.KEY_HASH] || undefined,
team_id: filters[FILTER_KEYS.TEAM_ID] || undefined,
request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined,
user_id: filters[FILTER_KEYS.USER_ID] || undefined,
end_user: filters[FILTER_KEYS.END_USER] || undefined,
status_filter: filters[FILTER_KEYS.STATUS] || undefined,
model_id: filters[FILTER_KEYS.MODEL] || undefined,
model: filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined,
key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined,
error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined,
error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
sort_by: sortBy,
sort_order: sortOrder,
},
});
const response = await uiSpendLogsCall({
accessToken,
start_date: formattedStartTime,
end_date: formattedEndTime,
page: currentPage,
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,
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,
sort_by: sortBy,
sort_order: sortOrder,
},
});
if (currentTimestamp === lastSearchTimestamp.current) {
setBackendFilteredLogs({
...response,
data: response.data ?? [],
});
}
} catch (error) {
console.error("Error searching users:", error);
setBackendFilteredLogs({
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
});
}
return response;
},
[accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder],
);
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage),
placeholderData: keepPreviousData,
// Only live-tail-poll while the tab is visible.
refetchIntervalInBackground: false,
});
const debouncedSearch = useMemo(
() => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300),
[performSearch],
);
const filteredLogs: PaginatedResponse = logsQuery.data ?? {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
// Determine when backend filters are active (server-side filtering)
const hasBackendFilters = useMemo(
() =>
!!(
filters[FILTER_KEYS.KEY_ALIAS] ||
filters[FILTER_KEYS.KEY_HASH] ||
filters[FILTER_KEYS.REQUEST_ID] ||
filters[FILTER_KEYS.USER_ID] ||
filters[FILTER_KEYS.END_USER] ||
filters[FILTER_KEYS.ERROR_CODE] ||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
filters[FILTER_KEYS.MODEL] ||
filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]
),
[filters],
);
// Keep refs in sync on every render so the sort/page/time effect always reads
// the latest values without those values being in its dep array.
useEffect(() => {
filtersRef.current = filters;
hasBackendFiltersRef.current = hasBackendFilters;
}, [filters, hasBackendFilters]);
// Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query)
useEffect(() => {
if (hasBackendFiltersRef.current && accessToken) {
// Cancel any pending debounced search to prevent it from overwriting this page's results
debouncedSearch.cancel();
performSearch(filtersRef.current, currentPage);
}
// filters / hasBackendFilters are read via refs — avoids stale-closure bugs
// when sort/page/time changes after a filter (e.g. Key Alias) was set.
// debouncedSearch / performSearch: filter changes go through handleFilterChange
// → debouncedSearch; adding them here would cause double-fetches on filter apply.
// accessToken: stable across sort/page/time changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]);
// Compute client-side filtered logs directly from incoming logs and filters
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
if (!logs || !logs.data) {
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
// If backend filters are on, don't perform client-side filtering here
if (hasBackendFilters) {
return logs;
}
let filteredData = [...logs.data];
if (filters[FILTER_KEYS.TEAM_ID]) {
filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]);
}
if (filters[FILTER_KEYS.STATUS]) {
filteredData = filteredData.filter((log) => {
if (filters[FILTER_KEYS.STATUS] === "success") {
return !log.status || log.status === "success";
}
return log.status === filters[FILTER_KEYS.STATUS];
});
}
if (filters[FILTER_KEYS.MODEL]) {
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
}
if (filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]) {
const m = filters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL];
filteredData = filteredData.filter((log) => log.model === m);
}
if (filters[FILTER_KEYS.KEY_HASH]) {
filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]);
}
if (filters[FILTER_KEYS.END_USER]) {
filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]);
}
if (filters[FILTER_KEYS.ERROR_CODE]) {
filteredData = filteredData.filter((log) => {
const metadata = log.metadata || {};
const errorInfo = metadata.error_information;
return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE];
});
}
return {
data: filteredData,
total: logs.total,
page: logs.page,
page_size: logs.page_size,
total_pages: logs.total_pages,
};
}, [logs, filters, hasBackendFilters]);
// Choose which filtered logs to expose: backend result when active, otherwise client-derived
const filteredLogs: PaginatedResponse = useMemo(() => {
if (hasBackendFilters) {
// When backend filters are active, only show backend results.
// If search hasn't completed yet (null), show empty state rather than
// falling back to unfiltered logs — that caused filtered views to
// display mismatched data when the filter matched zero rows.
if (backendFilteredLogs !== null) {
return backendFilteredLogs;
}
return {
data: [],
total: 0,
page: 1,
page_size: pageSize,
total_pages: 0,
};
}
return clientDerivedFilteredLogs;
}, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs]);
// Fetch all teams and users for potential filter dropdowns (optional, can be adapted)
const { data: allTeams } = useQuery<Team[], Error>({
queryKey: ["allTeamsForLogFilters", accessToken],
queryFn: async () => {
if (!accessToken) return [];
// Use fetchAllTeams helper function for consistency and abstraction
// Assuming fetchAllTeams returns Team[] directly
const teamsData = await fetchAllTeams(accessToken);
return teamsData || []; // Ensure it returns an array
return teamsData || [];
},
enabled: !!accessToken,
});
// Update filters state
const handleFilterChange = (newFilters: Partial<LogFilterState>) => {
setFilters((prev) => {
const updatedFilters = { ...prev, ...newFilters };
// Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters
for (const key of Object.keys(defaultFilters) as Array<keyof LogFilterState>) {
if (!(key in updatedFilters)) {
updatedFilters[key] = defaultFilters[key];
}
}
// Only call debouncedSearch if filters have actually changed
if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) {
setCurrentPage(1);
setBackendFilteredLogs(null);
debouncedSearch(updatedFilters, 1);
}
return updatedFilters as LogFilterState;
});
};
const handleFilterReset = () => {
// Reset filters state
setFilters(defaultFilters);
// Clear backend filtered logs to ensure fresh render
setBackendFilteredLogs(null);
// Cancel any in-flight debounced search
debouncedSearch.cancel();
// Reset to first page so the unfiltered view starts at page 1
setDebouncedFilters(defaultFilters);
setCurrentPage(1);
};
// Expose a filter-aware refetch so callers (e.g. the manual Fetch button) can
// refresh results while keeping all active backend filters intact. The plain
// `logs.refetch()` in the parent only re-runs the main TanStack Query, which
// does not carry key_alias or other backend-only filter params.
const refetchWithFilters = useCallback(
(page = currentPage) => {
if (hasBackendFilters && accessToken) {
debouncedSearch.cancel();
performSearch(filters, page);
}
},
[hasBackendFilters, accessToken, filters, currentPage, performSearch, debouncedSearch],
);
return {
filters,
logsQuery,
filteredLogs,
hasBackendFilters,
allTeams,
handleFilterChange,
handleFilterReset,
refetchWithFilters,
};
}

View File

@ -0,0 +1,45 @@
import moment from "moment";
import { describe, expect, it } from "vitest";
import { getTimeRangeDisplay } from "./logs_utils";
// startTime built relative to "now"; getTimeRangeDisplay computes now() internally.
const ago = (amount: number, unit: moment.unitOfTime.DurationConstructor) =>
moment().subtract(amount, unit).toISOString();
describe("getTimeRangeDisplay", () => {
it("labels a ~1-minute window as 'Last 1 Minute'", () => {
expect(getTimeRangeDisplay(false, ago(1, "minutes"), "")).toBe("Last 1 Minute");
});
it("labels a ~10-minute window as 'Last 15 Minutes'", () => {
expect(getTimeRangeDisplay(false, ago(10, "minutes"), "")).toBe("Last 15 Minutes");
});
it("labels a ~30-minute window as 'Last Hour'", () => {
expect(getTimeRangeDisplay(false, ago(30, "minutes"), "")).toBe("Last Hour");
});
it("labels a ~2-hour window as 'Last 4 Hours'", () => {
expect(getTimeRangeDisplay(false, ago(2, "hours"), "")).toBe("Last 4 Hours");
});
it("labels a ~10-hour window as 'Last 24 Hours'", () => {
expect(getTimeRangeDisplay(false, ago(10, "hours"), "")).toBe("Last 24 Hours");
});
it("labels a ~3-day window as 'Last 7 Days'", () => {
expect(getTimeRangeDisplay(false, ago(3, "days"), "")).toBe("Last 7 Days");
});
it("falls back to a 'MMM D - MMM D' range beyond 7 days", () => {
const label = getTimeRangeDisplay(false, ago(30, "days"), "");
expect(label).toMatch(/^[A-Z][a-z]{2} \d{1,2} - [A-Z][a-z]{2} \d{1,2}$/);
});
it("renders an explicit start - end range when isCustomDate is true", () => {
const start = "2025-01-02T03:04:00Z";
const end = "2025-01-05T06:07:00Z";
const expected = `${moment(start).format("MMM D, h:mm A")} - ${moment(end).format("MMM D, h:mm A")}`;
expect(getTimeRangeDisplay(true, start, end)).toBe(expected);
});
});

View File

@ -1,62 +0,0 @@
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useLogFilterLogic } from "../../src/components/view_logs/log_filter_logic";
// Minimal mocks to avoid real network during hook init
vi.mock("../../src/components/key_team_helpers/filter_helpers", () => ({
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
fetchAllTeams: vi.fn().mockResolvedValue([]),
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
function Harness({ logs }: { logs: any }) {
const { filteredLogs } = useLogFilterLogic({
logs,
accessToken: "token",
startTime: "2025-01-01 00:00:00",
endTime: "2025-01-02 00:00:00",
pageSize: 50,
isCustomDate: true,
setCurrentPage: () => {},
userID: "user-1",
userRole: "admin",
});
return <div data-testid="count">{filteredLogs.data.length}</div>;
}
describe("useLogFilterLogic (minimal)", () => {
it("useLogFilterLogic minimal: updates filteredLogs when logs change", async () => {
const qc = createQueryClient();
const logsA = { data: [{ request_id: "a" }], total: 1, page: 1, page_size: 50, total_pages: 1 };
const logsB = {
data: [{ request_id: "a" }, { request_id: "b" }],
total: 2,
page: 1,
page_size: 50,
total_pages: 1,
};
const { rerender } = render(
<QueryClientProvider client={qc}>
<Harness logs={logsA} />
</QueryClientProvider>,
);
expect(await screen.findByTestId("count")).toHaveTextContent("1");
rerender(
<QueryClientProvider client={qc}>
<Harness logs={logsB} />
</QueryClientProvider>,
);
expect(await screen.findByTestId("count")).toHaveTextContent("2");
});
});