[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:
parent
cff3e0b75e
commit
727a471ae9
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user