mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-23 18:25:22 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user