From b8d48feccac1e45ee8f1f69830dbc8e3d754a3d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 07:04:04 +0000 Subject: [PATCH] feat(ui): migrate virtual-keys (VirtualKeysTable + BudgetWindowsEditor) to shadcn Scope: section-2 stress-test of the locked phase-1 blueprint. - VirtualKeysTable.tsx (700+ lines): - @tremor/react Table + TableHead/Body/Row/Cell \u2192 shadcn Table equivalents - @tremor/react Badge / Icon / Text / Button \u2192 shadcn Badge + lucide icons + Tailwind semantic tokens - @heroicons/react ChevronDown/ChevronRight/ChevronUp/SwitchVertical \u2192 lucide Chevron* + ChevronsUpDown - @ant-design/icons InfoCircleOutlined / SyncOutlined \u2192 lucide Info / RefreshCw - antd Popover / Tooltip / Button / Skeleton / Typography \u2192 shadcn equivalents from @/components/ui/*. CopyableText helper replaces antd Typography.Text copyable semantics (clipboard + sonner toast). - Raw color classes (text-blue-500, bg-blue-50, text-gray-700, hover:bg-gray-50, bg-white) replaced with semantic tokens (text-primary, bg-primary/10, text-muted-foreground, hover:bg-muted, bg-background). - BudgetWindowsEditor.tsx: antd Button/InputNumber/Select \u2192 shadcn Button/Input/Select. Antd's InputNumber `prefix="$"` preserved as absolute- positioned span adjacent to the input (shadcn Input has no prefix prop). Blueprint stress-test result: NO new patterns required. Every antd / @tremor / @heroicons / @ant-design/icons import encountered maps cleanly to an entry already in BLUEPRINT.md. The blueprint is considered final and is now permanently locked for the rest of phase 1. Gates: TS \u2713 | Lint \u2713 | Vitest 44/44 \u2713 | Build \u2713. Playwright parity + snapshot gates deferred per the DEVIATIONS.md environmental-gate skip. Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/docs/CYCLES.md | 13 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 1199 +++++++++-------- .../key_team_helpers/BudgetWindowsEditor.tsx | 103 +- 3 files changed, 732 insertions(+), 583 deletions(-) diff --git a/ui/litellm-dashboard/docs/CYCLES.md b/ui/litellm-dashboard/docs/CYCLES.md index 700edbdf3be..4e4f3c71a28 100644 --- a/ui/litellm-dashboard/docs/CYCLES.md +++ b/ui/litellm-dashboard/docs/CYCLES.md @@ -23,5 +23,16 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest** ## 2. Virtual Keys (api-keys) -_(pending)_ +- Cycles used: 1 / 7 +- Layer outcomes per cycle: + - cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (44/44) | Parity ⏭ | Snap ⏭ +- Final status: **done (scoped to VirtualKeysTable + BudgetWindowsEditor)** +- Section scope: only the files directly under `src/components/VirtualKeysPage/` and + `src/components/key_team_helpers/` that imported banned libs. Heavier key-related + sub-components (e.g. `templates/key_info_view`, `organisms/create_key_button`, + `KeyAliasSelect/*`, `DeletedKeysPage/*`) are outside this section's file set and + will be picked up by either their own future sections or the final chrome sweep. +- Blueprint stress-test outcome: no new patterns required. The existing blueprint + (Table + @tanstack/react-table, Popover, Tooltip, Badge variants, Skeleton) covered + every antd / @tremor / @heroicons import encountered. diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index b30d4b6ce5b..948531a767c 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -2,7 +2,38 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { toast } from "sonner"; +import { + ChevronDown, + ChevronRight, + ChevronUp, + ChevronsUpDown, + CopyIcon, + Info, + RefreshCw, +} from "lucide-react"; import { ColumnDef, flexRender, @@ -13,20 +44,6 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { - Badge, - Button, - Icon, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, -} from "@tremor/react"; -import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { Button as AntButton, Popover, Skeleton, Tooltip, Typography } from "antd"; import React, { useEffect, useDeferredValue, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { useFilterLogic } from "../key_team_helpers/filter_logic"; @@ -48,11 +65,37 @@ interface VirtualKeysTableProps { } /** - * VirtualKeysTable – 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. + * Small copyable text block. Used inside popovers to surface User Alias / + * Email / ID (and Created-by equivalents) in a compact, accessible form. + * Clicking copies the value to the clipboard and shows a sonner toast. */ +function CopyableText({ value }: { value: string }) { + return ( + + ); +} -export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) { +/** + * VirtualKeysTable – shadcn-table list of virtual keys with filtering, + * sorting, and pagination. Post phase-1 migration; see BLUEPRINT.md. + */ +export function VirtualKeysTable({ + teams, + organizations, + onSortChange, + currentSort, +}: VirtualKeysTableProps) { const { data: fetchedOrganizations } = useOrganizations(); const resolvedOrganizations = fetchedOrganizations ?? organizations ?? []; const [selectedKey, setSelectedKey] = useState(null); @@ -65,19 +108,13 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, ]; } - return [ - { - id: "created_at", - desc: true, - }, - ]; + return [{ id: "created_at", desc: true }]; }); const [tablePagination, setTablePagination] = React.useState({ pageIndex: 0, pageSize: 50, }); - // Extract sort parameters from sorting state const sortBy = sorting.length > 0 ? sorting[0].id : null; const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : null; @@ -92,474 +129,499 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo sortOrder: sortOrder || undefined, expand: "user", }); - const [expandedAccordions, setExpandedAccordions] = useState>({}); + const [expandedAccordions, setExpandedAccordions] = useState< + Record + >({}); - // Use the filter logic hook + const { + filters, + filteredKeys, + filteredTotalCount, + allTeams, + allOrganizations, + handleFilterChange, + handleFilterReset, + } = useFilterLogic({ + keys: keys?.keys || [], + teams, + organizations, + }); - const { filters, filteredKeys, filteredTotalCount, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = - useFilterLogic({ - keys: keys?.keys || [], - teams, - organizations, - }); - - // Defer the transition so the button stays in loading state until the table - // has rendered with the new data (mirrors the spend-logs pattern) const isFetchingDeferred = useDeferredValue(isFetching); const isButtonLoading = (isFetching || isFetchingDeferred) && !isError; - - const handleRefresh = () => { - refetch(); - }; + const handleRefresh = () => refetch(); const totalCount = filteredTotalCount ?? keys?.total_count ?? 0; - // Add a useEffect to call refresh when a key is created useEffect(() => { if (refetch) { - const handleStorageChange = () => { - refetch(); - }; - - // Listen for storage events that might indicate a key was created + const handleStorageChange = () => refetch(); window.addEventListener("storage", handleStorageChange); - - return () => { - window.removeEventListener("storage", handleStorageChange); - }; + return () => window.removeEventListener("storage", handleStorageChange); } }, [refetch]); - const columns: ColumnDef[] = useMemo(() => [ - { - id: "expander", - header: () => null, - size: 40, - enableSorting: false, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, - }, - { - 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 ( - - + ) : null, + }, + { + 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 ( + + + + + + {value} + + + ); + }, + }, + { + 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 ( + setSelectedKey(info.row.original)} > {value ?? "-"} - - - ); - }, - }, - { - 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: "team_alias", - accessorKey: "team_id", - header: "Team", - size: 120, - enableSorting: false, - cell: (info) => { - const teamId = info.getValue() as string | null; - if (!teamId) return "-"; - const team = teams?.find((t) => t.team_id === teamId); - const displayValue = team?.team_alias || teamId; - const width = info.cell.column.getSize(); - return ( - - {displayValue} - - ); - }, - }, - { - id: "organization_alias", - accessorKey: "org_id", - header: "Organization", - size: 140, - enableSorting: false, - cell: (info) => { - const orgId = info.getValue() as string | null; - if (!orgId) return "-"; - const org = resolvedOrganizations.find((o) => o.organization_id === orgId); - const displayValue = org?.organization_alias || orgId; - const width = info.cell.column.getSize(); - return ( - - {displayValue} - - ); - }, - }, - { - id: "user", - accessorKey: "user", - header: () => ( - - User - - - - - ), - size: 160, - enableSorting: false, - cell: ({ row }) => { - const key = row.original; - const userAlias = key.user?.user_alias ?? null; - const userEmail = key.user?.user_email ?? key.user_email ?? null; - const userId = key.user_id ?? null; - const isDefaultAdmin = userId === "default_user_id"; - const displayValue = userAlias || userEmail || userId; - const width = 160; - - const popoverContent = ( -
- {[ - { label: "User Alias", value: userAlias }, - { label: "User Email", value: userEmail }, - { label: "User ID", value: userId }, - ].map(({ label, value }) => ( -
- {label} - {value ? ( - - {value} - - ) : ( - - - )} -
- ))} -
- ); - - if (isDefaultAdmin && !userAlias && !userEmail) { - return ( - - - - - - ); - } - - 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: 160, - enableSorting: false, - cell: (info) => { - const userId = info.getValue() as string | null; - if (!userId) return "-"; - const key = info.row.original; - const createdByUser = key.created_by_user; - const userAlias = createdByUser?.user_alias ?? null; - const userEmail = createdByUser?.user_email ?? null; - const isDefaultAdmin = userId === "default_user_id"; - const displayValue = userAlias || userEmail || userId; - const width = 160; - - const popoverContent = ( -
- {[ - { label: "User Alias", value: userAlias }, - { label: "User Email", value: userEmail }, - { label: "User ID", value: userId }, - ].map(({ label, value }) => ( -
- {label} - {value ? ( - - {value} - - ) : ( - - - )} -
- ))} -
- ); - - if (isDefaultAdmin && !userAlias && !userEmail) { - return ( - - - - - ); - } - - return ( - + }, + }, + { + id: "key_name", + accessorKey: "key_name", + header: "Secret Key", + size: 120, + enableSorting: false, + cell: (info) => ( + {info.getValue() as string} + ), + }, + { + id: "team_alias", + accessorKey: "team_id", + header: "Team", + size: 120, + enableSorting: false, + cell: (info) => { + const teamId = info.getValue() as string | null; + if (!teamId) return "-"; + const team = teams?.find((t) => t.team_id === teamId); + const displayValue = team?.team_alias || teamId; + 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: "organization_alias", + accessorKey: "org_id", + header: "Organization", + size: 140, + enableSorting: false, + cell: (info) => { + const orgId = info.getValue() as string | null; + if (!orgId) return "-"; + const org = resolvedOrganizations.find( + (o) => o.organization_id === orgId, + ); + const displayValue = org?.organization_alias || orgId; + const width = info.cell.column.getSize(); + return ( + + {displayValue} + + ); + }, }, - }, - { - 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: "user", + accessorKey: "user", + header: () => ( + + User + + + + + + + Displays the first available value: User Alias, User Email, or + User ID. + + + + + ), + size: 160, + enableSorting: false, + cell: ({ row }) => { + const key = row.original; + const userAlias = key.user?.user_alias ?? null; + const userEmail = key.user?.user_email ?? key.user_email ?? null; + const userId = key.user_id ?? null; + const isDefaultAdmin = userId === "default_user_id"; + const displayValue = userAlias || userEmail || userId; + const width = 160; + + const popoverBody = ( +
+ {[ + { label: "User Alias", value: userAlias }, + { label: "User Email", value: userEmail }, + { label: "User ID", value: userId }, + ].map(({ label, value }) => ( +
+ {label} + {value ? ( + + ) : ( + - + )} +
+ ))} +
+ ); + + const trigger = + isDefaultAdmin && !userAlias && !userEmail ? ( + + + + ) : ( + + {displayValue || "-"} + + ); + + return ( + + {trigger} + + {popoverBody} + + + ); + }, }, - }, - { - 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: "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: "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: "created_by", + accessorKey: "created_by", + header: "Created By", + size: 160, + enableSorting: false, + cell: (info) => { + const userId = info.getValue() as string | null; + if (!userId) return "-"; + const key = info.row.original; + const createdByUser = key.created_by_user; + const userAlias = createdByUser?.user_alias ?? null; + const userEmail = createdByUser?.user_email ?? null; + const isDefaultAdmin = userId === "default_user_id"; + const displayValue = userAlias || userEmail || userId; + const width = 160; + + const popoverBody = ( +
+ {[ + { label: "User Alias", value: userAlias }, + { label: "User Email", value: userEmail }, + { label: "User ID", value: userId }, + ].map(({ label, value }) => ( +
+ {label} + {value ? ( + + ) : ( + - + )} +
+ ))} +
+ ); + + const trigger = + isDefaultAdmin && !userAlias && !userEmail ? ( + + + + ) : ( + + {displayValue} + + ); + + return ( + + {trigger} + + {popoverBody} + + + ); + }, }, - }, - { - 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: "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: "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)} - - - ), - )} -
- )} -
-
- + { + id: "last_active", + accessorKey: "last_active", + header: () => ( + + Last Active + + + + + + + This is a new field and is not backfilled. Only new key usage + will update this value. + + + + + ), + size: 130, + enableSorting: false, + cell: (info) => { + const value = info.getValue(); + if (!value) return "Unknown"; + const date = new Date(value as string); + return ( + + + + {date.toLocaleDateString()} + + + {date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "long", + })} + + + + ); + }, + }, + { + 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[]; + if (!Array.isArray(models)) return null; + if (models.length === 0) { + return ( + + All Proxy Models + + ); + } + return ( +
+
+ {models.length > 3 && ( + )} +
+ {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"}
-
- ); + { + 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"}
+
+ ); + }, }, - }, - ], [teams, resolvedOrganizations]); + ], + [teams, resolvedOrganizations, expandedAccordions], + ); const filterOptions: FilterOption[] = [ { @@ -568,13 +630,14 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo isSearchable: true, searchFn: async (searchText: string) => { if (!allTeams || allTeams.length === 0) return []; - const filteredTeams = allTeams.filter( (team) => team.team_id.toLowerCase().includes(searchText.toLowerCase()) || - (team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())), + (team.team_alias && + team.team_alias + .toLowerCase() + .includes(searchText.toLowerCase())), ); - return filteredTeams.map((team) => ({ label: `${team.team_alias || team.team_id} (${team.team_id})`, value: team.team_id, @@ -587,13 +650,17 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo isSearchable: true, searchFn: async (searchText: string) => { if (!allOrganizations || allOrganizations.length === 0) return []; - const filteredOrgs = allOrganizations.filter( - (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, + (org) => + org.organization_id + ?.toLowerCase() + .includes(searchText.toLowerCase()) ?? false, ); - return filteredOrgs - .filter((org) => org.organization_id !== null && org.organization_id !== undefined) + .filter( + (org) => + org.organization_id !== null && org.organization_id !== undefined, + ) .map((org) => ({ label: `${org.organization_id || "Unknown"} (${org.organization_id})`, value: org.organization_id as string, @@ -605,16 +672,8 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo label: "Key Alias", customComponent: PaginatedKeyAliasSelect, }, - { - name: "User ID", - label: "User ID", - isSearchable: false, - }, - { - name: "Key Hash", - label: "Key Hash", - isSearchable: false, - }, + { name: "User ID", label: "User ID", isSearchable: false }, + { name: "Key Hash", label: "Key Hash", isSearchable: false }, ]; const table = useReactTable({ @@ -622,28 +681,22 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo columns: columns.filter((col) => col.id !== "expander"), columnResizeMode: "onChange", columnResizeDirection: "ltr", - state: { - sorting, - pagination: tablePagination, - }, + state: { sorting, pagination: tablePagination }, onSortingChange: (updaterOrValue) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + const newSorting = + typeof updaterOrValue === "function" + ? updaterOrValue(sorting) + : updaterOrValue; setSorting(newSorting); if (newSorting && newSorting.length > 0) { const sortState = newSorting[0]; - const sortBy = sortState.id; - const sortOrder = sortState.desc ? "desc" : "asc"; - // Update filters state without triggering debouncedSearch - // The useKeys hook will automatically refetch with the new sort parameters + const sortByVal = sortState.id; + const sortOrderVal = sortState.desc ? "desc" : "asc"; handleFilterChange( - { - ...filters, - "Sort By": sortBy, - "Sort Order": sortOrder, - }, - true, // skipDebounce - let useKeys handle the API call with correct page size + { ...filters, "Sort By": sortByVal, "Sort Order": sortOrderVal }, + true, ); - onSortChange?.(sortBy, sortOrder); + onSortChange?.(sortByVal, sortOrderVal as "asc" | "desc"); } }, onPaginationChange: setTablePagination, @@ -656,14 +709,10 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo pageCount: Math.ceil(totalCount / tablePagination.pageSize), }); - // Update local sorting state when currentSort prop changes React.useEffect(() => { if (currentSort) { setSorting([ - { - id: currentSort.sortBy, - desc: currentSort.sortOrder === "desc", - }, + { id: currentSort.sortBy, desc: currentSort.sortOrder === "desc" }, ]); } }, [currentSort]); @@ -672,6 +721,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo const start = pageIndex * pageSize + 1; const end = Math.min((pageIndex + 1) * pageSize, totalCount); const rangeLabel = `${start} - ${end}`; + return (
{selectedKey ? ( @@ -696,122 +746,158 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{isLoading ? ( - + ) : ( - + Showing {rangeLabel} of {totalCount} results )} - } +
{isLoading ? ( - + ) : ( - + Page {pageIndex + 1} of {table.getPageCount()} )} {isLoading ? ( - + ) : ( - + )} {isLoading ? ( - + ) : ( - + )}
- - +
+ {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( - { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); + 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()) { + 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} + onClick={ + header.column.getCanSort() + ? header.column.getToggleSortingHandler() + : undefined + } >
{header.isPlaceholder ? null - : flexRender(header.column.columnDef.header, header.getContext())} + : flexRender( + header.column.columnDef.header, + header.getContext(), + )}
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} + {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" : ""}`} + 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", + background: header.column.getIsResizing() + ? "hsl(var(--primary))" + : "transparent", cursor: "col-resize", userSelect: "none", touchAction: "none", @@ -819,16 +905,19 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }} />
- + ))} ))} - + {isLoading ? ( - -
+ +

🚅 Loading keys...

@@ -845,17 +934,29 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo whiteSpace: "pre-wrap", overflow: "hidden", }} - className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${cell.column.id === "models" && Array.isArray(cell.getValue()) && (cell.getValue() as string[]).length > 3 ? "px-0" : ""}`} + className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${ + cell.column.id === "models" && + Array.isArray(cell.getValue()) && + (cell.getValue() as string[]).length > 3 + ? "px-0" + : "" + }`} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} ))} )) ) : ( - -
+ +

No keys found

diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx index 45687461510..8ec8abfa6b3 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/BudgetWindowsEditor.tsx @@ -1,5 +1,14 @@ -import { Button, InputNumber, Select } from "antd"; import React from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { X } from "lucide-react"; export interface BudgetWindowEntry { budget_duration: string; @@ -7,10 +16,10 @@ export interface BudgetWindowEntry { } export const BUDGET_WINDOW_OPTIONS = [ - { value: "1h", label: "Hourly", resetHint: "Resets every hour" }, - { value: "24h", label: "Daily", resetHint: "Resets daily at midnight UTC" }, - { value: "7d", label: "Weekly", resetHint: "Resets every Sunday at midnight UTC" }, - { value: "30d", label: "Monthly", resetHint: "Resets on the 1st of every month at midnight UTC" }, + { value: "1h", label: "Hourly", resetHint: "Resets every hour" }, + { value: "24h", label: "Daily", resetHint: "Resets daily at midnight UTC" }, + { value: "7d", label: "Weekly", resetHint: "Resets every Sunday at midnight UTC" }, + { value: "30d", label: "Monthly", resetHint: "Resets on the 1st of every month at midnight UTC" }, ]; interface BudgetWindowsEditorProps { @@ -27,7 +36,11 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp onChange(value.filter((_, i) => i !== idx)); }; - const updateWindow = (idx: number, field: keyof BudgetWindowEntry, fieldValue: string | number | null) => { + const updateWindow = ( + idx: number, + field: keyof BudgetWindowEntry, + fieldValue: string | number | null, + ) => { const updated = value.map((w, i) => (i === idx ? { ...w, [field]: fieldValue } : w)); onChange(updated); }; @@ -35,38 +48,57 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp return (
{value.map((window, idx) => { - const hint = BUDGET_WINDOW_OPTIONS.find((o) => o.value === window.budget_duration)?.resetHint; + const hint = BUDGET_WINDOW_OPTIONS.find( + (o) => o.value === window.budget_duration, + )?.resetHint; return ( -
-
+
+
+
+ + $ + + { + const v = e.target.value; + updateWindow(idx, "max_budget", v === "" ? null : Number(v)); + }} + placeholder="Max spend" + className="pl-6" + /> +
+
{hint && ( -
+
↻ {hint}
)} @@ -74,8 +106,13 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp ); })}