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:
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))

View file

@ -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)

View file

@ -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"],

View file

@ -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,
)

View file

@ -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<KeyResponse[]>;
@ -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<string | null>(null);
const [userList, setUserList] = useState<UserResponse[]>([]);
// 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({
/>
) : (
<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}/>
<div className="flex items-center gap-4">
<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
</div>
<div className="flex items-center justify-between w-full mb-4">
<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
</span>
<div className="inline-flex items-center gap-2">
<span className="text-sm text-gray-700">
Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages}
</span>
<div className="inline-flex items-center gap-2">
<span className="text-sm text-gray-700">
Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages}
</span>
<button
onClick={() => onPageChange(pagination.currentPage - 1)}
disabled={isLoading || pagination.currentPage === 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => onPageChange(pagination.currentPage - 1)}
disabled={isLoading || pagination.currentPage === 1}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => onPageChange(pagination.currentPage + 1)}
disabled={isLoading || pagination.currentPage === pagination.totalPages}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<button
onClick={() => onPageChange(pagination.currentPage + 1)}
disabled={isLoading || pagination.currentPage === pagination.totalPages}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
<div className="h-[75vh] overflow-auto">

View file

@ -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,257 +27,114 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
onApplyFilters,
onResetFilters,
initialValues = {},
buttonLabel = "Filter",
buttonLabel = "Filters",
}) => {
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 [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [searchOptions, setSearchOptions] = useState<Array<{ label: string; value: string }>>([]);
const [searchLoading, setSearchLoading] = useState<boolean>(false);
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 [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 handleFilterChange = (name: string, value: string) => {
const newValues = {
...tempValues,
[name]: value
};
setTempValues(newValues);
onApplyFilters(newValues);
};
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: (
<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 emptyValues: FilterValues = {};
options.forEach(option => {
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 (
<div className="relative" ref={filtersRef}>
<TremorButton
icon={FilterIcon}
onClick={() => setShowFilters(!showFilters)}
variant="secondary"
size='xs'
className="flex items-center pr-2"
>
{buttonLabel}
</TremorButton>
<div className="w-full">
<div className="flex items-center gap-2 mb-6">
<Button
icon={<FilterIcon className="h-4 w-4" />}
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
{buttonLabel}
</Button>
<Button onClick={resetFilters}>Reset Filters</Button>
</div>
{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>
<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;
<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>
</Dropdown>
{currentOption?.isSearchable ? (
<Select
showSearch
placeholder={`Search ${currentOption.label || selectedFilter}...`}
value={tempValues[selectedFilter] || undefined}
onChange={(value) => handleFilterChange(value)}
onSearch={(value) => {
setSearchInputValue(value);
debouncedSearch(value, currentOption);
}}
onInputKeyDown={(e) => {
if (e.key === 'Enter' && searchInputValue) {
// Allow manual entry of the value on Enter
handleFilterChange(searchInputValue);
e.preventDefault();
}
}}
filterOption={false}
className="flex-1 w-full max-w-full truncate min-w-100"
loading={searchLoading}
options={searchOptions}
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
placeholder="Enter value..."
value={tempValues[selectedFilter] || ''}
onChange={(e) => 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] ? (
<XIcon
className="h-4 w-4 cursor-pointer text-gray-400 hover:text-gray-500"
onClick={(e) => {
e.stopPropagation();
handleFilterChange('');
}}
/>
) : null
}
/>
)}
</div>
<div className="flex gap-2 justify-end">
<Button
onClick={() => {
clearFilters();
onResetFilters();
setShowFilters(false);
}}
>
Reset
</Button>
<Button onClick={handleApplyFilters}>
Apply Filters
</Button>
</div>
</div>
</Card>
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
showSearch
className="w-full"
placeholder={`Search ${option.label || option.name}...`}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
onSearch={(value) => {
setSearchInputValueMap(prev => ({ ...prev, [option.name]: value }));
if (option.searchFn) {
debouncedSearch(value, option);
}
}}
filterOption={false}
loading={searchLoadingMap[option.name]}
options={searchOptionsMap[option.name] || []}
allowClear
/>
) : (
<Input
className="w-full"
placeholder={`Enter ${option.label || option.name}...`}
value={tempValues[option.name] || ''}
onChange={(e) => handleFilterChange(option.name, e.target.value)}
allowClear
/>
)}
</div>
);
})}
</div>
)}
</div>
);

View file

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

View file

@ -1,13 +1,15 @@
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<string[]> => {
export const fetchAllKeyAliases = async (
accessToken: string | null
): Promise<string[]> => {
if (!accessToken) return [];
try {
@ -22,6 +24,8 @@ export const fetchAllKeyAliases = async (accessToken: string | null): Promise<st
null, // organization_id
"", // team_id
null, // selectedKeyAlias
null, // user_id
null, // key_hash
currentPage,
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
* @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 [];
try {
@ -67,11 +74,11 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?:
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) {
@ -93,7 +100,9 @@ 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<Organization[]> => {
export const fetchAllOrganizations = async (
accessToken: string | null
): Promise<Organization[]> => {
if (!accessToken) return [];
try {
@ -102,12 +111,10 @@ export const fetchAllOrganizations = async (accessToken: string | null): Promise
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) {

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 { 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<React.SetStateAction<Organization | null>>;
setSelectedKeyAlias: Setter<string | null>
}) {
const [filters, setFilters] = useState<FilterState>({
const defaultFilters: FilterState = {
'Team ID': '',
'Organization ID': '',
'Key Alias': ''
});
'Key Alias': '',
'User ID': ''
}
const [filters, setFilters] = useState<FilterState>(defaultFilters);
const [allTeams, setAllTeams] = useState<Team[]>(teams || []);
const [allOrganizations, setAllOrganizations] = useState<Organization[]>(organizations || []);
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
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);
}
// Fetch keys based on new filters
const updatedFilters = {
...filters,
...newFilters
}
// 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)
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 {

View file

@ -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);

View file

@ -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());
}

View file

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