mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
Merge pull request #21143 from BerriAI/litellm_model_id_search
[Feature] UI - Spend Logs: Sorting Columns
This commit is contained in:
commit
6eeb9b109b
6 changed files with 910 additions and 186 deletions
|
|
@ -1,87 +0,0 @@
|
|||
import { uiSpendLogsCall } from '../../../ui/litellm-dashboard/src/components/networking';
|
||||
|
||||
// Mock the networking module
|
||||
jest.mock('../../../ui/litellm-dashboard/src/components/networking', () => ({
|
||||
uiSpendLogsCall: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUiSpendLogsCall = uiSpendLogsCall as jest.MockedFunction<typeof uiSpendLogsCall>;
|
||||
|
||||
describe('Key Alias Filtering Integration Test', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call API with correct key_alias parameter', async () => {
|
||||
// Mock API response with both success and failure logs
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{ request_id: 'req-1', status: 'success', metadata: { user_api_key_alias: 'test-key' } },
|
||||
{ request_id: 'req-2', status: 'failure', metadata: { user_api_key_alias: 'test-key' } }
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 1
|
||||
};
|
||||
|
||||
mockUiSpendLogsCall.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
// Simulate the API call that would happen when filtering by key alias
|
||||
const result = await uiSpendLogsCall(
|
||||
'test-token',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'2024-01-15 09:00:00',
|
||||
'2024-01-15 11:00:00',
|
||||
1,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'test-key-alias' // key_alias - this is the fix
|
||||
);
|
||||
|
||||
// Verify the API was called correctly
|
||||
expect(mockUiSpendLogsCall).toHaveBeenCalledWith(
|
||||
'test-token',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'2024-01-15 09:00:00',
|
||||
'2024-01-15 11:00:00',
|
||||
1,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'test-key-alias' // The key assertion - this parameter should be passed through
|
||||
);
|
||||
|
||||
// Verify response contains both success and failure logs
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].status).toBe('success');
|
||||
expect(result.data[1].status).toBe('failure');
|
||||
});
|
||||
|
||||
it('should pass undefined for empty key alias', async () => {
|
||||
mockUiSpendLogsCall.mockResolvedValueOnce({ data: [], total: 0, page: 1, page_size: 50, total_pages: 0 });
|
||||
|
||||
await uiSpendLogsCall(
|
||||
'test-token', undefined, undefined, undefined,
|
||||
'2024-01-15 09:00:00', '2024-01-15 11:00:00',
|
||||
1, 50, undefined, undefined, undefined, undefined,
|
||||
undefined // Empty string should become undefined
|
||||
);
|
||||
|
||||
expect(mockUiSpendLogsCall).toHaveBeenCalledWith(
|
||||
'test-token', undefined, undefined, undefined,
|
||||
'2024-01-15 09:00:00', '2024-01-15 11:00:00',
|
||||
1, 50, undefined, undefined, undefined, undefined,
|
||||
undefined // Should be undefined for empty key alias
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2581,46 +2581,66 @@ export const userSpendLogsCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const uiSpendLogsCall = async (
|
||||
accessToken: string,
|
||||
api_key?: string,
|
||||
team_id?: string,
|
||||
request_id?: string,
|
||||
start_date?: string,
|
||||
end_date?: string,
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
user_id?: string,
|
||||
end_user?: string,
|
||||
status_filter?: string,
|
||||
model?: string,
|
||||
modelId?: string,
|
||||
keyAlias?: string,
|
||||
error_code?: string,
|
||||
error_message?: string,
|
||||
) => {
|
||||
/**
|
||||
* Optional query params for /spend/logs/ui - matches backend spend_management_endpoints.py
|
||||
*/
|
||||
export interface UiSpendLogsParams {
|
||||
api_key?: string;
|
||||
team_id?: string;
|
||||
request_id?: string;
|
||||
user_id?: string;
|
||||
end_user?: string;
|
||||
status_filter?: string;
|
||||
/** Filter by model name (e.g. "gpt-4") */
|
||||
model?: string;
|
||||
/** Filter by model ID (litellm model deployment id) */
|
||||
model_id?: string;
|
||||
key_alias?: string;
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: "asc" | "desc";
|
||||
min_spend?: number;
|
||||
max_spend?: number;
|
||||
}
|
||||
|
||||
export interface UiSpendLogsCallOptions {
|
||||
accessToken: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
params?: UiSpendLogsParams;
|
||||
}
|
||||
|
||||
export const uiSpendLogsCall = async ({
|
||||
accessToken,
|
||||
start_date,
|
||||
end_date,
|
||||
page = 1,
|
||||
page_size = 50,
|
||||
params = {},
|
||||
}: UiSpendLogsCallOptions) => {
|
||||
try {
|
||||
// Construct base URL
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs/ui` : `/spend/logs/ui`;
|
||||
|
||||
// Add query parameters if they exist
|
||||
const queryParams = new URLSearchParams();
|
||||
if (api_key) queryParams.append("api_key", api_key);
|
||||
if (team_id) queryParams.append("team_id", team_id);
|
||||
if (request_id) queryParams.append("request_id", request_id);
|
||||
if (start_date) queryParams.append("start_date", start_date);
|
||||
if (end_date) queryParams.append("end_date", end_date);
|
||||
if (page) queryParams.append("page", page.toString());
|
||||
if (page_size) queryParams.append("page_size", page_size.toString());
|
||||
if (user_id) queryParams.append("user_id", user_id);
|
||||
if (end_user) queryParams.append("end_user", end_user);
|
||||
if (status_filter) queryParams.append("status_filter", status_filter);
|
||||
if (model) queryParams.append("model", model);
|
||||
if (modelId) queryParams.append("model_id", modelId);
|
||||
if (keyAlias) queryParams.append("key_alias", keyAlias);
|
||||
if (error_code) queryParams.append("error_code", error_code);
|
||||
if (error_message) queryParams.append("error_message", error_message);
|
||||
// Append query parameters to URL if any exist
|
||||
queryParams.append("start_date", start_date);
|
||||
queryParams.append("end_date", end_date);
|
||||
queryParams.append("page", page.toString());
|
||||
queryParams.append("page_size", page_size.toString());
|
||||
|
||||
// Add optional params only when explicitly provided
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value == null) continue;
|
||||
if (key === "min_spend" || key === "max_spend") {
|
||||
queryParams.append(key, value.toString());
|
||||
} else if (typeof value === "string" && value !== "") {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,26 @@ import { Badge, Button } from "@tremor/react";
|
|||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import { TimeCell } from "./time_cell";
|
||||
import { MCP_CALL_TYPES } from "./constants";
|
||||
import { LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges";
|
||||
|
||||
/** API sort field mapping for /spend/logs/ui endpoint */
|
||||
export const LOGS_SORT_FIELD_MAP = {
|
||||
startTime: "startTime",
|
||||
spend: "spend",
|
||||
total_tokens: "total_tokens",
|
||||
} as const;
|
||||
|
||||
export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP;
|
||||
|
||||
export interface LogsSortProps {
|
||||
sortBy: LogsSortField;
|
||||
sortOrder: "asc" | "desc";
|
||||
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
|
||||
}
|
||||
|
||||
// Helper to get the appropriate logo URL
|
||||
const getLogoUrl = (row: LogEntry, provider: string) => {
|
||||
// Check if mcp_tool_call_metadata exists and contains mcp_server_logo_url
|
||||
|
|
@ -56,9 +72,47 @@ export type LogEntry = {
|
|||
onSessionClick?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<LogEntry>[] = [
|
||||
const SortableHeader = ({
|
||||
label,
|
||||
field,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
}: {
|
||||
label: string;
|
||||
field: LogsSortField;
|
||||
sortBy: LogsSortField;
|
||||
sortOrder: "asc" | "desc";
|
||||
onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void;
|
||||
}) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{label}</span>
|
||||
<TableHeaderSortDropdown
|
||||
sortState={sortBy === field ? sortOrder : false}
|
||||
onSortChange={(newState) => {
|
||||
if (newState === false) {
|
||||
onSortChange("startTime", "desc");
|
||||
} else {
|
||||
onSortChange(field, newState);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const createColumns = (sortProps?: LogsSortProps): ColumnDef<LogEntry>[] => [
|
||||
{
|
||||
header: "Time",
|
||||
header: sortProps
|
||||
? () => (
|
||||
<SortableHeader
|
||||
label="Time"
|
||||
field="startTime"
|
||||
sortBy={sortProps.sortBy}
|
||||
sortOrder={sortProps.sortOrder}
|
||||
onSortChange={sortProps.onSortChange}
|
||||
/>
|
||||
)
|
||||
: "Time",
|
||||
accessorKey: "startTime",
|
||||
cell: (info: any) => <TimeCell utcTime={info.getValue()} />,
|
||||
},
|
||||
|
|
@ -145,7 +199,17 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
header: "Cost",
|
||||
header: sortProps
|
||||
? () => (
|
||||
<SortableHeader
|
||||
label="Cost"
|
||||
field="spend"
|
||||
sortBy={sortProps.sortBy}
|
||||
sortOrder={sortProps.sortOrder}
|
||||
onSortChange={sortProps.onSortChange}
|
||||
/>
|
||||
)
|
||||
: "Cost",
|
||||
accessorKey: "spend",
|
||||
cell: (info: any) => {
|
||||
const row = info.row.original;
|
||||
|
|
@ -240,7 +304,17 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
header: sortProps
|
||||
? () => (
|
||||
<SortableHeader
|
||||
label="Tokens"
|
||||
field="total_tokens"
|
||||
sortBy={sortProps.sortBy}
|
||||
sortOrder={sortProps.sortOrder}
|
||||
onSortChange={sortProps.onSortChange}
|
||||
/>
|
||||
)
|
||||
: "Tokens",
|
||||
accessorKey: "total_tokens",
|
||||
cell: (info: any) => {
|
||||
const row = info.row.original;
|
||||
|
|
@ -308,6 +382,9 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/** Default columns without sort (for backward compatibility) */
|
||||
export const columns = createColumns();
|
||||
|
||||
const formatMessage = (message: any): string => {
|
||||
if (!message) return "N/A";
|
||||
if (typeof message === "string") return message;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import moment from "moment";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useCallback, useDeferredValue, useEffect, useRef, useState } from "react";
|
||||
import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { truncateString } from "@/utils/textUtils";
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
import { SettingOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
|
|
@ -20,7 +19,7 @@ import FilterComponent, { FilterOption } from "../molecules/filter";
|
|||
import { allEndUsersCall, keyInfoV1Call, keyListCall, uiSpendLogsCall } from "../networking";
|
||||
import KeyInfoView from "../templates/key_info_view";
|
||||
import AuditLogs from "./audit_logs";
|
||||
import { columns, LogEntry } from "./columns";
|
||||
import { createColumns, LogEntry, type LogsSortField } from "./columns";
|
||||
import { ConfigInfoMessage } from "./ConfigInfoMessage";
|
||||
import { ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants";
|
||||
import { CostBreakdownViewer } from "./CostBreakdownViewer";
|
||||
|
|
@ -77,7 +76,7 @@ export default function SpendLogsTable({
|
|||
const [tempKeyHash, setTempKeyHash] = useState("");
|
||||
const [selectedTeamId, setSelectedTeamId] = useState("");
|
||||
const [selectedKeyHash, setSelectedKeyHash] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null);
|
||||
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
|
||||
const [selectedStatus, setSelectedStatus] = useState("");
|
||||
|
|
@ -90,6 +89,9 @@ export default function SpendLogsTable({
|
|||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false);
|
||||
|
||||
const [sortBy, setSortBy] = useState<LogsSortField>("startTime");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isLiveTail, setIsLiveTail] = useState<boolean>(() => {
|
||||
|
|
@ -168,7 +170,9 @@ export default function SpendLogsTable({
|
|||
selectedKeyHash,
|
||||
filterByCurrentUser ? userID : null,
|
||||
selectedStatus,
|
||||
selectedModel,
|
||||
selectedModelId,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!accessToken || !token || !userRole || !userID) {
|
||||
|
|
@ -189,29 +193,38 @@ export default function SpendLogsTable({
|
|||
// Get base response from API
|
||||
// NOTE: We only fetch the list of logs here (lightweight).
|
||||
// Log details (messages/response) are fetched on-demand when user clicks a row.
|
||||
const response = await uiSpendLogsCall(
|
||||
const response = await uiSpendLogsCall({
|
||||
accessToken,
|
||||
selectedKeyHash || undefined,
|
||||
selectedTeamId || undefined,
|
||||
undefined,
|
||||
formattedStartTime,
|
||||
formattedEndTime,
|
||||
currentPage,
|
||||
pageSize,
|
||||
filterByCurrentUser ? userID : undefined,
|
||||
selectedEndUser,
|
||||
selectedStatus,
|
||||
undefined,
|
||||
selectedModel || undefined,
|
||||
);
|
||||
start_date: formattedStartTime,
|
||||
end_date: formattedEndTime,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
params: {
|
||||
api_key: selectedKeyHash || undefined,
|
||||
team_id: selectedTeamId || undefined,
|
||||
user_id: filterByCurrentUser ? userID ?? undefined : undefined,
|
||||
end_user: selectedEndUser || undefined,
|
||||
status_filter: selectedStatus || undefined,
|
||||
model_id: selectedModelId || undefined,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
|
||||
refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Defer the transition from "Fetching" to "Fetch" so the button stays loading until
|
||||
// the table has rendered with the new data (avoids the visual gap where the button
|
||||
// exits loading state before the table updates)
|
||||
const isFetchingDeferred = useDeferredValue(logs.isFetching);
|
||||
const isButtonLoading = logs.isFetching || isFetchingDeferred;
|
||||
|
||||
const logsData = logs.data || {
|
||||
data: [],
|
||||
total: 0,
|
||||
|
|
@ -237,6 +250,9 @@ export default function SpendLogsTable({
|
|||
setCurrentPage,
|
||||
userID,
|
||||
userRole,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
currentPage,
|
||||
});
|
||||
|
||||
const fetchKeyHashForAlias = useCallback(
|
||||
|
|
@ -268,7 +284,7 @@ export default function SpendLogsTable({
|
|||
setSelectedTeamId("");
|
||||
}
|
||||
setSelectedStatus(filters["Status"] || "");
|
||||
setSelectedModel(filters["Model"] || "");
|
||||
setSelectedModelId(filters["Model"] || "");
|
||||
setSelectedEndUser(filters["End User"] || "");
|
||||
|
||||
if (filters["Key Hash"]) {
|
||||
|
|
@ -596,26 +612,15 @@ export default function SpendLogsTable({
|
|||
|
||||
<LiveTailControls />
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="default"
|
||||
icon={<SyncOutlined spin={isButtonLoading} />}
|
||||
onClick={handleRefresh}
|
||||
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
title="Refresh data"
|
||||
disabled={isButtonLoading}
|
||||
title="Fetch data"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${logs.isFetching ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
{isButtonLoading ? "Fetching" : "Fetch"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCustomDate && (
|
||||
|
|
@ -694,9 +699,18 @@ export default function SpendLogsTable({
|
|||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columns={createColumns({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange: (newSortBy, newSortOrder) => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
setCurrentPage(1);
|
||||
},
|
||||
})}
|
||||
data={filteredData}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={logs.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,682 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PaginatedResponse } from ".";
|
||||
import type { LogEntry, LogsSortField } from "./columns";
|
||||
import { useLogFilterLogic } from "./log_filter_logic";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
uiSpendLogsCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
|
||||
fetchAllKeyAliases: vi.fn().mockResolvedValue([]),
|
||||
fetchAllTeams: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { uiSpendLogsCall } from "../networking";
|
||||
|
||||
const createLogEntry = (overrides: Partial<LogEntry> = {}): LogEntry =>
|
||||
({
|
||||
request_id: "req-1",
|
||||
api_key: "key-1",
|
||||
team_id: "team-1",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "chat",
|
||||
spend: 0,
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
startTime: "2025-01-01T00:00:00Z",
|
||||
endTime: "2025-01-01T00:01:00Z",
|
||||
cache_hit: "miss",
|
||||
messages: [],
|
||||
response: {},
|
||||
metadata: {},
|
||||
request_tags: {},
|
||||
...overrides,
|
||||
} as LogEntry);
|
||||
|
||||
const createPaginatedResponse = (data: LogEntry[]): PaginatedResponse => ({
|
||||
data,
|
||||
total: data.length,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 1,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
logs: createPaginatedResponse([]),
|
||||
accessToken: "test-token",
|
||||
startTime: "2025-01-01T00:00:00",
|
||||
endTime: "2025-01-01T23:59:59",
|
||||
isCustomDate: true,
|
||||
setCurrentPage: vi.fn(),
|
||||
userID: "user-1",
|
||||
userRole: "Admin",
|
||||
};
|
||||
|
||||
describe("useLogFilterLogic", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
it("should return filters, filteredLogs, allKeyAliases, allTeams, handleFilterChange, and handleFilterReset", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogFilterLogic({
|
||||
...defaultProps,
|
||||
logs: createPaginatedResponse([createLogEntry()]),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.filters).toBeDefined();
|
||||
expect(result.current.filteredLogs).toBeDefined();
|
||||
expect(result.current.allKeyAliases).toBeDefined();
|
||||
expect(result.current).toHaveProperty("allTeams");
|
||||
expect(result.current.handleFilterChange).toBeDefined();
|
||||
expect(result.current.handleFilterReset).toBeDefined();
|
||||
});
|
||||
|
||||
it("should initialize filters with all keys empty", () => {
|
||||
const { result } = renderHook(() => useLogFilterLogic(defaultProps), { wrapper });
|
||||
|
||||
const filters = result.current.filters;
|
||||
expect(filters["Team ID"]).toBe("");
|
||||
expect(filters["Key Hash"]).toBe("");
|
||||
expect(filters["Request ID"]).toBe("");
|
||||
expect(filters["Model"]).toBe("");
|
||||
expect(filters["User ID"]).toBe("");
|
||||
expect(filters["End User"]).toBe("");
|
||||
expect(filters["Status"]).toBe("");
|
||||
expect(filters["Key Alias"]).toBe("");
|
||||
expect(filters["Error Code"]).toBe("");
|
||||
expect(filters["Error Message"]).toBe("");
|
||||
});
|
||||
|
||||
it("should return all logs when no filters are applied", () => {
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1" }),
|
||||
createLogEntry({ request_id: "req-2" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
expect(result.current.filteredLogs.data).toHaveLength(2);
|
||||
expect(result.current.filteredLogs.data).toEqual(logs.data);
|
||||
});
|
||||
|
||||
it("should filter logs by team_id when Team ID filter is set", () => {
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", team_id: "team-a" }),
|
||||
createLogEntry({ request_id: "req-2", team_id: "team-b" }),
|
||||
createLogEntry({ request_id: "req-3", team_id: "team-a" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-a" });
|
||||
});
|
||||
|
||||
expect(result.current.filteredLogs.data).toHaveLength(2);
|
||||
expect(result.current.filteredLogs.data.every((log) => log.team_id === "team-a")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter logs by status when Status filter is set to success", () => {
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", status: "success" }),
|
||||
createLogEntry({ request_id: "req-2" }),
|
||||
createLogEntry({ request_id: "req-3", status: "error" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ Status: "success" });
|
||||
});
|
||||
|
||||
expect(result.current.filteredLogs.data).toHaveLength(2);
|
||||
expect(result.current.filteredLogs.data.every((log) => !log.status || log.status === "success")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter logs by status when Status filter is set to error", () => {
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", status: "success" }),
|
||||
createLogEntry({ request_id: "req-2", status: "error" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ Status: "error" });
|
||||
});
|
||||
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].status).toBe("error");
|
||||
});
|
||||
|
||||
it("should filter logs by model_id when Model filter is set", async () => {
|
||||
const filteredLogs = [
|
||||
createLogEntry({ request_id: "req-1", model_id: "gpt-4" }),
|
||||
createLogEntry({ request_id: "req-3", model_id: "gpt-4" }),
|
||||
];
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse(filteredLogs),
|
||||
);
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", model_id: "gpt-4" }),
|
||||
createLogEntry({ request_id: "req-2", model_id: "gpt-3.5" }),
|
||||
createLogEntry({ request_id: "req-3", model_id: "gpt-4" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ Model: "gpt-4" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(2);
|
||||
expect(result.current.filteredLogs.data.every((log) => log.model_id === "gpt-4")).toBe(true);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter logs by api_key when Key Hash filter is set", async () => {
|
||||
const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" });
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([filteredLog]),
|
||||
);
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", api_key: "key-x" }),
|
||||
createLogEntry({ request_id: "req-2", api_key: "key-y" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Hash": "key-x" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].api_key).toBe("key-x");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter logs by end_user when End User filter is set", async () => {
|
||||
const filteredLog = createLogEntry({ request_id: "req-1", end_user: "user-a" });
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([filteredLog]),
|
||||
);
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({ request_id: "req-1", end_user: "user-a" }),
|
||||
createLogEntry({ request_id: "req-2", end_user: "user-b" }),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "End User": "user-a" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].end_user).toBe("user-a");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter logs by error_code when Error Code filter is set", async () => {
|
||||
const filteredLog = createLogEntry({
|
||||
request_id: "req-1",
|
||||
metadata: { error_information: { error_code: "429" } },
|
||||
});
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([filteredLog]),
|
||||
);
|
||||
const logs = createPaginatedResponse([
|
||||
createLogEntry({
|
||||
request_id: "req-1",
|
||||
metadata: { error_information: { error_code: "429" } },
|
||||
}),
|
||||
createLogEntry({
|
||||
request_id: "req-2",
|
||||
metadata: { error_information: { error_code: "500" } },
|
||||
}),
|
||||
]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Error Code": "429" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].metadata?.error_information?.error_code).toBe("429");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty data when logs is null or has no data", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogFilterLogic({
|
||||
...defaultProps,
|
||||
logs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.filteredLogs.data).toEqual([]);
|
||||
expect(result.current.filteredLogs.total).toBe(0);
|
||||
});
|
||||
|
||||
it("should reset filters when handleFilterReset is called", () => {
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" });
|
||||
});
|
||||
|
||||
expect(result.current.filters["Team ID"]).toBe("team-1");
|
||||
expect(result.current.filters["Status"]).toBe("success");
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterReset();
|
||||
});
|
||||
|
||||
expect(result.current.filters["Team ID"]).toBe("");
|
||||
expect(result.current.filters["Status"]).toBe("");
|
||||
});
|
||||
|
||||
it("should call setCurrentPage with 1 when handleFilterChange is invoked", () => {
|
||||
const setCurrentPage = vi.fn();
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(
|
||||
() => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-1" });
|
||||
});
|
||||
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should call uiSpendLogsCall when backend filter is set and debounce elapses", async () => {
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call uiSpendLogsCall when accessToken is null", async () => {
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(
|
||||
() => useLogFilterLogic({ ...defaultProps, logs, accessToken: null }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
expect(uiSpendLogsCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use backend filtered logs when backend filters are active and API returns data", async () => {
|
||||
const backendLog = createLogEntry({ request_id: "backend-req" });
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([backendLog]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call uiSpendLogsCall with request_id when Request ID filter is set", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry({ request_id: "req-xyz" })]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Request ID": "req-xyz" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ request_id: "req-xyz" }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call uiSpendLogsCall with user_id when User ID filter is set", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "User ID": "user-123" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ user_id: "user-123" }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should call uiSpendLogsCall with error_message when Error Message filter is set", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Error Message": "rate limit exceeded" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ error_message: "rate limit exceeded" }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to logs when backend filters are active but API returns empty", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total_pages: 0,
|
||||
});
|
||||
const clientLog = createLogEntry({ request_id: "client-req" });
|
||||
const logs = createPaginatedResponse([clientLog]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
expect(result.current.filteredLogs.data).toHaveLength(1);
|
||||
expect(result.current.filteredLogs.data[0].request_id).toBe("client-req");
|
||||
});
|
||||
|
||||
it("should refetch when sortBy changes and backend filters are active", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { sortBy?: LogsSortField }) =>
|
||||
useLogFilterLogic({ ...defaultProps, logs, ...props }),
|
||||
{ wrapper, initialProps: { sortBy: "startTime" as LogsSortField } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
|
||||
rerender({ sortBy: "spend" as LogsSortField });
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
|
||||
timeout: 500,
|
||||
});
|
||||
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ sort_by: "spend" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should refetch when sortOrder changes and backend filters are active", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { sortOrder?: "asc" | "desc" }) =>
|
||||
useLogFilterLogic({ ...defaultProps, logs, ...props }),
|
||||
{ wrapper, initialProps: { sortOrder: "desc" } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
|
||||
rerender({ sortOrder: "asc" });
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
|
||||
timeout: 500,
|
||||
});
|
||||
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ sort_order: "asc" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should refetch when currentPage changes and backend filters are active", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result, rerender } = renderHook(
|
||||
(props) => useLogFilterLogic({ ...defaultProps, logs, ...props }),
|
||||
{ wrapper, initialProps: { currentPage: 1 } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
|
||||
rerender({ currentPage: 2 });
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), {
|
||||
timeout: 500,
|
||||
});
|
||||
expect(uiSpendLogsCall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ page: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not call setCurrentPage when handleFilterChange receives identical filters", async () => {
|
||||
const setCurrentPage = vi.fn();
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(
|
||||
() => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-1" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), {
|
||||
timeout: 500,
|
||||
});
|
||||
|
||||
setCurrentPage.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleFilterChange({ "Team ID": "team-1" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
});
|
||||
|
||||
expect(setCurrentPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not crash when uiSpendLogsCall throws", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error"));
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), {
|
||||
timeout: 500,
|
||||
});
|
||||
|
||||
expect(result.current.filteredLogs).toBeDefined();
|
||||
expect(result.current.filters).toBeDefined();
|
||||
});
|
||||
|
||||
it("should clear backendFilteredLogs when handleFilterReset is called", async () => {
|
||||
const backendLog = createLogEntry({ request_id: "backend-req" });
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([backendLog]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]);
|
||||
const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req");
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterReset();
|
||||
});
|
||||
|
||||
expect(result.current.filteredLogs.data).toEqual(logs.data);
|
||||
expect(result.current.filteredLogs.data[0].request_id).toBe("client-req");
|
||||
});
|
||||
|
||||
it("should pass correct start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => {
|
||||
vi.mocked(uiSpendLogsCall).mockResolvedValue(
|
||||
createPaginatedResponse([createLogEntry()]),
|
||||
);
|
||||
const logs = createPaginatedResponse([createLogEntry()]);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogFilterLogic({
|
||||
...defaultProps,
|
||||
logs,
|
||||
startTime: "2025-01-15T00:00:00Z",
|
||||
endTime: "2025-01-15T23:59:59Z",
|
||||
isCustomDate: true,
|
||||
sortBy: "spend",
|
||||
sortOrder: "asc",
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleFilterChange({ "Key Alias": "alias-1" });
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(uiSpendLogsCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
start_date: "2025-01-15 00:00:00",
|
||||
end_date: "2025-01-15 23:59:59",
|
||||
params: expect.objectContaining({
|
||||
sort_by: "spend",
|
||||
sort_order: "asc",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_hel
|
|||
import { debounce } from "lodash";
|
||||
import { defaultPageSize } from "../constants";
|
||||
import { PaginatedResponse } from ".";
|
||||
import type { LogsSortField } from "./columns";
|
||||
|
||||
const FILTER_KEYS = {
|
||||
TEAM_ID: "Team ID",
|
||||
|
|
@ -34,6 +35,9 @@ export function useLogFilterLogic({
|
|||
setCurrentPage,
|
||||
userID,
|
||||
userRole,
|
||||
sortBy = "startTime",
|
||||
sortOrder = "desc",
|
||||
currentPage = 1,
|
||||
}: {
|
||||
logs: PaginatedResponse;
|
||||
accessToken: string | null;
|
||||
|
|
@ -44,6 +48,9 @@ export function useLogFilterLogic({
|
|||
setCurrentPage: (page: number) => void;
|
||||
userID: string | null;
|
||||
userRole: string | null;
|
||||
sortBy?: LogsSortField;
|
||||
sortOrder?: "asc" | "desc";
|
||||
currentPage?: number;
|
||||
}) {
|
||||
const defaultFilters = useMemo<LogFilterState>(
|
||||
() => ({
|
||||
|
|
@ -84,24 +91,27 @@ export function useLogFilterLogic({
|
|||
: moment().utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
try {
|
||||
const response = await uiSpendLogsCall(
|
||||
const response = await uiSpendLogsCall({
|
||||
accessToken,
|
||||
filters[FILTER_KEYS.KEY_HASH] || undefined,
|
||||
filters[FILTER_KEYS.TEAM_ID] || undefined,
|
||||
filters[FILTER_KEYS.REQUEST_ID] || undefined,
|
||||
formattedStartTime,
|
||||
formattedEndTime,
|
||||
start_date: formattedStartTime,
|
||||
end_date: formattedEndTime,
|
||||
page,
|
||||
pageSize,
|
||||
filters[FILTER_KEYS.USER_ID] || undefined,
|
||||
filters[FILTER_KEYS.END_USER] || undefined,
|
||||
filters[FILTER_KEYS.STATUS] || undefined,
|
||||
undefined,
|
||||
filters[FILTER_KEYS.MODEL] || undefined,
|
||||
filters[FILTER_KEYS.KEY_ALIAS] || undefined,
|
||||
filters[FILTER_KEYS.ERROR_CODE] || undefined,
|
||||
filters[FILTER_KEYS.ERROR_MESSAGE] || undefined,
|
||||
);
|
||||
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,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentTimestamp === lastSearchTimestamp.current && response.data) {
|
||||
setBackendFilteredLogs(response);
|
||||
|
|
@ -110,7 +120,7 @@ export function useLogFilterLogic({
|
|||
console.error("Error searching users:", error);
|
||||
}
|
||||
},
|
||||
[accessToken, startTime, endTime, isCustomDate, pageSize],
|
||||
[accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder],
|
||||
);
|
||||
|
||||
const debouncedSearch = useMemo(
|
||||
|
|
@ -142,11 +152,19 @@ export function useLogFilterLogic({
|
|||
filters[FILTER_KEYS.USER_ID] ||
|
||||
filters[FILTER_KEYS.END_USER] ||
|
||||
filters[FILTER_KEYS.ERROR_CODE] ||
|
||||
filters[FILTER_KEYS.ERROR_MESSAGE]
|
||||
filters[FILTER_KEYS.ERROR_MESSAGE] ||
|
||||
filters[FILTER_KEYS.MODEL]
|
||||
),
|
||||
[filters],
|
||||
);
|
||||
|
||||
// Refetch when sort or page changes (backend filters use their own fetch, not the main query)
|
||||
useEffect(() => {
|
||||
if (hasBackendFilters && accessToken) {
|
||||
performSearch(filters, currentPage);
|
||||
}
|
||||
}, [sortBy, sortOrder, currentPage]);
|
||||
|
||||
// Compute client-side filtered logs directly from incoming logs and filters
|
||||
const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => {
|
||||
if (!logs || !logs.data) {
|
||||
|
|
@ -180,7 +198,7 @@ export function useLogFilterLogic({
|
|||
}
|
||||
|
||||
if (filters[FILTER_KEYS.MODEL]) {
|
||||
filteredData = filteredData.filter((log) => log.model === filters[FILTER_KEYS.MODEL]);
|
||||
filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]);
|
||||
}
|
||||
|
||||
if (filters[FILTER_KEYS.KEY_HASH]) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue