Team id + Status filter on logs (#10831)

* added team_id filter

* improve hook

* pagination fixed

* fix pagination for team id filter

* minor

* added status filter

* fixed pagination
This commit is contained in:
tanjiro
2025-05-14 13:06:42 -07:00
committed by GitHub
parent 468b881ec5
commit 1fc5320e9f
6 changed files with 357 additions and 183 deletions
@@ -1654,6 +1654,10 @@ async def ui_view_spend_logs( # noqa: PLR0915
default=50, description="Number of items per page", ge=1, le=100
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
status_filter: Optional[str] = fastapi.Query(
default=None,
description="Filter logs by status (e.g., success, failure)"
),
):
"""
View spend logs for UI with pagination support
@@ -1706,6 +1710,12 @@ async def ui_view_spend_logs( # noqa: PLR0915
if team_id is not None:
where_conditions["team_id"] = team_id
if status_filter is not None:
if status_filter == "success":
where_conditions["status"] = {"in": ["success", None]} # Assuming None means empty status
else:
where_conditions["status"] = status_filter # Filtering for other status values
if api_key is not None:
where_conditions["api_key"] = api_key
@@ -1,13 +1,16 @@
import React, { useState, useCallback } from 'react';
import { Button, Input, Select } from 'antd';
import { FilterIcon } from '@heroicons/react/outline';
import debounce from 'lodash/debounce';
import React, { useState, useCallback } from "react";
import { Button, Input, Select } from "antd";
import { FilterIcon } from "@heroicons/react/outline";
import debounce from "lodash/debounce";
export interface FilterOption {
name: string;
label?: string;
isSearchable?: boolean;
searchFn?: (searchText: string) => Promise<Array<{ label: string; value: string }>>;
searchFn?: (
searchText: string
) => Promise<Array<{ label: string; value: string }>>;
options?: Array<{ label: string; value: string }>;
}
interface FilterValues {
@@ -31,23 +34,29 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
}) => {
const [showFilters, setShowFilters] = useState<boolean>(false);
const [tempValues, setTempValues] = useState<FilterValues>(initialValues);
const [searchOptionsMap, setSearchOptionsMap] = useState<{ [key: string]: Array<{ label: string; value: string }> }>({});
const [searchLoadingMap, setSearchLoadingMap] = useState<{ [key: string]: boolean }>({});
const [searchInputValueMap, setSearchInputValueMap] = useState<{ [key: string]: string }>({});
const [searchOptionsMap, setSearchOptionsMap] = useState<{
[key: string]: Array<{ label: string; value: string }>;
}>({});
const [searchLoadingMap, setSearchLoadingMap] = useState<{
[key: string]: boolean;
}>({});
const [searchInputValueMap, setSearchInputValueMap] = useState<{
[key: string]: string;
}>({});
const debouncedSearch = useCallback(
debounce(async (value: string, option: FilterOption) => {
if (!option.isSearchable || !option.searchFn) return;
setSearchLoadingMap(prev => ({ ...prev, [option.name]: true }));
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true }));
try {
const results = await option.searchFn(value);
setSearchOptionsMap(prev => ({ ...prev, [option.name]: results }));
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results }));
} catch (error) {
console.error('Error searching:', error);
setSearchOptionsMap(prev => ({ ...prev, [option.name]: [] }));
console.error("Error searching:", error);
setSearchOptionsMap((prev) => ({ ...prev, [option.name]: [] }));
} finally {
setSearchLoadingMap(prev => ({ ...prev, [option.name]: false }));
setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false }));
}
}, 300),
[]
@@ -56,7 +65,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
const handleFilterChange = (name: string, value: string) => {
const newValues = {
...tempValues,
[name]: value
[name]: value,
};
setTempValues(newValues);
onApplyFilters(newValues);
@@ -64,8 +73,8 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
const resetFilters = () => {
const emptyValues: FilterValues = {};
options.forEach(option => {
emptyValues[option.name] = '';
options.forEach((option) => {
emptyValues[option.name] = "";
});
setTempValues(emptyValues);
onResetFilters();
@@ -73,17 +82,18 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
// Define the order of filters
const orderedFilters = [
'Team ID',
'Organization ID',
'Key Alias',
'User ID',
'Key Hash'
"Team ID",
"Status",
"Organization ID",
"Key Alias",
"User ID",
"Key Hash",
];
return (
<div className="w-full">
<div className="flex items-center gap-2 mb-6">
<Button
<Button
icon={<FilterIcon className="h-4 w-4" />}
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
@@ -96,7 +106,9 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
{showFilters && (
<div className="grid grid-cols-3 gap-x-6 gap-y-4 mb-6">
{orderedFilters.map((filterName) => {
const option = options.find(opt => opt.label === filterName || opt.name === filterName);
const option = options.find(
(opt) => opt.label === filterName || opt.name === filterName
);
if (!option) return null;
return (
@@ -112,7 +124,10 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
onSearch={(value) => {
setSearchInputValueMap(prev => ({ ...prev, [option.name]: value }));
setSearchInputValueMap((prev) => ({
...prev,
[option.name]: value,
}));
if (option.searchFn) {
debouncedSearch(value, option);
}
@@ -122,12 +137,28 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
options={searchOptionsMap[option.name] || []}
allowClear
/>
) : option.options ? (
<Select
className="w-full"
placeholder={`Select ${option.label || option.name}...`}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
allowClear
>
{option.options.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
) : (
<Input
className="w-full"
placeholder={`Enter ${option.label || option.name}...`}
value={tempValues[option.name] || ''}
onChange={(e) => handleFilterChange(option.name, e.target.value)}
value={tempValues[option.name] || ""}
onChange={(e) =>
handleFilterChange(option.name, e.target.value)
}
allowClear
/>
)}
@@ -140,4 +171,4 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
);
};
export default FilterComponent;
export default FilterComponent;
@@ -2147,6 +2147,7 @@ export const uiSpendLogsCall = async (
page?: number,
page_size?: number,
user_id?: string,
status_filter?: string
) => {
try {
// Construct base URL
@@ -2162,6 +2163,7 @@ export const uiSpendLogsCall = async (
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 (status_filter) queryParams.append('status_filter', status_filter);
// Append query parameters to URL if any exist
const queryString = queryParams.toString();
@@ -47,6 +47,7 @@ export type LogEntry = {
response: string | any[] | Record<string, any>;
proxy_server_request?: string | any[] | Record<string, any>;
session_id?: string;
status?: string;
onKeyHashClick?: (keyHash: string) => void;
onSessionClick?: (sessionId: string) => void;
};
@@ -17,6 +17,9 @@ import { KeyResponse, Team } from "../key_team_helpers/key_list";
import KeyInfoView from "../key_info_view";
import { SessionView } from './SessionView';
import { VectorStoreViewer } from './VectorStoreViewer';
import FilterComponent from "../common_components/filter";
import { FilterOption } from "../common_components/filter";
import { useLogFilterLogic } from "./log_filter_logic";
interface SpendLogsTableProps {
accessToken: string | null;
@@ -26,7 +29,7 @@ interface SpendLogsTableProps {
allTeams: Team[];
}
interface PaginatedResponse {
export interface PaginatedResponse {
data: LogEntry[];
total: number;
page: number;
@@ -72,7 +75,7 @@ export default function SpendLogsTable({
const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [selectedKeyInfo, setSelectedKeyInfo] = useState<KeyResponse | null>(null);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(null);
const [selectedFilter, setSelectedFilter] = useState("Team ID");
const [selectedStatus, setSelectedStatus] = useState("");
const [filterByCurrentUser, setFilterByCurrentUser] = useState(
userRole && internalUserRoles.includes(userRole)
);
@@ -86,7 +89,6 @@ export default function SpendLogsTable({
const fetchKeyInfo = async () => {
if (selectedKeyIdInfoView && accessToken) {
const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView);
console.log("keyData", keyData);
const keyResponse: KeyResponse = {
...keyData["info"],
@@ -145,6 +147,7 @@ export default function SpendLogsTable({
selectedTeamId,
selectedKeyHash,
filterByCurrentUser ? userID : null,
selectedStatus
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
@@ -173,7 +176,8 @@ export default function SpendLogsTable({
formattedEndTime,
currentPage,
pageSize,
filterByCurrentUser ? userID : undefined
filterByCurrentUser ? userID : undefined,
selectedStatus
);
// Trigger prefetch for all logs
@@ -205,6 +209,42 @@ export default function SpendLogsTable({
refetchIntervalInBackground: true,
});
const logsData = logs.data || {
data: [],
total: 0,
page: 1,
page_size: pageSize || 10,
total_pages: 1
};
const {
filters,
filteredLogs,
allTeams: hookAllTeams,
allKeyAliases,
handleFilterChange,
handleFilterReset
} = useLogFilterLogic({
logs: logsData,
accessToken,
startTime,
endTime,
pageSize,
isCustomDate,
setCurrentPage
})
// Add this effect to update selectedTeamId and selectedStatus when team filter changes
useEffect(() => {
if (filters['Team ID']) {
setSelectedTeamId(filters['Team ID']);
} else {
setSelectedTeamId("");
}
setSelectedStatus(filters['Status'] || "");
}, [filters]);
// Fetch logs for a session if selected
const sessionLogs = useQuery<PaginatedResponse>({
queryKey: ["sessionLogs", selectedSessionId],
@@ -236,14 +276,11 @@ export default function SpendLogsTable({
}, [logs.data?.data, expandedRequestId]);
if (!accessToken || !token || !userRole || !userID) {
console.log(
"got None values for one of accessToken, token, userRole, userID",
);
return null;
}
const filteredData =
logs.data?.data?.filter((log) => {
filteredLogs.data.filter((log) => {
const matchesSearch =
!searchTerm ||
log.request_id.includes(searchTerm) ||
@@ -298,6 +335,34 @@ export default function SpendLogsTable({
setExpandedRequestId(requestId);
};
const logFilterOptions: FilterOption[] = [
{
name: 'Team ID',
label: 'Team ID',
isSearchable: true,
searchFn: async (searchText: string) => {
if (!allTeams || allTeams.length === 0) return [];
const filtered = allTeams.filter((team: Team) =>{
return team.team_id.toLowerCase().includes(searchText.toLowerCase()) ||
(team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase()))
});
return filtered.map((team: Team) => ({
label: `${team.team_alias || team.team_id} (${team.team_id})`,
value: team.team_id
}));
}
},
{
name:'Status',
label:'Status',
isSearchable: false,
options: [
{ label: 'Success', value: 'success' },
{ label: 'Failure', value: 'failure' }
]
}
]
// When a session is selected, render the SessionView component
if (selectedSessionId && sessionLogs.data) {
return (
@@ -313,6 +378,7 @@ export default function SpendLogsTable({
return (
<div className="w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">
{selectedSessionId ? (
@@ -344,6 +410,7 @@ export default function SpendLogsTable({
</div>
) : (
<>
<FilterComponent options={logFilterOptions} onApplyFilters={handleFilterChange} onResetFilters={handleFilterReset} />
<div className="bg-white rounded-lg shadow">
<div className="border-b px-6 py-4">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
@@ -370,144 +437,6 @@ export default function SpendLogsTable({
/>
</svg>
</div>
<div className="relative" ref={filtersRef}>
<button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={() => setShowFilters(!showFilters)}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filter
</button>
{showFilters && (
<div className="absolute left-0 mt-2 w-[500px] bg-white rounded-lg shadow-lg border p-4 z-50">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Where</span>
<div className="relative">
<button
onClick={() => setShowColumnDropdown(!showColumnDropdown)}
className="px-3 py-1.5 border rounded-md bg-white text-sm min-w-[160px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-left flex justify-between items-center"
>
{selectedFilter}
<svg
className="h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showColumnDropdown && (
<div className="absolute left-0 mt-1 w-[160px] bg-white border rounded-md shadow-lg z-50">
{["Team ID", "Key Hash"].map((option) => (
<button
key={option}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-2 ${
selectedFilter === option
? "bg-blue-50 text-blue-600"
: ""
}`}
onClick={() => {
setSelectedFilter(option);
setShowColumnDropdown(false);
if (option === "Team ID") {
setTempKeyHash("");
} else {
setTempTeamId("");
}
}}
>
{selectedFilter === option && (
<svg
className="h-4 w-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
{option}
</button>
))}
</div>
)}
</div>
<input
type="text"
placeholder="Enter value..."
className="px-3 py-1.5 border rounded-md text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={selectedFilter === "Team ID" ? tempTeamId : tempKeyHash}
onChange={(e) => {
if (selectedFilter === "Team ID") {
setTempTeamId(e.target.value);
} else {
setTempKeyHash(e.target.value);
}
}}
/>
<button
className="p-1 hover:bg-gray-100 rounded-md"
onClick={() => {
setTempTeamId("");
setTempKeyHash("");
}}
>
<span className="text-gray-500">×</span>
</button>
</div>
<div className="flex justify-end gap-2">
<button
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50"
onClick={() => {
setTempTeamId("");
setTempKeyHash("");
setShowFilters(false);
}}
>
Cancel
</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => {
setSelectedTeamId(tempTeamId);
setSelectedKeyHash(tempKeyHash);
setCurrentPage(1); // Reset to first page when applying new filters
setShowFilters(false);
}}
>
Apply Filters
</button>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="relative" ref={quickSelectRef}>
@@ -630,20 +559,20 @@ export default function SpendLogsTable({
Showing{" "}
{logs.isLoading
? "..."
: logs.data
: filteredLogs
? (currentPage - 1) * pageSize + 1
: 0}{" "}
-{" "}
{logs.isLoading
? "..."
: logs.data
? Math.min(currentPage * pageSize, logs.data.total)
: filteredLogs
? Math.min(currentPage * pageSize, filteredLogs.total)
: 0}{" "}
of{" "}
{logs.isLoading
? "..."
: logs.data
? logs.data.total
: filteredLogs
? filteredLogs.total
: 0}{" "}
results
</span>
@@ -652,8 +581,8 @@ export default function SpendLogsTable({
Page {logs.isLoading ? "..." : currentPage} of{" "}
{logs.isLoading
? "..."
: logs.data
? logs.data.total_pages
: filteredLogs
? filteredLogs.total_pages
: 1}
</span>
<button
@@ -669,14 +598,14 @@ export default function SpendLogsTable({
onClick={() =>
setCurrentPage((p) =>
Math.min(
logs.data?.total_pages || 1,
filteredLogs.total_pages || 1,
p + 1,
),
)
}
disabled={
logs.isLoading ||
currentPage === (logs.data?.total_pages || 1)
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"
>
@@ -0,0 +1,201 @@
import moment from "moment";
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { useQuery } from "@tanstack/react-query";
import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
import { PaginatedResponse } from ".";
export const FILTER_KEYS = {
TEAM_ID: "Team ID",
KEY_HASH: "Key Hash",
REQUEST_ID: "Request ID",
MODEL: "Model",
USER_ID: "User ID",
STATUS: "Status"
} as const;
export type FilterKey = keyof typeof FILTER_KEYS;
export type LogFilterState = Record<typeof FILTER_KEYS[FilterKey], string>;
export function useLogFilterLogic({
logs,
accessToken,
startTime, // Receive from SpendLogsTable
endTime, // Receive from SpendLogsTable
pageSize = defaultPageSize,
isCustomDate,
setCurrentPage
}: {
logs: PaginatedResponse;
accessToken: string | null;
startTime: string;
endTime: string;
pageSize?: number;
isCustomDate: boolean;
setCurrentPage: (page: number) => void;
}) {
const defaultFilters = useMemo<LogFilterState>(() => ({
[FILTER_KEYS.TEAM_ID]: "",
[FILTER_KEYS.KEY_HASH]: "",
[FILTER_KEYS.REQUEST_ID]: "",
[FILTER_KEYS.MODEL]: "",
[FILTER_KEYS.USER_ID]: "",
[FILTER_KEYS.STATUS]: ""
}), []);
const [filters, setFilters] = useState<LogFilterState>(defaultFilters);
const [filteredLogs, setFilteredLogs] = useState<PaginatedResponse>(logs);
const lastSearchTimestamp = useRef(0);
const performSearch = useCallback(async (filters: LogFilterState, page = 1) => {
if (!accessToken) return;
const currentTimestamp = Date.now();
lastSearchTimestamp.current = currentTimestamp;
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,
filters[FILTER_KEYS.KEY_HASH] || undefined,
filters[FILTER_KEYS.TEAM_ID] || undefined,
filters[FILTER_KEYS.REQUEST_ID] || undefined,
formattedStartTime,
formattedEndTime,
page,
pageSize,
filters[FILTER_KEYS.USER_ID] || undefined,
filters[FILTER_KEYS.STATUS] || undefined
);
if (currentTimestamp === lastSearchTimestamp.current && response.data) {
setFilteredLogs(response);
}
} catch (error) {
console.error("Error searching users:", error);
}
}, [accessToken, startTime, endTime, isCustomDate, pageSize]);
const debouncedSearch = useMemo(
() => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300),
[performSearch]
);
useEffect(() => {
return () => debouncedSearch.cancel();
}, [debouncedSearch]);
// Apply filters to keys whenever logs or filters change
useEffect(() => {
if (!logs || !logs.data) {
setFilteredLogs({
data: [],
total: 0,
page: 1,
page_size: 50,
total_pages: 0
});
return;
}
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];
}
);
}
const newFilteredLogs: PaginatedResponse = {
data: filteredData,
total: logs.total,
page: logs.page,
page_size: logs.page_size,
total_pages: logs.total_pages,
};
if (JSON.stringify(newFilteredLogs) !== JSON.stringify(filteredLogs)) {
setFilteredLogs(newFilteredLogs);
}
}, [logs, filters, filteredLogs]);
const queryAllKeysQuery = useQuery({
queryKey: ['allKeys'],
queryFn: async () => {
if (!accessToken) throw new Error('Access token required');
return await fetchAllKeyAliases(accessToken);
},
enabled: !!accessToken
});
const allKeyAliases = queryAllKeysQuery.data || []
// 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
},
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);
debouncedSearch(updatedFilters, 1);
}
return updatedFilters as LogFilterState;
});
};
const handleFilterReset = () => {
// Reset filters state
setFilters(defaultFilters);
// Reset selections
debouncedSearch(defaultFilters, 1);
};
return {
filters,
filteredLogs,
allKeyAliases,
allTeams,
handleFilterChange,
handleFilterReset,
};
}