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 5fbc331970.

* 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
This commit is contained in:
Krish Dholakia 2025-04-25 21:30:56 -07:00 committed by GitHub
parent 339351c579
commit c66c821f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 292 additions and 350 deletions

View file

@ -1000,9 +1000,9 @@ class PrometheusLogger(CustomLogger):
): ):
try: try:
verbose_logger.debug("setting remaining tokens requests metric") verbose_logger.debug("setting remaining tokens requests metric")
standard_logging_payload: Optional[StandardLoggingPayload] = ( standard_logging_payload: Optional[
request_kwargs.get("standard_logging_object") StandardLoggingPayload
) ] = request_kwargs.get("standard_logging_object")
if standard_logging_payload is None: if standard_logging_payload is None:
return return
@ -1453,6 +1453,7 @@ class PrometheusLogger(CustomLogger):
user_id=None, user_id=None,
team_id=None, team_id=None,
key_alias=None, key_alias=None,
key_hash=None,
exclude_team_id=UI_SESSION_TOKEN_TEAM_ID, exclude_team_id=UI_SESSION_TOKEN_TEAM_ID,
return_full_object=True, return_full_object=True,
organization_id=None, organization_id=None,
@ -1771,11 +1772,11 @@ class PrometheusLogger(CustomLogger):
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.prometheus import PrometheusLogger from litellm.integrations.prometheus import PrometheusLogger
prometheus_loggers: List[CustomLogger] = ( prometheus_loggers: List[
litellm.logging_callback_manager.get_custom_loggers_for_type( CustomLogger
] = litellm.logging_callback_manager.get_custom_loggers_for_type(
callback_type=PrometheusLogger callback_type=PrometheusLogger
) )
)
# we need to get the initialized prometheus logger instance(s) and call logger.initialize_remaining_budget_metrics() on them # 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)) verbose_logger.debug("found %s prometheus loggers", len(prometheus_loggers))
if len(prometheus_loggers) > 0: if len(prometheus_loggers) > 0:

View file

@ -1861,6 +1861,7 @@ async def validate_key_list_check(
team_id: Optional[str], team_id: Optional[str],
organization_id: Optional[str], organization_id: Optional[str],
key_alias: Optional[str], key_alias: Optional[str],
key_hash: Optional[str],
prisma_client: PrismaClient, prisma_client: PrismaClient,
) -> Optional[LiteLLM_UserTable]: ) -> Optional[LiteLLM_UserTable]:
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
@ -1924,6 +1925,31 @@ async def validate_key_list_check(
param="organization_id", param="organization_id",
code=status.HTTP_403_FORBIDDEN, 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 return complete_user_info
@ -1972,6 +1998,7 @@ async def list_keys(
organization_id: Optional[str] = Query( organization_id: Optional[str] = Query(
None, description="Filter keys by organization ID" 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"), key_alias: Optional[str] = Query(None, description="Filter keys by key alias"),
return_full_object: bool = Query(False, description="Return full key object"), return_full_object: bool = Query(False, description="Return full key object"),
include_team_keys: bool = Query( include_team_keys: bool = Query(
@ -2004,6 +2031,7 @@ async def list_keys(
team_id=team_id, team_id=team_id,
organization_id=organization_id, organization_id=organization_id,
key_alias=key_alias, key_alias=key_alias,
key_hash=key_hash,
prisma_client=prisma_client, prisma_client=prisma_client,
) )
@ -2029,6 +2057,7 @@ async def list_keys(
user_id=user_id, user_id=user_id,
team_id=team_id, team_id=team_id,
key_alias=key_alias, key_alias=key_alias,
key_hash=key_hash,
return_full_object=return_full_object, return_full_object=return_full_object,
organization_id=organization_id, organization_id=organization_id,
admin_team_ids=admin_team_ids, admin_team_ids=admin_team_ids,
@ -2065,6 +2094,7 @@ async def _list_key_helper(
team_id: Optional[str], team_id: Optional[str],
organization_id: Optional[str], organization_id: Optional[str],
key_alias: Optional[str], key_alias: Optional[str],
key_hash: Optional[str],
exclude_team_id: Optional[str] = None, exclude_team_id: Optional[str] = None,
return_full_object: bool = False, return_full_object: bool = False,
admin_team_ids: Optional[ admin_team_ids: Optional[
@ -2111,6 +2141,8 @@ async def _list_key_helper(
user_condition["team_id"] = {"not": exclude_team_id} user_condition["team_id"] = {"not": exclude_team_id}
if organization_id and isinstance(organization_id, str): if organization_id and isinstance(organization_id, str):
user_condition["organization_id"] = organization_id user_condition["organization_id"] = organization_id
if key_hash and isinstance(key_hash, str):
user_condition["token"] = key_hash
if user_condition: if user_condition:
or_conditions.append(user_condition) or_conditions.append(user_condition)

View file

@ -30,6 +30,7 @@ async def test_list_keys():
"team_id": None, "team_id": None,
"organization_id": None, "organization_id": None,
"key_alias": None, "key_alias": None,
"key_hash": None,
"exclude_team_id": None, "exclude_team_id": None,
"return_full_object": True, "return_full_object": True,
"admin_team_ids": ["28bd3181-02c5-48f2-b408-ce790fb3d5ba"], "admin_team_ids": ["28bd3181-02c5-48f2-b408-ce790fb3d5ba"],

View file

@ -989,6 +989,7 @@ async def test_list_key_helper(prisma_client):
user_id=None, user_id=None,
team_id=None, team_id=None,
key_alias=None, key_alias=None,
key_hash=None,
organization_id=None, organization_id=None,
) )
assert len(result["keys"]) == 2, "Should return exactly 2 keys" 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, user_id=test_user_id,
team_id=None, team_id=None,
key_alias=None, key_alias=None,
key_hash=None,
organization_id=None, organization_id=None,
) )
assert len(result["keys"]) == 3, "Should return exactly 3 keys for test user" 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, user_id=None,
team_id=test_team_id, team_id=test_team_id,
key_alias=None, key_alias=None,
key_hash=None,
organization_id=None, organization_id=None,
) )
assert len(result["keys"]) == 2, "Should return exactly 2 keys for test team" 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, user_id=None,
team_id=None, team_id=None,
key_alias=test_key_alias, key_alias=test_key_alias,
key_hash=None,
organization_id=None, organization_id=None,
) )
assert len(result["keys"]) == 1, "Should return exactly 1 key with test alias" 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, user_id=test_user_id,
team_id=None, team_id=None,
key_alias=None, key_alias=None,
key_hash=None,
return_full_object=True, return_full_object=True,
organization_id=None, organization_id=None,
) )
@ -1141,6 +1146,7 @@ async def test_list_key_helper_team_filtering(prisma_client):
user_id=None, user_id=None,
team_id=None, team_id=None,
key_alias=None, key_alias=None,
key_hash=None,
return_full_object=True, return_full_object=True,
organization_id=None, organization_id=None,
) )

View file

@ -1,5 +1,5 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useCallback, useRef } from "react";
import { ColumnDef, Row } from "@tanstack/react-table"; import { ColumnDef, Row } from "@tanstack/react-table";
import { DataTable } from "./view_logs/table"; import { DataTable } from "./view_logs/table";
import { Select, SelectItem } from "@tremor/react" import { Select, SelectItem } from "@tremor/react"
@ -9,13 +9,16 @@ import { Tooltip } from "antd";
import { Team, KeyResponse } from "./key_team_helpers/key_list"; import { Team, KeyResponse } from "./key_team_helpers/key_list";
import FilterComponent from "./common_components/filter"; import FilterComponent from "./common_components/filter";
import { FilterOption } 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 { createTeamSearchFunction } from "./key_team_helpers/team_search_fn";
import { createOrgSearchFunction } from "./key_team_helpers/organization_search_fn"; import { createOrgSearchFunction } from "./key_team_helpers/organization_search_fn";
import { useFilterLogic } from "./key_team_helpers/filter_logic"; import { useFilterLogic } from "./key_team_helpers/filter_logic";
import { Setter } from "@/types"; import { Setter } from "@/types";
import { updateExistingKeys } from "@/utils/dataUtils"; 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 { interface AllKeysTableProps {
keys: KeyResponse[]; keys: KeyResponse[];
setKeys: Setter<KeyResponse[]>; setKeys: Setter<KeyResponse[]>;
@ -90,6 +93,8 @@ const TeamFilter = ({
* AllKeysTable a new table for keys that mimics the table styling used in view_logs. * 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. * The team selector and filtering have been removed so that all keys are shown.
*/ */
export function AllKeysTable({ export function AllKeysTable({
keys, keys,
setKeys, setKeys,
@ -111,8 +116,8 @@ export function AllKeysTable({
}: AllKeysTableProps) { }: AllKeysTableProps) {
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null); const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [userList, setUserList] = useState<UserResponse[]>([]); const [userList, setUserList] = useState<UserResponse[]>([]);
// Use the filter logic hook // Use the filter logic hook
const { const {
filters, filters,
filteredKeys, filteredKeys,
@ -126,11 +131,10 @@ export function AllKeysTable({
teams, teams,
organizations, organizations,
accessToken, accessToken,
setSelectedTeam,
setCurrentOrg,
setSelectedKeyAlias
}); });
useEffect(() => { useEffect(() => {
if (accessToken) { if (accessToken) {
const user_IDs = keys.map(key => key.user_id).filter(id => id !== null); 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 accessorKey: "team_id", // Change to access the team_id
cell: ({ row, getValue }) => { cell: ({ row, getValue }) => {
const teamId = getValue() as string; 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"; 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,9 +429,11 @@ export function AllKeysTable({
/> />
) : ( ) : (
<div className="border-b py-4 flex-1 overflow-hidden"> <div className="border-b py-4 flex-1 overflow-hidden">
<div className="flex items-center justify-between w-full mb-2"> <div className="w-full mb-6">
<FilterComponent options={filterOptions} onApplyFilters={handleFilterChange} initialValues={filters} onResetFilters={handleFilterReset}/> <FilterComponent options={filterOptions} onApplyFilters={handleFilterChange} initialValues={filters} onResetFilters={handleFilterReset}/>
<div className="flex items-center gap-4"> </div>
<div className="flex items-center justify-between w-full mb-4">
<span className="inline-flex text-sm text-gray-700"> <span className="inline-flex text-sm text-gray-700">
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
</span> </span>
@ -443,7 +460,6 @@ export function AllKeysTable({
</button> </button>
</div> </div>
</div> </div>
</div>
<div className="h-[75vh] overflow-auto"> <div className="h-[75vh] overflow-auto">
<DataTable <DataTable

View file

@ -1,14 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Button, Input, Dropdown, MenuProps, Select, Spin } from 'antd'; import { Button, Input, Select } from 'antd';
import { Card, Button as TremorButton } from '@tremor/react'; import { FilterIcon } from '@heroicons/react/outline';
import {
FilterIcon,
XIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
SearchIcon
} from '@heroicons/react/outline';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
export interface FilterOption { export interface FilterOption {
@ -35,257 +27,114 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
onApplyFilters, onApplyFilters,
onResetFilters, onResetFilters,
initialValues = {}, initialValues = {},
buttonLabel = "Filter", buttonLabel = "Filters",
}) => { }) => {
const [showFilters, setShowFilters] = useState<boolean>(false); const [showFilters, setShowFilters] = useState<boolean>(false);
const [selectedFilter, setSelectedFilter] = useState<string>(options[0]?.name || '');
const [filterValues, setFilterValues] = useState<FilterValues>(initialValues);
const [tempValues, setTempValues] = useState<FilterValues>(initialValues); const [tempValues, setTempValues] = useState<FilterValues>(initialValues);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false); const [searchOptionsMap, setSearchOptionsMap] = useState<{ [key: string]: Array<{ label: string; value: string }> }>({});
const [searchOptions, setSearchOptions] = useState<Array<{ label: string; value: string }>>([]); const [searchLoadingMap, setSearchLoadingMap] = useState<{ [key: string]: boolean }>({});
const [searchLoading, setSearchLoading] = useState<boolean>(false); const [searchInputValueMap, setSearchInputValueMap] = useState<{ [key: string]: string }>({});
const [searchInputValue, setSearchInputValue] = useState<string>('');
const filtersRef = useRef<HTMLDivElement>(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 debouncedSearch = useCallback( const debouncedSearch = useCallback(
debounce(async (value: string, option: FilterOption) => { debounce(async (value: string, option: FilterOption) => {
if (!option.isSearchable || !option.searchFn) return; if (!option.isSearchable || !option.searchFn) return;
setSearchLoading(true); setSearchLoadingMap(prev => ({ ...prev, [option.name]: true }));
try { try {
const results = await option.searchFn(value); const results = await option.searchFn(value);
setSearchOptions(results); setSearchOptionsMap(prev => ({ ...prev, [option.name]: results }));
} catch (error) { } catch (error) {
console.error('Error searching:', error); console.error('Error searching:', error);
setSearchOptions([]); setSearchOptionsMap(prev => ({ ...prev, [option.name]: [] }));
} finally { } finally {
setSearchLoading(false); setSearchLoadingMap(prev => ({ ...prev, [option.name]: false }));
} }
}, 300), }, 300),
[] []
); );
const handleFilterChange = (value: string) => { const handleFilterChange = (name: string, value: string) => {
setTempValues(prev => ({ const newValues = {
...prev, ...tempValues,
[selectedFilter]: value [name]: value
}));
}; };
setTempValues(newValues);
const clearFilters = () => { onApplyFilters(newValues);
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: (
<div className="flex items-center gap-2">
{selectedFilter === option.name && (
<CheckIcon className="h-4 w-4 text-blue-600" />
)}
{option.label || option.name}
</div>
),
}));
const currentOption = options.find(option => option.name === selectedFilter);
const resetFilters = () => { const resetFilters = () => {
const emptyValues: FilterValues = {}; const emptyValues: FilterValues = {};
options.forEach(option => { options.forEach(option => {
emptyValues[option.name] = ''; emptyValues[option.name] = '';
}); });
setTempValues(emptyValues); setTempValues(emptyValues);
setFilterValues(emptyValues); onResetFilters();
setSearchInputValue('');
setSearchOptions([]);
onResetFilters(); // Call the parent's reset function
}; };
// Define the order of filters
const orderedFilters = [
'Team ID',
'Organization ID',
'Key Alias',
'User ID',
'Key Hash'
];
return ( return (
<div className="relative" ref={filtersRef}> <div className="w-full">
<TremorButton <div className="flex items-center gap-2 mb-6">
icon={FilterIcon} <Button
icon={<FilterIcon className="h-4 w-4" />}
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
variant="secondary" className="flex items-center gap-2"
size='xs'
className="flex items-center pr-2"
> >
{buttonLabel} {buttonLabel}
</TremorButton>
{showFilters && (
<Card className="absolute left-0 mt-2 w-[500px] z-50 border border-gray-200 shadow-lg">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Where</span>
<Dropdown
menu={{
items: dropdownItems,
onClick: ({ key }) => handleFilterSelect(key),
style: { minWidth: '200px' }
}}
onOpenChange={setDropdownOpen}
open={dropdownOpen}
trigger={['click']}
>
<Button className="min-w-40 text-left flex justify-between items-center">
{currentOption?.label || selectedFilter}
{dropdownOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button> </Button>
</Dropdown> <Button onClick={resetFilters}>Reset Filters</Button>
</div>
{currentOption?.isSearchable ? ( {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);
if (!option) return null;
return (
<div key={option.name} className="flex flex-col gap-2">
<label className="text-sm text-gray-600">
{option.label || option.name}
</label>
{option.isSearchable ? (
<Select <Select
showSearch showSearch
placeholder={`Search ${currentOption.label || selectedFilter}...`} className="w-full"
value={tempValues[selectedFilter] || undefined} placeholder={`Search ${option.label || option.name}...`}
onChange={(value) => handleFilterChange(value)} value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
onSearch={(value) => { onSearch={(value) => {
setSearchInputValue(value); setSearchInputValueMap(prev => ({ ...prev, [option.name]: value }));
debouncedSearch(value, currentOption); if (option.searchFn) {
}} debouncedSearch(value, option);
onInputKeyDown={(e) => {
if (e.key === 'Enter' && searchInputValue) {
// Allow manual entry of the value on Enter
handleFilterChange(searchInputValue);
e.preventDefault();
} }
}} }}
filterOption={false} filterOption={false}
className="flex-1 w-full max-w-full truncate min-w-100" loading={searchLoadingMap[option.name]}
loading={searchLoading} options={searchOptionsMap[option.name] || []}
options={searchOptions}
allowClear allowClear
notFoundContent={
searchLoading ? <Spin size="small" /> : (
<div className="p-2">
{searchInputValue && (
<Button
type="link"
className="p-0 mt-1"
onClick={() => {
handleFilterChange(searchInputValue);
// Close the dropdown/select after selecting the value
const selectElement = document.activeElement as HTMLElement;
if (selectElement) {
selectElement.blur();
}
}}
>
Use &ldquo;{searchInputValue}&rdquo; as filter value
</Button>
)}
</div>
)
}
/> />
) : ( ) : (
<Input <Input
placeholder="Enter value..." className="w-full"
value={tempValues[selectedFilter] || ''} placeholder={`Enter ${option.label || option.name}...`}
onChange={(e) => handleFilterChange(e.target.value)} value={tempValues[option.name] || ''}
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" onChange={(e) => handleFilterChange(option.name, e.target.value)}
suffix={ allowClear
tempValues[selectedFilter] ? (
<XIcon
className="h-4 w-4 cursor-pointer text-gray-400 hover:text-gray-500"
onClick={(e) => {
e.stopPropagation();
handleFilterChange('');
}}
/>
) : null
}
/> />
)} )}
</div> </div>
);
})}
<div className="flex gap-2 justify-end">
<Button
onClick={() => {
clearFilters();
onResetFilters();
setShowFilters(false);
}}
>
Reset
</Button>
<Button onClick={handleApplyFilters}>
Apply Filters
</Button>
</div> </div>
</div>
</Card>
)} )}
</div> </div>
); );

View file

@ -13,3 +13,5 @@ export const useBaseUrl = () => {
return baseUrl; return baseUrl;
}; };
export const defaultPageSize = 25;

View file

@ -1,13 +1,15 @@
import { keyListCall, teamListCall, organizationListCall } from '../networking'; import { keyListCall, teamListCall, organizationListCall } from "../networking";
import { Team } from './key_list'; import { Team } from "./key_list";
import { Organization } from '../networking'; import { Organization } from "../networking";
/** /**
* Fetches all key aliases across all pages * Fetches all key aliases across all pages
* @param accessToken The access token for API authentication * @param accessToken The access token for API authentication
* @returns Array of all unique key aliases * @returns Array of all unique key aliases
*/ */
export const fetchAllKeyAliases = async (accessToken: string | null): Promise<string[]> => { export const fetchAllKeyAliases = async (
accessToken: string | null
): Promise<string[]> => {
if (!accessToken) return []; if (!accessToken) return [];
try { try {
@ -22,6 +24,8 @@ export const fetchAllKeyAliases = async (accessToken: string | null): Promise<st
null, // organization_id null, // organization_id
"", // team_id "", // team_id
null, // selectedKeyAlias null, // selectedKeyAlias
null, // user_id
null, // key_hash
currentPage, currentPage,
100 // larger page size to reduce number of requests 100 // larger page size to reduce number of requests
); );
@ -55,7 +59,10 @@ export const fetchAllKeyAliases = async (accessToken: string | null): Promise<st
* @param organizationId Optional organization ID to filter teams * @param organizationId Optional organization ID to filter teams
* @returns Array of all teams * @returns Array of all teams
*/ */
export const fetchAllTeams = async (accessToken: string | null, organizationId?: string | null): Promise<Team[]> => { export const fetchAllTeams = async (
accessToken: string | null,
organizationId?: string | null
): Promise<Team[]> => {
if (!accessToken) return []; if (!accessToken) return [];
try { try {
@ -67,11 +74,11 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?:
const response = await teamListCall( const response = await teamListCall(
accessToken, accessToken,
organizationId || null, organizationId || null,
null, null
); );
// Add teams from this page // Add teams from this page
allTeams = [...allTeams, ...response.teams]; allTeams = [...allTeams, ...response];
// Check if there are more pages // Check if there are more pages
if (currentPage < response.total_pages) { if (currentPage < response.total_pages) {
@ -93,7 +100,9 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?:
* @param accessToken The access token for API authentication * @param accessToken The access token for API authentication
* @returns Array of all organizations * @returns Array of all organizations
*/ */
export const fetchAllOrganizations = async (accessToken: string | null): Promise<Organization[]> => { export const fetchAllOrganizations = async (
accessToken: string | null
): Promise<Organization[]> => {
if (!accessToken) return []; if (!accessToken) return [];
try { try {
@ -102,12 +111,10 @@ export const fetchAllOrganizations = async (accessToken: string | null): Promise
let hasMorePages = true; let hasMorePages = true;
while (hasMorePages) { while (hasMorePages) {
const response = await organizationListCall( const response = await organizationListCall(accessToken);
accessToken
);
// Add organizations from this page // Add organizations from this page
allOrganizations = [...allOrganizations, ...response.organizations]; allOrganizations = [...allOrganizations, ...response];
// Check if there are more pages // Check if there are more pages
if (currentPage < response.total_pages) { if (currentPage < response.total_pages) {

View file

@ -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 { 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 { Team } from "../key_team_helpers/key_list";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "./filter_helpers"; import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "./filter_helpers";
import { Setter } from "@/types"; import { Setter } from "@/types";
import { debounce } from "lodash";
import { defaultPageSize } from "../constants";
export interface FilterState { export interface FilterState {
'Team ID': string; 'Team ID': string;
'Organization ID': string; 'Organization ID': string;
'Key Alias': string; 'Key Alias': string;
[key: string]: string; [key: string]: string;
'User ID': string;
} }
export function useFilterLogic({ export function useFilterLogic({
keys, keys,
teams, teams,
organizations, organizations,
accessToken, accessToken,
setSelectedTeam,
setCurrentOrg,
setSelectedKeyAlias
}: { }: {
keys: KeyResponse[]; keys: KeyResponse[];
teams: Team[] | null; teams: Team[] | null;
organizations: Organization[] | null; organizations: Organization[] | null;
accessToken: string | null; accessToken: string | null;
setSelectedTeam: (team: Team | null) => void;
setCurrentOrg: React.Dispatch<React.SetStateAction<Organization | null>>;
setSelectedKeyAlias: Setter<string | null>
}) { }) {
const [filters, setFilters] = useState<FilterState>({ const defaultFilters: FilterState = {
'Team ID': '', 'Team ID': '',
'Organization ID': '', 'Organization ID': '',
'Key Alias': '' 'Key Alias': '',
}); 'User ID': ''
}
const [filters, setFilters] = useState<FilterState>(defaultFilters);
const [allTeams, setAllTeams] = useState<Team[]>(teams || []); const [allTeams, setAllTeams] = useState<Team[]>(teams || []);
const [allOrganizations, setAllOrganizations] = useState<Organization[]>(organizations || []); const [allOrganizations, setAllOrganizations] = useState<Organization[]>(organizations || []);
const [filteredKeys, setFilteredKeys] = useState<KeyResponse[]>(keys); const [filteredKeys, setFilteredKeys] = useState<KeyResponse[]>(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 // Apply filters to keys whenever keys or filters change
useEffect(() => { useEffect(() => {
if (!keys) { if (!keys) {
@ -120,44 +156,24 @@ export function useFilterLogic({
setFilters({ setFilters({
'Team ID': newFilters['Team ID'] || '', 'Team ID': newFilters['Team ID'] || '',
'Organization ID': newFilters['Organization ID'] || '', 'Organization ID': newFilters['Organization ID'] || '',
'Key Alias': newFilters['Key Alias'] || '' 'Key Alias': newFilters['Key Alias'] || '',
'User ID': newFilters['User ID'] || ''
}); });
// Handle Team change // Fetch keys based on new filters
if (newFilters['Team ID']) { const updatedFilters = {
const selectedTeamData = allTeams?.find(team => team.team_id === newFilters['Team ID']); ...filters,
if (selectedTeamData) { ...newFilters
setSelectedTeam(selectedTeamData);
} }
} debouncedSearch(updatedFilters);
// 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)
}; };
const handleFilterReset = () => { const handleFilterReset = () => {
// Reset filters state // Reset filters state
setFilters({ setFilters(defaultFilters);
'Team ID': '',
'Organization ID': '',
'Key Alias': ''
});
// Reset selections // Reset selections
setSelectedTeam(null); debouncedSearch(defaultFilters);
setCurrentOrg(null);
setSelectedKeyAlias(null);
}; };
return { return {

View file

@ -133,10 +133,12 @@ const useKeyList = ({
const data = await keyListCall( const data = await keyListCall(
accessToken, accessToken,
currentOrg?.organization_id || null, null,
selectedTeam?.team_id || "", null,
selectedKeyAlias, null,
params.page as number || 1, null,
null,
1,
50, 50,
); );
console.log("data", data); console.log("data", data);

View file

@ -2689,6 +2689,8 @@ export const keyListCall = async (
organizationID: string | null, organizationID: string | null,
teamID: string | null, teamID: string | null,
selectedKeyAlias: string | null, selectedKeyAlias: string | null,
userID: string | null,
keyHash: string | null,
page: number, page: number,
pageSize: number, pageSize: number,
) => { ) => {
@ -2712,6 +2714,14 @@ export const keyListCall = async (
queryParams.append('key_alias', selectedKeyAlias) queryParams.append('key_alias', selectedKeyAlias)
} }
if (keyHash) {
queryParams.append('key_hash', keyHash);
}
if (userID) {
queryParams.append('user_id', userID.toString());
}
if (page) { if (page) {
queryParams.append('page', page.toString()); queryParams.append('page', page.toString());
} }

View file

@ -171,7 +171,7 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
// NEW: Declare filter states for team and key alias. // NEW: Declare filter states for team and key alias.
const [teamFilter, setTeamFilter] = useState<string>(selectedTeam?.team_id || ""); const [teamFilter, setTeamFilter] = useState<string>(selectedTeam?.team_id || "");
const [keyAliasFilter, setKeyAliasFilter] = useState<string>("");
// Keep the team filter in sync with the incoming prop. // Keep the team filter in sync with the incoming prop.
useEffect(() => { useEffect(() => {