From c66c821f9682a212e80de333ad2a1f73e6e9dfc8 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Fri, 25 Apr 2025 21:30:56 -0700 Subject: [PATCH] UI (Keys Page) - Support cross filtering, filter by user id, filter by key hash (#10322) * feat(filter.tsx): initial commit making filter component more generic - same style as user table filters * refactor(all_keys_table.tsx): refactor to simplify update logic * fix: partially revert changes - reduce scope of pr * fix(filter_logic.tsx): fix filter update logic * fix(all_keys_table.tsx): fix filtering + search logic * refactor: cleanup unused params * Revert "fix(all_keys_table.tsx): fix filtering + search logic" This reverts commit 5fbc3319702080fa987bee7b0a89e1cb635dd6be. * feat(filter_logic.tsx): allow filter by user id * fix(key_management_endpoints.py): support filtering `/key/list` by key hash Enables lookup by key hash on ui * fix(key_list.tsx): fix update * fix(key_management.py): fix linting error * test: update testing * fix(prometheus.py): fix key hash * style(all_keys_table.tsx): style improvements * test: fix test --- litellm/integrations/prometheus.py | 15 +- .../key_management_endpoints.py | 32 ++ .../test_key_management_endpoints.py | 1 + .../test_key_management.py | 6 + .../src/components/all_keys_table.tsx | 82 +++-- .../components/common_components/filter.tsx | 315 +++++------------- .../src/components/constants.tsx | 4 +- .../key_team_helpers/filter_helpers.ts | 63 ++-- .../key_team_helpers/filter_logic.tsx | 102 +++--- .../components/key_team_helpers/key_list.tsx | 10 +- .../src/components/networking.tsx | 10 + .../src/components/view_key_table.tsx | 2 +- 12 files changed, 292 insertions(+), 350 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index f61321e53d..03bf1cd29e 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -1000,9 +1000,9 @@ class PrometheusLogger(CustomLogger): ): try: verbose_logger.debug("setting remaining tokens requests metric") - standard_logging_payload: Optional[StandardLoggingPayload] = ( - request_kwargs.get("standard_logging_object") - ) + standard_logging_payload: Optional[ + StandardLoggingPayload + ] = request_kwargs.get("standard_logging_object") if standard_logging_payload is None: return @@ -1453,6 +1453,7 @@ class PrometheusLogger(CustomLogger): user_id=None, team_id=None, key_alias=None, + key_hash=None, exclude_team_id=UI_SESSION_TOKEN_TEAM_ID, return_full_object=True, organization_id=None, @@ -1771,10 +1772,10 @@ class PrometheusLogger(CustomLogger): from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.prometheus import PrometheusLogger - prometheus_loggers: List[CustomLogger] = ( - litellm.logging_callback_manager.get_custom_loggers_for_type( - callback_type=PrometheusLogger - ) + prometheus_loggers: List[ + CustomLogger + ] = litellm.logging_callback_manager.get_custom_loggers_for_type( + callback_type=PrometheusLogger ) # we need to get the initialized prometheus logger instance(s) and call logger.initialize_remaining_budget_metrics() on them verbose_logger.debug("found %s prometheus loggers", len(prometheus_loggers)) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 3fef6a0862..ea39ccc5e6 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1861,6 +1861,7 @@ async def validate_key_list_check( team_id: Optional[str], organization_id: Optional[str], key_alias: Optional[str], + key_hash: Optional[str], prisma_client: PrismaClient, ) -> Optional[LiteLLM_UserTable]: if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: @@ -1924,6 +1925,31 @@ async def validate_key_list_check( param="organization_id", code=status.HTTP_403_FORBIDDEN, ) + + if key_hash: + try: + key_info = await prisma_client.db.litellm_verificationtoken.find_unique( + where={"token": key_hash}, + ) + except Exception: + raise ProxyException( + message="Key Hash not found.", + type=ProxyErrorTypes.bad_request_error, + param="key_hash", + code=status.HTTP_403_FORBIDDEN, + ) + can_user_query_key_info = await _can_user_query_key_info( + user_api_key_dict=user_api_key_dict, + key=key_hash, + key_info=key_info, + ) + if not can_user_query_key_info: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not allowed to access this key's info. Your role={}".format( + user_api_key_dict.user_role + ), + ) return complete_user_info @@ -1972,6 +1998,7 @@ async def list_keys( organization_id: Optional[str] = Query( None, description="Filter keys by organization ID" ), + key_hash: Optional[str] = Query(None, description="Filter keys by key hash"), key_alias: Optional[str] = Query(None, description="Filter keys by key alias"), return_full_object: bool = Query(False, description="Return full key object"), include_team_keys: bool = Query( @@ -2004,6 +2031,7 @@ async def list_keys( team_id=team_id, organization_id=organization_id, key_alias=key_alias, + key_hash=key_hash, prisma_client=prisma_client, ) @@ -2029,6 +2057,7 @@ async def list_keys( user_id=user_id, team_id=team_id, key_alias=key_alias, + key_hash=key_hash, return_full_object=return_full_object, organization_id=organization_id, admin_team_ids=admin_team_ids, @@ -2065,6 +2094,7 @@ async def _list_key_helper( team_id: Optional[str], organization_id: Optional[str], key_alias: Optional[str], + key_hash: Optional[str], exclude_team_id: Optional[str] = None, return_full_object: bool = False, admin_team_ids: Optional[ @@ -2111,6 +2141,8 @@ async def _list_key_helper( user_condition["team_id"] = {"not": exclude_team_id} if organization_id and isinstance(organization_id, str): user_condition["organization_id"] = organization_id + if key_hash and isinstance(key_hash, str): + user_condition["token"] = key_hash if user_condition: or_conditions.append(user_condition) diff --git a/tests/litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/litellm/proxy/management_endpoints/test_key_management_endpoints.py index c436e08901..8d72441c11 100644 --- a/tests/litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -30,6 +30,7 @@ async def test_list_keys(): "team_id": None, "organization_id": None, "key_alias": None, + "key_hash": None, "exclude_team_id": None, "return_full_object": True, "admin_team_ids": ["28bd3181-02c5-48f2-b408-ce790fb3d5ba"], diff --git a/tests/proxy_admin_ui_tests/test_key_management.py b/tests/proxy_admin_ui_tests/test_key_management.py index b943b9591c..b3bb65e340 100644 --- a/tests/proxy_admin_ui_tests/test_key_management.py +++ b/tests/proxy_admin_ui_tests/test_key_management.py @@ -989,6 +989,7 @@ async def test_list_key_helper(prisma_client): user_id=None, team_id=None, key_alias=None, + key_hash=None, organization_id=None, ) assert len(result["keys"]) == 2, "Should return exactly 2 keys" @@ -1004,6 +1005,7 @@ async def test_list_key_helper(prisma_client): user_id=test_user_id, team_id=None, key_alias=None, + key_hash=None, organization_id=None, ) assert len(result["keys"]) == 3, "Should return exactly 3 keys for test user" @@ -1016,6 +1018,7 @@ async def test_list_key_helper(prisma_client): user_id=None, team_id=test_team_id, key_alias=None, + key_hash=None, organization_id=None, ) assert len(result["keys"]) == 2, "Should return exactly 2 keys for test team" @@ -1028,6 +1031,7 @@ async def test_list_key_helper(prisma_client): user_id=None, team_id=None, key_alias=test_key_alias, + key_hash=None, organization_id=None, ) assert len(result["keys"]) == 1, "Should return exactly 1 key with test alias" @@ -1040,6 +1044,7 @@ async def test_list_key_helper(prisma_client): user_id=test_user_id, team_id=None, key_alias=None, + key_hash=None, return_full_object=True, organization_id=None, ) @@ -1141,6 +1146,7 @@ async def test_list_key_helper_team_filtering(prisma_client): user_id=None, team_id=None, key_alias=None, + key_hash=None, return_full_object=True, organization_id=None, ) diff --git a/ui/litellm-dashboard/src/components/all_keys_table.tsx b/ui/litellm-dashboard/src/components/all_keys_table.tsx index 6ecd802b65..54f9d647f8 100644 --- a/ui/litellm-dashboard/src/components/all_keys_table.tsx +++ b/ui/litellm-dashboard/src/components/all_keys_table.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { ColumnDef, Row } from "@tanstack/react-table"; import { DataTable } from "./view_logs/table"; import { Select, SelectItem } from "@tremor/react" @@ -9,13 +9,16 @@ import { Tooltip } from "antd"; import { Team, KeyResponse } from "./key_team_helpers/key_list"; import FilterComponent from "./common_components/filter"; import { FilterOption } from "./common_components/filter"; -import { Organization, userListCall } from "./networking"; +import { keyListCall, Organization, userListCall } from "./networking"; import { createTeamSearchFunction } from "./key_team_helpers/team_search_fn"; import { createOrgSearchFunction } from "./key_team_helpers/organization_search_fn"; import { useFilterLogic } from "./key_team_helpers/filter_logic"; import { Setter } from "@/types"; import { updateExistingKeys } from "@/utils/dataUtils"; - +import { debounce } from "lodash"; +import { defaultPageSize } from "./constants"; +import { fetchAllTeams } from "./key_team_helpers/filter_helpers"; +import { fetchAllOrganizations } from "./key_team_helpers/filter_helpers"; interface AllKeysTableProps { keys: KeyResponse[]; setKeys: Setter; @@ -90,6 +93,8 @@ const TeamFilter = ({ * AllKeysTable – a new table for keys that mimics the table styling used in view_logs. * The team selector and filtering have been removed so that all keys are shown. */ + + export function AllKeysTable({ keys, setKeys, @@ -111,8 +116,8 @@ export function AllKeysTable({ }: AllKeysTableProps) { const [selectedKeyId, setSelectedKeyId] = useState(null); const [userList, setUserList] = useState([]); - // Use the filter logic hook + const { filters, filteredKeys, @@ -126,11 +131,10 @@ export function AllKeysTable({ teams, organizations, accessToken, - setSelectedTeam, - setCurrentOrg, - setSelectedKeyAlias }); + + useEffect(() => { if (accessToken) { const user_IDs = keys.map(key => key.user_id).filter(id => id !== null); @@ -208,7 +212,7 @@ export function AllKeysTable({ accessorKey: "team_id", // Change to access the team_id cell: ({ row, getValue }) => { const teamId = getValue() as string; - const team = allTeams?.find(t => t.team_id === teamId); + const team = teams?.find(t => t.team_id === teamId); return team?.team_alias || "Unknown"; }, }, @@ -379,7 +383,18 @@ export function AllKeysTable({ } }); } + }, + { + name: 'User ID', + label: 'User ID', + isSearchable: false, + }, + { + name: 'Key Hash', + label: 'Key Hash', + isSearchable: false, } + ]; @@ -414,34 +429,35 @@ export function AllKeysTable({ /> ) : (
-
+
-
- - Showing {isLoading ? "..." : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`} of {isLoading ? "..." : pagination.totalCount} results +
+ +
+ + Showing {isLoading ? "..." : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`} of {isLoading ? "..." : pagination.totalCount} results + + +
+ + Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} -
- - Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} - - - - - -
+ + +
diff --git a/ui/litellm-dashboard/src/components/common_components/filter.tsx b/ui/litellm-dashboard/src/components/common_components/filter.tsx index cca4f70b00..1155a04192 100644 --- a/ui/litellm-dashboard/src/components/common_components/filter.tsx +++ b/ui/litellm-dashboard/src/components/common_components/filter.tsx @@ -1,14 +1,6 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { Button, Input, Dropdown, MenuProps, Select, Spin } from 'antd'; -import { Card, Button as TremorButton } from '@tremor/react'; -import { - FilterIcon, - XIcon, - CheckIcon, - ChevronDownIcon, - ChevronUpIcon, - SearchIcon -} from '@heroicons/react/outline'; +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 { @@ -35,124 +27,40 @@ const FilterComponent: React.FC = ({ onApplyFilters, onResetFilters, initialValues = {}, - buttonLabel = "Filter", + buttonLabel = "Filters", }) => { const [showFilters, setShowFilters] = useState(false); - const [selectedFilter, setSelectedFilter] = useState(options[0]?.name || ''); - const [filterValues, setFilterValues] = useState(initialValues); const [tempValues, setTempValues] = useState(initialValues); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [searchOptions, setSearchOptions] = useState>([]); - const [searchLoading, setSearchLoading] = useState(false); - const [searchInputValue, setSearchInputValue] = useState(''); - - const filtersRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (filtersRef.current && - !filtersRef.current.contains(target) && - !target.closest('.ant-dropdown') && - !target.closest('.ant-select-dropdown')) { - setShowFilters(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - useEffect(() => { - if (options.length > 0 && options[0].isSearchable && options[0].searchFn) { - loadInitialOptions(options[0]); - } - }, []); - - const loadInitialOptions = async (option: FilterOption) => { - if (!option.isSearchable || !option.searchFn) return; - - setSearchLoading(true); - try { - const results = await option.searchFn(''); - setSearchOptions(results); - } catch (error) { - console.error('Error loading initial options:', error); - setSearchOptions([]); - } finally { - setSearchLoading(false); - } - }; - - useEffect(() => { - if (showFilters && currentOption?.isSearchable && currentOption?.searchFn) { - loadInitialOptions(currentOption); - } - }, [showFilters, selectedFilter]); - - const handleFilterSelect = (key: string) => { - setSelectedFilter(key); - setDropdownOpen(false); - - const newOption = options.find(opt => opt.name === key); - if (newOption?.isSearchable && newOption?.searchFn) { - loadInitialOptions(newOption); - } else { - setSearchOptions([]); - } - }; + 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; - setSearchLoading(true); + setSearchLoadingMap(prev => ({ ...prev, [option.name]: true })); try { const results = await option.searchFn(value); - setSearchOptions(results); + setSearchOptionsMap(prev => ({ ...prev, [option.name]: results })); } catch (error) { console.error('Error searching:', error); - setSearchOptions([]); + setSearchOptionsMap(prev => ({ ...prev, [option.name]: [] })); } finally { - setSearchLoading(false); + setSearchLoadingMap(prev => ({ ...prev, [option.name]: false })); } }, 300), [] ); - - const handleFilterChange = (value: string) => { - setTempValues(prev => ({ - ...prev, - [selectedFilter]: value - })); - }; - - const clearFilters = () => { - const emptyValues: FilterValues = {}; - options.forEach(option => { - emptyValues[option.name] = ''; - }); - setTempValues(emptyValues); - }; - - const handleApplyFilters = () => { - setFilterValues(tempValues); - onApplyFilters(tempValues); - setShowFilters(false); - }; - - const dropdownItems: MenuProps['items'] = options.map(option => ({ - key: option.name, - label: ( -
- {selectedFilter === option.name && ( - - )} - {option.label || option.name} -
- ), - })); - const currentOption = options.find(option => option.name === selectedFilter); + const handleFilterChange = (name: string, value: string) => { + const newValues = { + ...tempValues, + [name]: value + }; + setTempValues(newValues); + onApplyFilters(newValues); + }; const resetFilters = () => { const emptyValues: FilterValues = {}; @@ -160,132 +68,73 @@ const FilterComponent: React.FC = ({ emptyValues[option.name] = ''; }); setTempValues(emptyValues); - setFilterValues(emptyValues); - setSearchInputValue(''); - setSearchOptions([]); - onResetFilters(); // Call the parent's reset function + onResetFilters(); }; - + + // Define the order of filters + const orderedFilters = [ + 'Team ID', + 'Organization ID', + 'Key Alias', + 'User ID', + 'Key Hash' + ]; + return ( -
- setShowFilters(!showFilters)} - variant="secondary" - size='xs' - className="flex items-center pr-2" - > - {buttonLabel} - +
+
+ + +
+ {showFilters && ( - -
-
- Where - - handleFilterSelect(key), - style: { minWidth: '200px' } - }} - onOpenChange={setDropdownOpen} - open={dropdownOpen} - trigger={['click']} - > - - - - {currentOption?.isSearchable ? ( - handleFilterChange(e.target.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" - suffix={ - tempValues[selectedFilter] ? ( - { - e.stopPropagation(); - handleFilterChange(''); - }} - /> - ) : null - } - /> - )} -
+
+ {orderedFilters.map((filterName) => { + const option = options.find(opt => opt.label === filterName || opt.name === filterName); + if (!option) return null; - -
- - -
-
- + return ( +
+ + {option.isSearchable ? ( + handleFilterChange(option.name, e.target.value)} + allowClear + /> + )} +
+ ); + })} +
)}
); diff --git a/ui/litellm-dashboard/src/components/constants.tsx b/ui/litellm-dashboard/src/components/constants.tsx index 81ba55784a..a26ac677d1 100644 --- a/ui/litellm-dashboard/src/components/constants.tsx +++ b/ui/litellm-dashboard/src/components/constants.tsx @@ -12,4 +12,6 @@ export const useBaseUrl = () => { }, []); // Removed router dependency return baseUrl; -}; \ No newline at end of file +}; + +export const defaultPageSize = 25; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index 7e99c7f9fc..887815596d 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -1,38 +1,42 @@ -import { keyListCall, teamListCall, organizationListCall } from '../networking'; -import { Team } from './key_list'; -import { Organization } from '../networking'; +import { keyListCall, teamListCall, organizationListCall } from "../networking"; +import { Team } from "./key_list"; +import { Organization } from "../networking"; /** * Fetches all key aliases across all pages * @param accessToken The access token for API authentication * @returns Array of all unique key aliases */ -export const fetchAllKeyAliases = async (accessToken: string | null): Promise => { +export const fetchAllKeyAliases = async ( + accessToken: string | null +): Promise => { if (!accessToken) return []; - + try { // Fetch all pages of keys to extract aliases let allAliases: string[] = []; let currentPage = 1; let hasMorePages = true; - + while (hasMorePages) { const response = await keyListCall( accessToken, null, // organization_id "", // team_id null, // selectedKeyAlias + null, // user_id + null, // key_hash currentPage, 100 // larger page size to reduce number of requests ); - + // Extract aliases from this page const pageAliases = response.keys .map((key: any) => key.key_alias) .filter(Boolean) as string[]; - + allAliases = [...allAliases, ...pageAliases]; - + // Check if there are more pages if (currentPage < response.total_pages) { currentPage++; @@ -40,7 +44,7 @@ export const fetchAllKeyAliases = async (accessToken: string | null): Promise => { +export const fetchAllTeams = async ( + accessToken: string | null, + organizationId?: string | null +): Promise => { if (!accessToken) return []; - + try { let allTeams: Team[] = []; let currentPage = 1; let hasMorePages = true; - + while (hasMorePages) { const response = await teamListCall( accessToken, organizationId || null, - null, + null ); - + // Add teams from this page - allTeams = [...allTeams, ...response.teams]; - + allTeams = [...allTeams, ...response]; + // Check if there are more pages if (currentPage < response.total_pages) { currentPage++; @@ -80,7 +87,7 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?: hasMorePages = false; } } - + return allTeams; } catch (error) { console.error("Error fetching all teams:", error); @@ -93,22 +100,22 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?: * @param accessToken The access token for API authentication * @returns Array of all organizations */ -export const fetchAllOrganizations = async (accessToken: string | null): Promise => { +export const fetchAllOrganizations = async ( + accessToken: string | null +): Promise => { if (!accessToken) return []; - + try { let allOrganizations: Organization[] = []; let currentPage = 1; let hasMorePages = true; - + while (hasMorePages) { - const response = await organizationListCall( - accessToken - ); - + const response = await organizationListCall(accessToken); + // Add organizations from this page - allOrganizations = [...allOrganizations, ...response.organizations]; - + allOrganizations = [...allOrganizations, ...response]; + // Check if there are more pages if (currentPage < response.total_pages) { currentPage++; @@ -116,7 +123,7 @@ export const fetchAllOrganizations = async (accessToken: string | null): Promise hasMorePages = false; } } - + return allOrganizations; } catch (error) { console.error("Error fetching all organizations:", error); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx index cc3d0eea38..1ebca77461 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_logic.tsx @@ -1,45 +1,81 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState, useRef } from "react"; import { KeyResponse } from "../key_team_helpers/key_list"; -import { Organization } from "../networking"; +import { keyListCall, Organization } from "../networking"; import { Team } from "../key_team_helpers/key_list"; import { useQuery } from "@tanstack/react-query"; import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "./filter_helpers"; import { Setter } from "@/types"; - +import { debounce } from "lodash"; +import { defaultPageSize } from "../constants"; export interface FilterState { 'Team ID': string; 'Organization ID': string; 'Key Alias': string; [key: string]: string; + 'User ID': string; } + + export function useFilterLogic({ keys, teams, organizations, accessToken, - setSelectedTeam, - setCurrentOrg, - setSelectedKeyAlias }: { keys: KeyResponse[]; teams: Team[] | null; organizations: Organization[] | null; accessToken: string | null; - setSelectedTeam: (team: Team | null) => void; - setCurrentOrg: React.Dispatch>; - setSelectedKeyAlias: Setter }) { - const [filters, setFilters] = useState({ + const defaultFilters: FilterState = { 'Team ID': '', 'Organization ID': '', - 'Key Alias': '' - }); + 'Key Alias': '', + 'User ID': '' + } + const [filters, setFilters] = useState(defaultFilters); const [allTeams, setAllTeams] = useState(teams || []); const [allOrganizations, setAllOrganizations] = useState(organizations || []); const [filteredKeys, setFilteredKeys] = useState(keys); - + const lastSearchTimestamp = useRef(0); + const debouncedSearch = useCallback( + debounce(async (filters: FilterState) => { + if (!accessToken) { + return; + } + + const currentTimestamp = Date.now(); + lastSearchTimestamp.current = currentTimestamp; + + try { + // Make the API call using userListCall with all filter parameters + const data = await keyListCall( + accessToken, + filters["Organization ID"] || null, + filters["Team ID"] || null, + filters["Key Alias"] || null, + filters["User ID"] || null, + filters["Key Hash"] || null, + 1, // Reset to first page when searching + defaultPageSize + ); + + // Only update state if this is the most recent search + if (currentTimestamp === lastSearchTimestamp.current) { + if (data) { + setFilteredKeys(data.keys); + console.log("called from debouncedSearch filters:", JSON.stringify(filters)); + console.log("called from debouncedSearch data:", JSON.stringify(data)); + } + } + } catch (error) { + console.error("Error searching users:", error); + } + }, 300), + [accessToken] + ); // Apply filters to keys whenever keys or filters change useEffect(() => { if (!keys) { @@ -120,44 +156,24 @@ export function useFilterLogic({ setFilters({ 'Team ID': newFilters['Team ID'] || '', 'Organization ID': newFilters['Organization ID'] || '', - 'Key Alias': newFilters['Key Alias'] || '' + 'Key Alias': newFilters['Key Alias'] || '', + 'User ID': newFilters['User ID'] || '' }); - - // Handle Team change - if (newFilters['Team ID']) { - const selectedTeamData = allTeams?.find(team => team.team_id === newFilters['Team ID']); - if (selectedTeamData) { - setSelectedTeam(selectedTeamData); - } - } - - // Handle Org change - if (newFilters['Organization ID']) { - const selectedOrg = allOrganizations?.find(org => org.organization_id === newFilters['Organization ID']); - if (selectedOrg) { - setCurrentOrg(selectedOrg); - } - } - const keyAlias = newFilters['Key Alias']; - const selectedKeyAlias = keyAlias - ? allKeyAliases.find((k) => k === keyAlias) || null - : null; - setSelectedKeyAlias(selectedKeyAlias) + // Fetch keys based on new filters + const updatedFilters = { + ...filters, + ...newFilters + } + debouncedSearch(updatedFilters); }; const handleFilterReset = () => { // Reset filters state - setFilters({ - 'Team ID': '', - 'Organization ID': '', - 'Key Alias': '' - }); + setFilters(defaultFilters); // Reset selections - setSelectedTeam(null); - setCurrentOrg(null); - setSelectedKeyAlias(null); + debouncedSearch(defaultFilters); }; return { diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 98d8e0e499..533968361d 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -133,10 +133,12 @@ const useKeyList = ({ const data = await keyListCall( accessToken, - currentOrg?.organization_id || null, - selectedTeam?.team_id || "", - selectedKeyAlias, - params.page as number || 1, + null, + null, + null, + null, + null, + 1, 50, ); console.log("data", data); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 6a343293ad..a502716315 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2689,6 +2689,8 @@ export const keyListCall = async ( organizationID: string | null, teamID: string | null, selectedKeyAlias: string | null, + userID: string | null, + keyHash: string | null, page: number, pageSize: number, ) => { @@ -2712,6 +2714,14 @@ export const keyListCall = async ( queryParams.append('key_alias', selectedKeyAlias) } + if (keyHash) { + queryParams.append('key_hash', keyHash); + } + + if (userID) { + queryParams.append('user_id', userID.toString()); + } + if (page) { queryParams.append('page', page.toString()); } diff --git a/ui/litellm-dashboard/src/components/view_key_table.tsx b/ui/litellm-dashboard/src/components/view_key_table.tsx index 715bf6b7f7..c2d742f27c 100644 --- a/ui/litellm-dashboard/src/components/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/view_key_table.tsx @@ -171,7 +171,7 @@ const ViewKeyTable: React.FC = ({ // NEW: Declare filter states for team and key alias. const [teamFilter, setTeamFilter] = useState(selectedTeam?.team_id || ""); - const [keyAliasFilter, setKeyAliasFilter] = useState(""); + // Keep the team filter in sync with the incoming prop. useEffect(() => {