From 34d09a314ca5a60d7cd64a7f1e531213c01c5d90 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 14:47:07 -0800 Subject: [PATCH 01/11] virtual-keys-team-table --- .../src/components/team/TeamInfo.test.tsx | 59 +- .../src/components/team/TeamInfo.tsx | 12 + .../team/TeamVirtualKeysTable.test.tsx | 192 +++++ .../components/team/TeamVirtualKeysTable.tsx | 757 ++++++++++++++++++ .../team/tabVisibilityUtils.test.ts | 19 +- .../src/components/team/tabVisibilityUtils.ts | 6 +- 6 files changed, 1040 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index cc0483aafd6..75fe76902bd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -100,12 +100,28 @@ vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ }), })); +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: vi.fn().mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + }), +})); + +vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue([]), + fetchAllOrganizations: vi.fn().mockResolvedValue([]), +})); + import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; const mockUseAllProxyModels = vi.mocked(useAllProxyModels); +const mockUseKeys = vi.mocked(useKeys); const mockUseTeam = vi.mocked(useTeam); const mockUseOrganization = vi.mocked(useOrganization); const mockUseCurrentUser = vi.mocked(useCurrentUser); @@ -180,6 +196,12 @@ describe("TeamInfoView", () => { data: { models: [] }, isLoading: false, } as any); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] }); vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] }); @@ -558,7 +580,42 @@ describe("TeamInfoView", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should show Virtual Keys tab when user cannot edit team", async () => { + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Virtual Keys" })).toBeInTheDocument(); + }); + }); + + it("should display X Members in Virtual Keys tab when navigated to", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 5, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByText("5 Members")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index ea7a9a1c460..62208b4186d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -48,6 +48,7 @@ import { TEAM_INFO_TAB_LABELS, } from "./tabVisibilityUtils"; import TeamMembersComponent from "./TeamMemberTab"; +import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; export interface TeamMembership { user_id: string; @@ -726,6 +727,17 @@ const TeamInfoView: React.FC = ({ ), }, + { + key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, + label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS], + children: ( + + ), + }, { key: TEAM_INFO_TAB_KEYS.MEMBERS, label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS], diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx new file mode 100644 index 00000000000..e4b7b00fb58 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -0,0 +1,192 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; +import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { KeyResponse } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; + +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ + useKeys: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(), +})); + +vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue([]), + fetchAllOrganizations: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ + getModelDisplayName: vi.fn((model: string) => model), +})); + +vi.mock("../templates/key_info_view", () => ({ + default: vi.fn(({ onClose }: { onClose: () => void }) => ( +
+ Key Info View + +
+ )), +})); + +const mockUseKeys = useKeys as MockedFunction; +const mockUseAuthorized = useAuthorized as MockedFunction; + +const createMockKey = (overrides: Partial = {}): KeyResponse => + ({ + token: "sk-test123", + token_id: "key-1", + key_alias: "alice_key_team1", + key_name: "sk-...abc", + user_id: "user-1", + organization_id: null, + user: { user_id: "user-1", user_email: "alice@example.com" }, + created_at: "2024-01-01T00:00:00Z", + team_id: "team-1", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + ...overrides, + } as KeyResponse); + +const mockOrganization: Organization = { + organization_id: "org-123", + organization_alias: "Test Org", + budget_id: "budget-1", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + updated_by: "", + litellm_budget_table: {}, + teams: [], + users: [], + members: [], +}; + +describe("TeamVirtualKeysTable", () => { + const defaultProps = { + teamId: "team-1", + teamAlias: "team1", + organization: null as Organization | null, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" } as any); + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + }); + + it("should render successfully", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + }); + + it("should display X Members instead of Showing X of Y results", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey(), createMockKey({ token: "sk-2", token_id: "key-2" })], + total_count: 2, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("2 Members")).toBeInTheDocument(); + }); + expect(screen.queryByText(/Showing.*results/)).not.toBeInTheDocument(); + }); + + it("should display 1 Member when singular", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey()], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + }); + + it("should call useKeys with expand user to fetch user email", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenCalledWith( + 1, + 50, + expect.objectContaining({ + teamID: "team-1", + expand: "user", + }) + ); + }); + }); + + it("should enrich keys with organization_id when organization is provided", async () => { + const keyWithoutOrg = createMockKey({ organization_id: null }); + mockUseKeys.mockReturnValue({ + data: { + keys: [keyWithoutOrg], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders( + + ); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + // Key with org_id should display in table - org-123 from organization + await waitFor(() => { + expect(screen.getByText("org-123")).toBeInTheDocument(); + }); + }); + + it("should show table with Key ID column header", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("Key ID")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx new file mode 100644 index 00000000000..ce998d18677 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -0,0 +1,757 @@ +// TO-DO: Standardize tables eventually + +"use client"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + Badge, + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Text, +} from "@tremor/react"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Popover, Skeleton, Tooltip } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; +import { KeyResponse, Team } from "../key_team_helpers/key_list"; +import FilterComponent, { FilterOption } from "../molecules/filter"; +import { Organization } from "../networking"; +import KeyInfoView from "../templates/key_info_view"; +import { useQuery } from "@tanstack/react-query"; +import { fetchAllKeyAliases, fetchAllOrganizations } from "../key_team_helpers/filter_helpers"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +interface TeamVirtualKeysTableProps { + teamId: string; + teamAlias?: string; + organization: Organization | null; +} + +/** + * TeamVirtualKeysTable – variant of VirtualKeysTable scoped to a single team. + * Displays all virtual keys belonging to the team with same format and styling. + */ +export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) { + const { accessToken } = useAuthorized(); + const [selectedKey, setSelectedKey] = useState(null); + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true }, + ]); + const [tablePagination, setTablePagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + const [filters, setFilters] = useState>({ + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }); + + const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; + const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc"; + + const { + data: keys, + isPending: isLoading, + isFetching, + refetch, + } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { + teamID: teamId, + sortBy: sortBy || undefined, + sortOrder: sortOrder || undefined, + organizationID: filters["Organization ID"] || undefined, + selectedKeyAlias: filters["Key Alias"] || undefined, + userID: filters["User ID"] || undefined, + expand: "user", + }); + + const totalCount = keys?.total_count || 0; + const displayKeys = useMemo(() => { + const kList = keys?.keys || []; + const orgId = organization?.organization_id; + if (!orgId) return kList; + return kList.map((k: KeyResponse) => ({ + ...k, + organization_id: k.organization_id || orgId, + })); + }, [keys?.keys, organization?.organization_id]); + const [expandedAccordions, setExpandedAccordions] = useState>({}); + + const currentTeam: Team = useMemo( + () => ({ + team_id: teamId, + team_alias: teamAlias || teamId, + models: [], + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + organization_id: organization?.organization_id || "", + created_at: "", + keys: [], + members_with_roles: [], + spend: 0, + }), + [teamId, teamAlias, organization], + ); + + const allKeyAliasesQuery = useQuery({ + queryKey: ["allKeyAliases"], + queryFn: async () => fetchAllKeyAliases(accessToken), + enabled: !!accessToken, + }); + const allKeyAliases = allKeyAliasesQuery.data || []; + + const allOrganizationsQuery = useQuery({ + queryKey: ["allOrganizations"], + queryFn: async () => fetchAllOrganizations(accessToken), + enabled: !!accessToken, + }); + const allOrganizations = allOrganizationsQuery.data || []; + + useEffect(() => { + if (refetch) { + const handleStorageChange = () => refetch(); + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + } + }, [refetch]); + + const handleFilterChange = (newFilters: Record, skipDebounce = false) => { + setFilters((prev) => ({ + ...prev, + "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], + "Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"], + "User ID": newFilters["User ID"] ?? prev["User ID"], + "Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at", + "Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc", + })); + if (!skipDebounce) { + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + } + }; + + const handleFilterReset = () => { + setFilters({ + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }; + + const filterOptions: FilterOption[] = useMemo( + () => [ + { + name: "Organization ID", + label: "Organization ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allOrganizations.length) return []; + const filtered = allOrganizations.filter( + (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, + ); + return filtered + .filter((org) => org.organization_id != null) + .map((org) => ({ + label: `${org.organization_id || "Unknown"} (${org.organization_id})`, + value: org.organization_id as string, + })); + }, + }, + { + name: "Key Alias", + label: "Key Alias", + isSearchable: true, + searchFn: async (searchText: string) => { + const filtered = allKeyAliases.filter((alias) => + alias.toLowerCase().includes(searchText.toLowerCase()), + ); + return filtered.map((alias) => ({ label: alias, value: alias })); + }, + }, + { name: "User ID", label: "User ID", isSearchable: false }, + ], + [allOrganizations, allKeyAliases], + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "token", + accessorKey: "token", + header: "Key ID", + size: 100, + enableSorting: true, + cell: (info) => { + const value = info.getValue() as string; + const width = info.cell.column.getSize(); + return ( + + + + ); + }, + }, + { + id: "key_alias", + accessorKey: "key_alias", + header: "Key Alias", + size: 150, + enableSorting: true, + cell: (info) => { + const value = info.getValue() as string; + const width = info.cell.column.getSize(); + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "key_name", + accessorKey: "key_name", + header: "Secret Key", + size: 120, + enableSorting: false, + cell: (info) => {info.getValue() as string}, + }, + { + id: "organization_id", + accessorKey: "organization_id", + header: "Organization ID", + size: 140, + enableSorting: false, + cell: (info) => (info.getValue() ? info.renderValue() : "-"), + }, + { + id: "user_email", + accessorKey: "user", + header: "User Email", + size: 160, + enableSorting: false, + cell: (info) => { + const user = info.getValue() as { user_email?: string } | undefined; + const value = user?.user_email; + const width = info.cell.column.getSize(); + return ( + + + {value ?? "-"} + + + ); + }, + }, + { + id: "user_id", + accessorKey: "user_id", + header: "User ID", + size: 70, + enableSorting: false, + cell: (info) => { + const userId = info.getValue() as string | null; + const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId; + const width = info.cell.column.getSize(); + return ( + + + {displayValue ?? "-"} + + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created At", + size: 120, + enableSorting: true, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "-"; + }, + }, + { + id: "created_by", + accessorKey: "created_by", + header: "Created By", + size: 70, + enableSorting: false, + cell: (info) => { + const value = info.getValue() as string | null; + const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value; + const width = info.cell.column.getSize(); + return ( + + + {displayValue ?? "-"} + + + ); + }, + }, + { + id: "updated_at", + accessorKey: "updated_at", + header: "Updated At", + size: 120, + enableSorting: true, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "last_active", + accessorKey: "last_active", + header: () => ( + + Last Active + + + + + ), + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + if (!value) return "Unknown"; + const date = new Date(value as string); + return ( + + {date.toLocaleDateString()} + + ); + }, + }, + { + id: "expires", + accessorKey: "expires", + header: "Expires", + size: 120, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + size: 100, + enableSorting: true, + cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + size: 110, + enableSorting: true, + cell: (info) => { + const maxBudget = info.getValue() as number | null; + if (maxBudget === null) return "Unlimited"; + return `$${formatNumberWithCommas(maxBudget)}`; + }, + }, + { + id: "budget_reset_at", + accessorKey: "budget_reset_at", + header: "Budget Reset", + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleString() : "Never"; + }, + }, + { + id: "models", + accessorKey: "models", + header: "Models", + size: 200, + enableSorting: false, + cell: (info) => { + const models = info.getValue() as string[]; + return ( +
+ {Array.isArray(models) ? ( +
+ {models.length === 0 ? ( + + All Proxy Models + + ) : ( + <> +
+ {models.length > 3 && ( +
+ + setExpandedAccordions((prev) => ({ + ...prev, + [info.row.id]: !prev[info.row.id], + })) + } + /> +
+ )} +
+ {models.slice(0, 3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {models.length > 3 && !expandedAccordions[info.row.id] && ( + + + +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordions[info.row.id] && ( +
+ {models.slice(3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} +
+ )} +
+
+ + )} +
+ ) : null} +
+ ); + }, + }, + { + id: "rate_limits", + header: "Rate Limits", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const key = row.original; + return ( +
+
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
+
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
+
+ ); + }, + }, + ], + [expandedAccordions], + ); + + const table = useReactTable({ + data: displayKeys, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { sorting, pagination: tablePagination }, + onSortingChange: (updaterOrValue) => { + const newSorting = + typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + setSorting(newSorting); + if (newSorting?.length > 0) { + const sortState = newSorting[0]; + handleFilterChange( + { + ...filters, + "Sort By": sortState.id, + "Sort Order": sortState.desc ? "desc" : "asc", + }, + true, + ); + } + }, + onPaginationChange: setTablePagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + enableSorting: true, + manualSorting: false, + manualPagination: true, + pageCount: Math.ceil(totalCount / tablePagination.pageSize), + }); + + const { pageIndex, pageSize } = table.getState().pagination; + return ( +
+ {selectedKey ? ( + setSelectedKey(null)} + keyData={selectedKey} + teams={[currentTeam]} + onDelete={refetch} + /> + ) : ( +
+
+ +
+ +
+ {isLoading || isFetching ? ( + + ) : ( + + {totalCount} Member{totalCount !== 1 ? "s" : ""} + + )} + +
+ {isLoading || isFetching ? ( + + ) : ( + + Page {pageIndex + 1} of {table.getPageCount()} + + )} + + {isLoading || isFetching ? ( + + ) : ( + + )} + + {isLoading || isFetching ? ( + + ) : ( + + )} +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + { + const resizer = document.querySelector( + `[data-header-id="${header.id}"] .resizer`, + ); + if (resizer) (resizer as HTMLElement).style.opacity = "0.5"; + }} + onMouseLeave={() => { + const resizer = document.querySelector( + `[data-header-id="${header.id}"] .resizer`, + ); + if (resizer && !header.column.getIsResizing()) + (resizer as HTMLElement).style.opacity = "0"; + }} + onClick={ + header.column.getCanSort() + ? header.column.getToggleSortingHandler() + : undefined + } + > +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {header.id !== "actions" && header.column.getCanSort() && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${table.options.columnResizeDirection} ${ + header.column.getIsResizing() ? "isResizing" : "" + }`} + style={{ + position: "absolute", + right: 0, + top: 0, + height: "100%", + width: "5px", + background: header.column.getIsResizing() ? "#3b82f6" : "transparent", + cursor: "col-resize", + userSelect: "none", + touchAction: "none", + opacity: header.column.getIsResizing() ? 1 : 0, + }} + /> +
+ + ))} + + ))} + + + {isLoading || isFetching ? ( + + +
+

Loading keys...

+
+
+
+ ) : displayKeys.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + 3 + ? "px-0" + : "" + }`} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No keys found

+
+
+
+ )} +
+
+
+
+
+
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts index 5a09b4fa36c..8b9a0402c9a 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.test.ts @@ -11,6 +11,7 @@ describe("team_info_tabs", () => { describe("TEAM_INFO_TAB_LABELS", () => { it("should have label for every tab key", () => { expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview"); + expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]).toBe("Virtual Keys"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions"); expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]).toBe("Settings"); @@ -18,15 +19,16 @@ describe("team_info_tabs", () => { }); describe("getTeamInfoVisibleTabs", () => { - it("returns only overview when user cannot edit team", () => { + it("returns overview and virtual keys when user cannot edit team", () => { const tabs = getTeamInfoVisibleTabs(false); - expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW]); + expect(tabs).toEqual([TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]); }); it("returns all tabs when user can edit team", () => { const tabs = getTeamInfoVisibleTabs(true); expect(tabs).toEqual([ TEAM_INFO_TAB_KEYS.OVERVIEW, + TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, TEAM_INFO_TAB_KEYS.MEMBERS, TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, TEAM_INFO_TAB_KEYS.SETTINGS, @@ -55,6 +57,19 @@ describe("team_info_tabs", () => { expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.OVERVIEW, true)).toBe(true); }); + it("always returns true for virtual keys tab regardless of edit permission", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, false)).toBe(true); + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS, true)).toBe(true); + }); + + it("returns false for member permissions tab when user cannot edit", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, false)).toBe(false); + }); + + it("returns true for member permissions tab when user can edit", () => { + expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS, true)).toBe(true); + }); + it("returns false for members tab when user cannot edit", () => { expect(isTeamInfoTabVisible(TEAM_INFO_TAB_KEYS.MEMBERS, false)).toBe(false); }); diff --git a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts index d77230ea09b..dd0e54baf36 100644 --- a/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts +++ b/ui/litellm-dashboard/src/components/team/tabVisibilityUtils.ts @@ -5,6 +5,7 @@ export const TEAM_INFO_TAB_KEYS = { OVERVIEW: "overview", + VIRTUAL_KEYS: "virtual-keys", MEMBERS: "members", MEMBER_PERMISSIONS: "member-permissions", SETTINGS: "settings", @@ -12,6 +13,7 @@ export const TEAM_INFO_TAB_KEYS = { export const TEAM_INFO_TAB_LABELS: Record = { [TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview", + [TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "Virtual Keys", [TEAM_INFO_TAB_KEYS.MEMBERS]: "Members", [TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions", [TEAM_INFO_TAB_KEYS.SETTINGS]: "Settings", @@ -19,11 +21,11 @@ export const TEAM_INFO_TAB_LABELS: Record = { /** * Returns the list of tab keys that should be visible based on permissions. - * - Overview: always visible + * - Overview, Virtual Keys: always visible * - Members, Member Permissions, Settings: only when canEditTeam is true */ export function getTeamInfoVisibleTabs(canEditTeam: boolean): readonly string[] { - const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW]; + const baseTabs = [TEAM_INFO_TAB_KEYS.OVERVIEW, TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]; if (canEditTeam) { return [ ...baseTabs, From 6d98622923f95c6e047c910637cdf92b3f055f77 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:21:49 -0800 Subject: [PATCH 02/11] filters working properly for team virtual keys --- .../key_team_helpers/filter_helpers.ts | 77 +++++++- .../src/components/team/TeamInfo.test.tsx | 71 +++++++- .../team/TeamVirtualKeysTable.test.tsx | 170 +++++++++++++++++- .../components/team/TeamVirtualKeysTable.tsx | 133 ++++++++------ 4 files changed, 390 insertions(+), 61 deletions(-) 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 fa12a75aacd..08eccc4ed2c 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,7 +1,82 @@ -import { teamListCall, organizationListCall, keyAliasesCall } from "../networking" +import { teamListCall, organizationListCall, keyAliasesCall, keyListCall } from "../networking"; import { Team } from "./key_list"; import { Organization } from "../networking"; +export interface TeamFilterOptions { + keyAliases: string[]; + organizationIds: string[]; + userIds: Array<{ id: string; email: string }>; +} + +/** + * Fetches filter options (key aliases, org IDs, user IDs) scoped to a team's keys. + * Used by TeamVirtualKeysTable to show only relevant filter options. + */ +export const fetchTeamFilterOptions = async ( + accessToken: string | null, + teamId: string, +): Promise => { + if (!accessToken || !teamId) { + return { keyAliases: [], organizationIds: [], userIds: [] }; + } + + try { + const keyAliases = new Set(); + const organizationIds = new Set(); + const userMap = new Map(); // user_id -> user_email + + let page = 1; + let totalPages = 1; + + do { + const response = await keyListCall( + accessToken, + null, + teamId, + null, + null, + null, + page, + 100, + null, + null, + "user", + null, + ); + + const keys = response?.keys || []; + totalPages = response?.total_pages ?? 1; + + for (const key of keys) { + const alias = key?.key_alias; + if (alias && typeof alias === "string") { + keyAliases.add(alias.trim()); + } + const orgId = key?.organization_id; + if (orgId && typeof orgId === "string") { + organizationIds.add(orgId.trim()); + } + const userId = key?.user_id; + if (userId && typeof userId === "string") { + const email = key?.user?.user_email || userId; + userMap.set(userId, email); + } + } + + page++; + } while (page <= totalPages); + + return { + keyAliases: Array.from(keyAliases).sort(), + organizationIds: Array.from(organizationIds).sort(), + userIds: Array.from(userMap.entries()).map(([id, email]) => ({ id, email })), + }; + } catch (error) { + console.error("Error fetching team filter options:", error); + return { keyAliases: [], organizationIds: [], userIds: [] }; + } +}; + /** * Fetches all key aliases via the dedicated /key/aliases endpoint * @param accessToken The access token for API authentication diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 75fe76902bd..317db7b37fd 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -110,6 +110,11 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ })); vi.mock("../key_team_helpers/filter_helpers", () => ({ + fetchTeamFilterOptions: vi.fn().mockResolvedValue({ + keyAliases: [], + organizationIds: [], + userIds: [], + }), fetchAllKeyAliases: vi.fn().mockResolvedValue([]), fetchAllOrganizations: vi.fn().mockResolvedValue([]), })); @@ -597,8 +602,22 @@ describe("TeamInfoView", () => { it("should display X Members in Virtual Keys tab when navigated to", async () => { const user = userEvent.setup(); vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + const fiveKeys = Array.from({ length: 5 }, (_, i) => ({ + token: `sk-${i}`, + token_id: `key-${i}`, + key_alias: `key_${i}`, + key_name: `sk-...${i}`, + user_id: `user-${i}`, + organization_id: null, + user: { user_id: `user-${i}`, user_email: `user${i}@test.com` }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + })); mockUseKeys.mockReturnValue({ - data: { keys: [], total_count: 5, current_page: 1, total_pages: 1 }, + data: { keys: fiveKeys, total_count: 5, current_page: 1, total_pages: 1 }, isPending: false, isFetching: false, refetch: vi.fn(), @@ -619,6 +638,56 @@ describe("TeamInfoView", () => { }); }); + it("should show Filters and pagination controls in Virtual Keys tab", async () => { + const user = userEvent.setup(); + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData()); + mockUseKeys.mockReturnValue({ + data: { + keys: [ + { + token: "sk-1", + token_id: "key-1", + key_alias: "key1", + key_name: "sk-...1", + user_id: "user-1", + organization_id: null, + user: { user_id: "user-1", user_email: "user1@test.com" }, + created_at: "2024-01-01T00:00:00Z", + team_id: "123", + spend: 0, + max_budget: 100, + models: ["gpt-4"], + }, + ], + total_count: 1, + current_page: 1, + total_pages: 1, + }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + const teamNameElements = screen.queryAllByText("Test Team"); + expect(teamNameElements.length).toBeGreaterThan(0); + }); + + const virtualKeysTab = screen.getByRole("tab", { name: "Virtual Keys" }); + await user.click(virtualKeysTab); + + await waitFor(() => { + expect(screen.getByText("1 Member")).toBeInTheDocument(); + }); + expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + }); + it("should display object permissions when present", async () => { vi.mocked(networking.teamInfoCall).mockResolvedValue( createMockTeamData({ diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index e4b7b00fb58..41df5611e07 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; @@ -17,8 +18,11 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ })); vi.mock("../key_team_helpers/filter_helpers", () => ({ - fetchAllKeyAliases: vi.fn().mockResolvedValue([]), - fetchAllOrganizations: vi.fn().mockResolvedValue([]), + fetchTeamFilterOptions: vi.fn().mockResolvedValue({ + keyAliases: [], + organizationIds: [], + userIds: [], + }), })); vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ @@ -139,7 +143,7 @@ describe("TeamVirtualKeysTable", () => { }); }); - it("should call useKeys with expand user to fetch user email", async () => { + it("should call useKeys with page, pageSize, and expand user for server-side pagination", async () => { renderWithProviders(); await waitFor(() => { @@ -189,4 +193,164 @@ describe("TeamVirtualKeysTable", () => { }); expect(screen.getByText("Key ID")).toBeInTheDocument(); }); + + it("should display keys in table when data is loaded", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [ + createMockKey({ key_alias: "alice_key_team1" }), + createMockKey({ token: "sk-2", token_id: "key-2", key_alias: "bob_key_team1" }), + ], + total_count: 2, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("2 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("alice_key_team1")).toBeInTheDocument(); + expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); + }); + + it("should show Page X of Y when multiple pages exist", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey()], + total_count: 100, + current_page: 1, + total_pages: 3, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + expect(screen.getByText("100 Members")).toBeInTheDocument(); + }); + + it("should fetch page 2 when Next is clicked", async () => { + const user = userEvent.setup(); + mockUseKeys.mockImplementation((page: number) => ({ + data: { + keys: page === 1 ? [createMockKey()] : [createMockKey({ token: "sk-page2", key_alias: "page2_key" })], + total_count: 100, + current_page: page, + total_pages: 3, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any)); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + }); + + const nextButton = screen.getByRole("button", { name: "Next" }); + await user.click(nextButton); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 2, + 50, + expect.objectContaining({ teamID: "team-1" }) + ); + }); + }); + + it("should show Loading keys when isPending", async () => { + mockUseKeys.mockReturnValue({ + data: undefined, + isPending: true, + isFetching: true, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Loading keys...")).toBeInTheDocument(); + }); + }); + + it("should show No keys found when keys array is empty", async () => { + mockUseKeys.mockReturnValue({ + data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("0 Members")).toBeInTheDocument(); + }); + expect(screen.getByText("No keys found")).toBeInTheDocument(); + }); + + it("should fetch team-scoped filter options for Key Alias, Organization ID, and User ID", async () => { + const mockFetchTeamFilterOptions = vi.mocked(fetchTeamFilterOptions); + mockFetchTeamFilterOptions.mockResolvedValue({ + keyAliases: ["alice_key_team1", "charlie_key_team1"], + organizationIds: ["org-123"], + userIds: [ + { id: "user-1", email: "alice@example.com" }, + { id: "user-2", email: "charlie@example.com" }, + ], + }); + + // Use unique teamId to avoid cache hit from previous tests (refetchOnMount: false) + renderWithProviders( + + ); + + await waitFor(() => { + expect(mockFetchTeamFilterOptions).toHaveBeenCalledWith( + "test-token", + "team-filter-options-test" + ); + }); + }); + + it("should open Key Info View when key is clicked", async () => { + mockUseKeys.mockReturnValue({ + data: { + keys: [createMockKey({ token: "sk-click-me", key_alias: "clickable_key" })], + total_count: 1, + current_page: 1, + total_pages: 1, + } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("clickable_key")).toBeInTheDocument(); + }); + + const keyButton = screen.getByRole("button", { name: /sk-click-me|clickable_key/ }); + await userEvent.click(keyButton); + + await waitFor(() => { + expect(screen.getByText("Key Info View")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index ce998d18677..ddbffa118b1 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -8,7 +8,6 @@ import { ColumnDef, flexRender, getCoreRowModel, - getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, @@ -28,14 +27,14 @@ import { } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Popover, Skeleton, Tooltip } from "antd"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import { useQuery } from "@tanstack/react-query"; -import { fetchAllKeyAliases, fetchAllOrganizations } from "../key_team_helpers/filter_helpers"; +import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface TeamVirtualKeysTableProps { @@ -69,22 +68,24 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc"; + const pageIndex = tablePagination.pageIndex; + const pageSize = tablePagination.pageSize; + const { data: keys, isPending: isLoading, isFetching, refetch, - } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { + } = useKeys(pageIndex + 1, pageSize, { teamID: teamId, + organizationID: filters["Organization ID"]?.trim() || undefined, + selectedKeyAlias: filters["Key Alias"]?.trim() || undefined, + userID: filters["User ID"]?.trim() || undefined, sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, - organizationID: filters["Organization ID"] || undefined, - selectedKeyAlias: filters["Key Alias"] || undefined, - userID: filters["User ID"] || undefined, expand: "user", }); - const totalCount = keys?.total_count || 0; const displayKeys = useMemo(() => { const kList = keys?.keys || []; const orgId = organization?.organization_id; @@ -94,6 +95,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi organization_id: k.organization_id || orgId, })); }, [keys?.keys, organization?.organization_id]); + + const totalCount = keys?.total_count ?? 0; + const pageCount = keys?.total_pages ?? 0; const [expandedAccordions, setExpandedAccordions] = useState>({}); const currentTeam: Team = useMemo( @@ -114,29 +118,27 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [teamId, teamAlias, organization], ); - const allKeyAliasesQuery = useQuery({ - queryKey: ["allKeyAliases"], - queryFn: async () => fetchAllKeyAliases(accessToken), - enabled: !!accessToken, + const teamFilterOptionsQuery = useQuery({ + queryKey: ["teamFilterOptions", teamId], + queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), + enabled: !!accessToken && !!teamId, }); - const allKeyAliases = allKeyAliasesQuery.data || []; + const teamFilterOptions = teamFilterOptionsQuery.data || { + keyAliases: [], + organizationIds: [], + userIds: [], + }; - const allOrganizationsQuery = useQuery({ - queryKey: ["allOrganizations"], - queryFn: async () => fetchAllOrganizations(accessToken), - enabled: !!accessToken, - }); - const allOrganizations = allOrganizationsQuery.data || []; - - useEffect(() => { - if (refetch) { - const handleStorageChange = () => refetch(); - window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); - } + const handleStorageChange = useCallback(() => { + refetch?.(); }, [refetch]); - const handleFilterChange = (newFilters: Record, skipDebounce = false) => { + useEffect(() => { + window.addEventListener("storage", handleStorageChange); + return () => window.removeEventListener("storage", handleStorageChange); + }, [handleStorageChange]); + + const handleFilterChange = useCallback((newFilters: Record, skipDebounce = false) => { setFilters((prev) => ({ ...prev, "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], @@ -148,9 +150,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi if (!skipDebounce) { setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); } - }; + }, []); - const handleFilterReset = () => { + const handleFilterReset = useCallback(() => { setFilters({ "Organization ID": "", "Key Alias": "", @@ -159,7 +161,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Sort Order": "desc", }); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }; + }, []); const filterOptions: FilterOption[] = useMemo( () => [ @@ -168,16 +170,13 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi label: "Organization ID", isSearchable: true, searchFn: async (searchText: string) => { - if (!allOrganizations.length) return []; - const filtered = allOrganizations.filter( - (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, - ); - return filtered - .filter((org) => org.organization_id != null) - .map((org) => ({ - label: `${org.organization_id || "Unknown"} (${org.organization_id})`, - value: org.organization_id as string, - })); + const { organizationIds } = teamFilterOptions; + if (!organizationIds.length) return []; + const lower = searchText.toLowerCase(); + const filtered = lower + ? organizationIds.filter((id) => id.toLowerCase().includes(lower)) + : organizationIds; + return filtered.map((id) => ({ label: id, value: id })); }, }, { @@ -185,15 +184,35 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi label: "Key Alias", isSearchable: true, searchFn: async (searchText: string) => { - const filtered = allKeyAliases.filter((alias) => - alias.toLowerCase().includes(searchText.toLowerCase()), - ); + const { keyAliases } = teamFilterOptions; + const lower = searchText.toLowerCase(); + const filtered = lower + ? keyAliases.filter((alias) => alias.toLowerCase().includes(lower)) + : keyAliases; return filtered.map((alias) => ({ label: alias, value: alias })); }, }, - { name: "User ID", label: "User ID", isSearchable: false }, + { + name: "User ID", + label: "User ID", + isSearchable: true, + searchFn: async (searchText: string) => { + const { userIds } = teamFilterOptions; + const lower = searchText.toLowerCase(); + const filtered = lower + ? userIds.filter( + (u) => + u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower), + ) + : userIds; + return filtered.map((u) => ({ + label: u.email ? `${u.id} (${u.email})` : u.id, + value: u.id, + })); + }, + }, ], - [allOrganizations, allKeyAliases], + [teamFilterOptions], ); const columns: ColumnDef[] = useMemo( @@ -521,13 +540,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [expandedAccordions], ); - const table = useReactTable({ - data: displayKeys, - columns, - columnResizeMode: "onChange", - columnResizeDirection: "ltr", - state: { sorting, pagination: tablePagination }, - onSortingChange: (updaterOrValue) => { + const handleSortingChange = useCallback( + (updaterOrValue: React.SetStateAction) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; setSorting(newSorting); @@ -535,7 +549,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const sortState = newSorting[0]; handleFilterChange( { - ...filters, "Sort By": sortState.id, "Sort Order": sortState.desc ? "desc" : "asc", }, @@ -543,17 +556,25 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ); } }, + [sorting, handleFilterChange], + ); + + const table = useReactTable({ + data: displayKeys, + columns, + columnResizeMode: "onChange", + columnResizeDirection: "ltr", + state: { sorting, pagination: tablePagination }, + onSortingChange: handleSortingChange, onPaginationChange: setTablePagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), enableSorting: true, manualSorting: false, manualPagination: true, - pageCount: Math.ceil(totalCount / tablePagination.pageSize), + pageCount: pageCount, }); - const { pageIndex, pageSize } = table.getState().pagination; return (
{selectedKey ? ( From f44c36f9802bc46bbd4b646924aed8ddeea82f2a Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:43:04 -0800 Subject: [PATCH 03/11] Fix Unbounded pagination --- .../src/components/key_team_helpers/filter_helpers.ts | 6 ++++-- .../src/components/team/TeamVirtualKeysTable.tsx | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) 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 08eccc4ed2c..dd3766592bf 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 @@ -25,6 +25,8 @@ export const fetchTeamFilterOptions = async ( const organizationIds = new Set(); const userMap = new Map(); // user_id -> user_email + const MAX_PAGES = 1; // Cap at 20 keys on load to avoid heavy fetches + const PAGE_SIZE = 20; let page = 1; let totalPages = 1; @@ -37,7 +39,7 @@ export const fetchTeamFilterOptions = async ( null, null, page, - 100, + PAGE_SIZE, null, null, "user", @@ -64,7 +66,7 @@ export const fetchTeamFilterOptions = async ( } page++; - } while (page <= totalPages); + } while (page <= totalPages && page <= MAX_PAGES); return { keyAliases: Array.from(keyAliases).sort(), diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index ddbffa118b1..13d7885d672 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -122,6 +122,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi queryKey: ["teamFilterOptions", teamId], queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), enabled: !!accessToken && !!teamId, + staleTime: 30000, // 30 seconds - align with useKeys }); const teamFilterOptions = teamFilterOptionsQuery.data || { keyAliases: [], From bc99e38e55348aaab5db5fcef3adb1f9ba44faa3 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 15:44:51 -0800 Subject: [PATCH 04/11] Fix: accessToken not included in queryKey --- .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 13d7885d672..74100069779 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -119,7 +119,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ); const teamFilterOptionsQuery = useQuery({ - queryKey: ["teamFilterOptions", teamId], + queryKey: ["teamFilterOptions", teamId, accessToken], queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), enabled: !!accessToken && !!teamId, staleTime: 30000, // 30 seconds - align with useKeys From a1f5450b4001555b04ce51044004580ced8e3cb1 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Mon, 23 Feb 2026 16:01:04 -0800 Subject: [PATCH 05/11] manualSorting fix. Filtering fetching fix --- .../components/key_team_helpers/filter_helpers.ts | 14 ++++++++------ .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) 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 dd3766592bf..0a5590d78ce 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 @@ -8,9 +8,13 @@ export interface TeamFilterOptions { userIds: Array<{ id: string; email: string }>; } +const FILTER_OPTIONS_PAGE_SIZE = 100; // API max per page +const MAX_PAGES = 50; // Cap at 5000 keys to avoid unbounded fetches + /** - * Fetches filter options (key aliases, org IDs, user IDs) scoped to a team's keys. - * Used by TeamVirtualKeysTable to show only relevant filter options. + * Fetches filter options (key aliases, org IDs, user IDs) from all team keys. + * Paginates through pages to build complete dropdowns. Capped at 50 pages + * (5000 keys) to limit load for very large teams. */ export const fetchTeamFilterOptions = async ( accessToken: string | null, @@ -23,10 +27,8 @@ export const fetchTeamFilterOptions = async ( try { const keyAliases = new Set(); const organizationIds = new Set(); - const userMap = new Map(); // user_id -> user_email + const userMap = new Map(); - const MAX_PAGES = 1; // Cap at 20 keys on load to avoid heavy fetches - const PAGE_SIZE = 20; let page = 1; let totalPages = 1; @@ -39,7 +41,7 @@ export const fetchTeamFilterOptions = async ( null, null, page, - PAGE_SIZE, + FILTER_OPTIONS_PAGE_SIZE, null, null, "user", diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 74100069779..de3061a003d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -571,7 +571,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), enableSorting: true, - manualSorting: false, + manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort manualPagination: true, pageCount: pageCount, }); From 51f18b05a97f9352ef6fa20a064fb253c03565f1 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:00:16 -0800 Subject: [PATCH 06/11] Update ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index de3061a003d..c3cdd7fba5b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -569,7 +569,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi onSortingChange: handleSortingChange, onPaginationChange: setTablePagination, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), + // getSortedRowModel not needed — manualSorting: true delegates sorting to the server enableSorting: true, manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort manualPagination: true, From 383f79f36f4a9a6cfe38bfe786a5a940f528325d Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:00:57 -0800 Subject: [PATCH 07/11] Update ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/components/key_team_helpers/filter_helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 97166f92735..a1602fc3aea 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,4 +1,4 @@ -import { teamListCall, organizationListCall, keyAliasesCall, keyListCall } from "../networking"; +import { teamListCall, organizationListCall, keyListCall } from "../networking"; import { Team } from "./key_list"; import { Organization } from "../networking"; From 47ff0d1d863426a3390dfb30bd871b98bf28b9a1 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Tue, 3 Mar 2026 16:03:25 -0800 Subject: [PATCH 08/11] Fixed: 50 sequential API calls on mount is excessive --- .../key_team_helpers/filter_helpers.ts | 109 +++++++++++------- 1 file changed, 68 insertions(+), 41 deletions(-) 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 a1602fc3aea..539ac172569 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 @@ -9,12 +9,35 @@ export interface TeamFilterOptions { } const FILTER_OPTIONS_PAGE_SIZE = 100; // API max per page -const MAX_PAGES = 50; // Cap at 5000 keys to avoid unbounded fetches +const MAX_PAGES = 10; // Cap at 1000 keys; filter completeness beyond ~500 has diminishing returns + +const processKeysIntoOptions = ( + keys: Array>, + keyAliases: Set, + organizationIds: Set, + userMap: Map, +) => { + for (const key of keys) { + const alias = key?.key_alias; + if (alias && typeof alias === "string") { + keyAliases.add(alias.trim()); + } + const orgId = key?.organization_id; + if (orgId && typeof orgId === "string") { + organizationIds.add(orgId.trim()); + } + const userId = key?.user_id; + if (userId && typeof userId === "string") { + const email = (key?.user as { user_email?: string })?.user_email || userId; + userMap.set(userId, email); + } + } +}; /** - * Fetches filter options (key aliases, org IDs, user IDs) from all team keys. - * Paginates through pages to build complete dropdowns. Capped at 50 pages - * (5000 keys) to limit load for very large teams. + * Fetches filter options (key aliases, org IDs, user IDs) from team keys. + * Fetches page 1 first to get totalPages, then batches remaining pages with + * Promise.all. Capped at 10 pages (1000 keys) */ export const fetchTeamFilterOptions = async ( accessToken: string | null, @@ -29,46 +52,50 @@ export const fetchTeamFilterOptions = async ( const organizationIds = new Set(); const userMap = new Map(); - let page = 1; - let totalPages = 1; + // First request: get page 1 and totalPages + const firstResponse = await keyListCall( + accessToken, + null, + teamId, + null, + null, + null, + 1, + FILTER_OPTIONS_PAGE_SIZE, + null, + null, + "user", + null, + ); - do { - const response = await keyListCall( - accessToken, - null, - teamId, - null, - null, - null, - page, - FILTER_OPTIONS_PAGE_SIZE, - null, - null, - "user", - null, + const firstKeys = firstResponse?.keys || []; + const totalPages = firstResponse?.total_pages ?? 1; + processKeysIntoOptions(firstKeys, keyAliases, organizationIds, userMap); + + // Batch fetch remaining pages (2 through min(totalPages, MAX_PAGES)) in parallel + const pagesToFetch = Math.min(totalPages, MAX_PAGES) - 1; + if (pagesToFetch > 0) { + const pagePromises = Array.from({ length: pagesToFetch }, (_, i) => + keyListCall( + accessToken, + null, + teamId, + null, + null, + null, + i + 2, + FILTER_OPTIONS_PAGE_SIZE, + null, + null, + "user", + null, + ), ); - - const keys = response?.keys || []; - totalPages = response?.total_pages ?? 1; - - for (const key of keys) { - const alias = key?.key_alias; - if (alias && typeof alias === "string") { - keyAliases.add(alias.trim()); - } - const orgId = key?.organization_id; - if (orgId && typeof orgId === "string") { - organizationIds.add(orgId.trim()); - } - const userId = key?.user_id; - if (userId && typeof userId === "string") { - const email = key?.user?.user_email || userId; - userMap.set(userId, email); - } + const responses = await Promise.all(pagePromises); + for (const response of responses) { + processKeysIntoOptions(response?.keys || [], keyAliases, organizationIds, userMap); } - - page++; - } while (page <= totalPages && page <= MAX_PAGES); + } return { keyAliases: Array.from(keyAliases).sort(), From 34acbb39da74f890f22f014d3dcac431705efa45 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:19:32 -0800 Subject: [PATCH 09/11] Update ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/components/team/TeamVirtualKeysTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index c3cdd7fba5b..bb4ab2b974d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -8,7 +8,7 @@ import { ColumnDef, flexRender, getCoreRowModel, - getSortedRowModel, + getCoreRowModel, PaginationState, SortingState, useReactTable, From 18a2213cdb375d31f4cb255533c156d2452b7900 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia Date: Tue, 3 Mar 2026 16:21:02 -0800 Subject: [PATCH 10/11] Fix: Promise.all loses all results on single page failure --- .../src/components/key_team_helpers/filter_helpers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 539ac172569..b587e090d33 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 @@ -37,7 +37,7 @@ const processKeysIntoOptions = ( /** * Fetches filter options (key aliases, org IDs, user IDs) from team keys. * Fetches page 1 first to get totalPages, then batches remaining pages with - * Promise.all. Capped at 10 pages (1000 keys) + * Promise.allSettled (preserves successful pages if some fail). Capped at 10 pages (1000 keys) */ export const fetchTeamFilterOptions = async ( accessToken: string | null, @@ -91,9 +91,11 @@ export const fetchTeamFilterOptions = async ( null, ), ); - const responses = await Promise.all(pagePromises); - for (const response of responses) { - processKeysIntoOptions(response?.keys || [], keyAliases, organizationIds, userMap); + const results = await Promise.allSettled(pagePromises); + for (const result of results) { + if (result.status === "fulfilled") { + processKeysIntoOptions(result.value?.keys || [], keyAliases, organizationIds, userMap); + } } } From 5f38c3bfecd403bd126e6999d1729471a2f72701 Mon Sep 17 00:00:00 2001 From: Alejandro Tapia <67175024+atapia27@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:30:10 -0800 Subject: [PATCH 11/11] Update ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../src/components/team/TeamVirtualKeysTable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index bb4ab2b974d..5d76b99ef91 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -8,7 +8,6 @@ import { ColumnDef, flexRender, getCoreRowModel, - getCoreRowModel, PaginationState, SortingState, useReactTable,