mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-06 14:24:50 +00:00
Allow team/org filters to be searchable on the Create Key Page (#8881)
* fix(filtercomponent): always show apply filters button fix hiding behavior * style(create_key_button.tsx): style improvements on create key modal remove the numbering * feat(filter.tsx): allow searching team by team alias * style(filter.tsx): style improvements to ensure dropdown + custom value works as expected * style(filter.tsx): add explicit button allowing reset filters * fix(filter.tsx): fix linting error * feat(all_keys_table.tsx): show team alias on keys table * style(all_keys_table.tsx): enforce length constraints on table make it easier to see all columns
This commit is contained in:
@@ -1,15 +1,22 @@
|
||||
model_list:
|
||||
- model_name: gpt-4o-mini
|
||||
- model_name: claude-3.7
|
||||
litellm_params:
|
||||
custom_llm_provider: azure
|
||||
model: gpt-4o-mini
|
||||
api_version: "2024-10-21"
|
||||
api_base: os.environ/AZURE_API_BASE
|
||||
api_key: os.environ/AZURE_API_KEY
|
||||
model: openai/gpt-3.5-turbo
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
api_base: http://0.0.0.0:8090
|
||||
- model_name: deepseek-r1
|
||||
litellm_params:
|
||||
model: bedrock/deepseek_r1/arn:aws:bedrock:us-west-2:888602223428:imported-model/bnnr6463ejgf
|
||||
- model_name: deepseek-r1-api
|
||||
litellm_params:
|
||||
model: deepseek/deepseek-reasoner
|
||||
- model_name: cohere.embed-english-v3
|
||||
litellm_params:
|
||||
model: bedrock/cohere.embed-english-v3
|
||||
api_key: os.environ/COHERE_API_KEY
|
||||
- model_name: bedrock-claude-3-7
|
||||
litellm_params:
|
||||
model: bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["prometheus"]
|
||||
|
||||
general_settings:
|
||||
disable_spend_logs: true
|
||||
|
||||
callbacks: ["langfuse"]
|
||||
@@ -8,7 +8,10 @@ import KeyInfoView from "./key_info_view";
|
||||
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 } from "./networking";
|
||||
import { createTeamSearchFunction } from "./key_team_helpers/team_search_fn";
|
||||
import { createOrgSearchFunction } from "./key_team_helpers/organization_search_fn";
|
||||
interface AllKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
isLoading?: boolean;
|
||||
@@ -171,21 +174,43 @@ export function AllKeysTable({
|
||||
accessorKey: "key_name",
|
||||
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
header: "Team Alias",
|
||||
accessorKey: "team_id", // Change to access the team_id
|
||||
cell: ({ row, getValue }) => {
|
||||
const teamId = getValue() as string;
|
||||
const team = teams?.find(t => t.team_id === teamId);
|
||||
return team?.team_alias || "Unknown";
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Team ID",
|
||||
accessorKey: "team_id",
|
||||
cell: (info) => info.getValue() ? info.renderValue() : "Not Set",
|
||||
cell: (info) => <Tooltip title={info.getValue() as string}>{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "Not Set"}</Tooltip>
|
||||
},
|
||||
|
||||
{
|
||||
header: "Key Alias",
|
||||
accessorKey: "key_alias",
|
||||
cell: (info) => info.getValue() ? info.renderValue() : "Not Set",
|
||||
cell: (info) => <Tooltip title={info.getValue() as string}>{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "Not Set"}</Tooltip>
|
||||
},
|
||||
{
|
||||
header: "Organization ID",
|
||||
accessorKey: "organization_id",
|
||||
cell: (info) => info.getValue() ? info.renderValue() : "Not Set",
|
||||
cell: (info) => <Tooltip title={info.getValue() as string}>{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "Not Set"}</Tooltip>
|
||||
},
|
||||
// {
|
||||
// header: "User Email",
|
||||
// accessorKey: "user_id",
|
||||
// cell: (info) => {
|
||||
// const userId = info.getValue() as string;
|
||||
// return userId ? (
|
||||
// <Tooltip title={userId}>
|
||||
// <span>{userId.slice(0, 5)}...</span>
|
||||
// </Tooltip>
|
||||
// ) : "Not Set";
|
||||
// },
|
||||
// },
|
||||
{
|
||||
header: "User ID",
|
||||
accessorKey: "user_id",
|
||||
@@ -272,9 +297,9 @@ export function AllKeysTable({
|
||||
},
|
||||
];
|
||||
|
||||
const filterOptions = [
|
||||
{ name: 'Team ID', label: 'Team ID' },
|
||||
{ name: 'Organization ID', label: 'Organization ID' }
|
||||
const filterOptions: FilterOption[] = [
|
||||
{ name: 'Team ID', label: 'Team ID', isSearchable: true, searchFn: createTeamSearchFunction(teams) },
|
||||
{ name: 'Organization ID', label: 'Organization ID', isSearchable: true, searchFn: createOrgSearchFunction(organizations) }
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button, Input, Dropdown, MenuProps } from 'antd';
|
||||
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
|
||||
ChevronUpIcon,
|
||||
SearchIcon
|
||||
} from '@heroicons/react/outline';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
interface FilterOption {
|
||||
export interface FilterOption {
|
||||
name: string;
|
||||
label?: string;
|
||||
isSearchable?: boolean;
|
||||
searchFn?: (searchText: string) => Promise<Array<{ label: string; value: string }>>;
|
||||
}
|
||||
|
||||
interface FilterValues {
|
||||
@@ -38,6 +42,9 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
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);
|
||||
|
||||
@@ -46,22 +53,40 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
const target = event.target as HTMLElement;
|
||||
if (filtersRef.current &&
|
||||
!filtersRef.current.contains(target) &&
|
||||
!target.closest('.ant-dropdown')) {
|
||||
!target.closest('.ant-dropdown') &&
|
||||
!target.closest('.ant-select-dropdown')) {
|
||||
setShowFilters(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async (value: string, option: FilterOption) => {
|
||||
if (!value || !option.isSearchable || !option.searchFn) return;
|
||||
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const results = await option.searchFn(value);
|
||||
setSearchOptions(results);
|
||||
} catch (error) {
|
||||
console.error('Error searching:', error);
|
||||
setSearchOptions([]);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setTempValues(prev => ({
|
||||
...prev,
|
||||
[selectedFilter]: value
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
const clearFilters = () => {
|
||||
const emptyValues: FilterValues = {};
|
||||
options.forEach(option => {
|
||||
@@ -69,13 +94,13 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
});
|
||||
setTempValues(emptyValues);
|
||||
};
|
||||
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilterValues(tempValues);
|
||||
onApplyFilters(tempValues);
|
||||
setShowFilters(false);
|
||||
};
|
||||
|
||||
|
||||
const dropdownItems: MenuProps['items'] = options.map(option => ({
|
||||
key: option.name,
|
||||
label: (
|
||||
@@ -88,6 +113,20 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
),
|
||||
}));
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={filtersRef}>
|
||||
<TremorButton
|
||||
@@ -99,7 +138,6 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
>
|
||||
{buttonLabel}
|
||||
</TremorButton>
|
||||
|
||||
{showFilters && (
|
||||
<Card className="absolute left-0 mt-2 w-96 z-50 border border-gray-200 shadow-lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -112,6 +150,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
onClick: ({ key }) => {
|
||||
setSelectedFilter(key);
|
||||
setDropdownOpen(false);
|
||||
setSearchOptions([]);
|
||||
}
|
||||
}}
|
||||
onOpenChange={setDropdownOpen}
|
||||
@@ -119,7 +158,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button className="min-w-32 text-left flex justify-between items-center">
|
||||
{selectedFilter}
|
||||
{currentOption?.label || selectedFilter}
|
||||
{dropdownOpen ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
@@ -128,40 +167,84 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<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
|
||||
{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"
|
||||
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 “{searchInputValue}” 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 justify-end gap-2">
|
||||
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
const emptyValues: FilterValues = {};
|
||||
options.forEach(option => {
|
||||
emptyValues[option.name] = '';
|
||||
});
|
||||
onResetFilters();
|
||||
setShowFilters(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleApplyFilters}>
|
||||
<Button onClick={handleApplyFilters}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +339,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
|
||||
+ Create New Key
|
||||
</Button>
|
||||
<Modal
|
||||
title="Create Key"
|
||||
// title="Create Key"
|
||||
visible={isModalVisible}
|
||||
width={1000}
|
||||
footer={null}
|
||||
@@ -355,7 +355,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
|
||||
>
|
||||
{/* Section 1: Key Ownership */}
|
||||
<div className="mb-8">
|
||||
<Title className="mb-4">1. Key Ownership</Title>
|
||||
<Title className="mb-4">Key Ownership</Title>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
@@ -444,7 +444,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
|
||||
|
||||
{/* Section 2: Key Details */}
|
||||
<div className="mb-8">
|
||||
<Title className="mb-4">2. Key Details</Title>
|
||||
<Title className="mb-4">Key Details</Title>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
@@ -503,7 +503,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({
|
||||
<div className="mb-8">
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<Title className="m-0">3. Optional Settings</Title>
|
||||
<Title className="m-0">Optional Settings</Title>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Organization } from "../networking";
|
||||
|
||||
export const createOrgSearchFunction = (organizations: Organization[] | null) => {
|
||||
return async (searchText: string): Promise<Array<{ label: string; value: string }>> => {
|
||||
if (!organizations || !searchText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find organizations that match the search text by alias
|
||||
const matchingOrgs: Array<{ label: string; value: string }> = [];
|
||||
|
||||
organizations.forEach(org => {
|
||||
if (
|
||||
org.organization_alias &&
|
||||
org.organization_alias.toLowerCase().includes(searchText.toLowerCase())
|
||||
) {
|
||||
matchingOrgs.push({
|
||||
label: `${org.organization_alias} (${org.organization_id})`,
|
||||
value: org.organization_id || ""
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return matchingOrgs;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Team } from "./key_list";
|
||||
|
||||
export const createTeamSearchFunction = (teams: Team[] | null) => {
|
||||
return async (searchText: string): Promise<Array<{ label: string; value: string }>> => {
|
||||
// Return empty array if teams is null or searchText is empty
|
||||
if (!teams || !searchText.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter teams where team_alias contains the search text (case insensitive)
|
||||
const filteredTeams = teams.filter(team =>
|
||||
team.team_alias.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// Map filtered teams to the required format
|
||||
return filteredTeams.map(team => ({
|
||||
label: `${team.team_alias} (${team.team_id.substring(0, 8)}...)`,
|
||||
value: team.team_id
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user