diff --git a/ui/litellm-dashboard/docs/BLOCKERS.md b/ui/litellm-dashboard/docs/BLOCKERS.md index b154880db38..67f8eba5354 100644 --- a/ui/litellm-dashboard/docs/BLOCKERS.md +++ b/ui/litellm-dashboard/docs/BLOCKERS.md @@ -31,3 +31,12 @@ stay on antd for phase 1 and will be addressed in a targeted follow-up. with Switch/InputNumber/Select controls. Defer until shared section unblocks. - `src/components/add_pass_through.tsx`: same dependency chain. +- `src/components/team/TeamInfo.tsx` (1724 LoC): team detail/edit page with a + deeply coupled antd `Form` — `Form.List` + inline Promise validators for + per-model rate limits, `Form.useWatch` hooks driving dependent rendering, + `shouldUpdate` patterns, hidden form fields registered via `Form.Item`, + antd `Select.OptGroup` with a custom `tagRender` for guardrails, and the + Tremor `Accordion` still required by the settings panel. Migration + exceeds the two-attempt budget for this batch. Defer until Tremor + `Accordion` has a shadcn replacement adopted and the antd Form surface + can be broken into sub-forms. diff --git a/ui/litellm-dashboard/src/components/team/EditMembership.tsx b/ui/litellm-dashboard/src/components/team/EditMembership.tsx index 8725e201c20..c0ce768d052 100644 --- a/ui/litellm-dashboard/src/components/team/EditMembership.tsx +++ b/ui/litellm-dashboard/src/components/team/EditMembership.tsx @@ -1,13 +1,12 @@ -import { Form, Select } from "antd"; +import React, { useEffect, useMemo, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import React, { useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; import NumericalInput from "../shared/numerical_input"; interface BaseMember { @@ -16,6 +15,18 @@ interface BaseMember { role: string; } +interface AdditionalField { + name: string; + label: string | React.ReactNode; + type: "input" | "select" | "numerical" | "multi-select"; + options?: Array<{ label: string; value: string }>; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rules?: any[]; + step?: number; + min?: number; + placeholder?: string; +} + interface ModalConfig { title: string; roleOptions: Array<{ @@ -25,17 +36,7 @@ interface ModalConfig { defaultRole?: string; showEmail?: boolean; showUserId?: boolean; - additionalFields?: Array<{ - name: string; - label: string | React.ReactNode; - type: "input" | "select" | "numerical" | "multi-select"; - options?: Array<{ label: string; value: string }>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rules?: any[]; - step?: number; - min?: number; - placeholder?: string; - }>; + additionalFields?: AdditionalField[]; } interface MemberModalProps { @@ -47,6 +48,79 @@ interface MemberModalProps { config: ModalConfig; } +type FormValues = { + user_email?: string; + user_id?: string; + role: string; + max_budget_in_team?: number | string | null; + tpm_limit?: number | string | null; + rpm_limit?: number | string | null; + allowed_models?: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +function MultiSelectField({ + value, + onChange, + options, + placeholder, +}: { + value: string[]; + onChange: (next: string[]) => void; + options: Array<{ label: string; value: string }>; + placeholder?: string; +}) { + const selected = value ?? []; + const remaining = options.filter((o) => !selected.includes(o.value)); + + return ( +
+ + {selected.length > 0 && ( +
+ {selected.map((v) => { + const opt = options.find((o) => o.value === v); + return ( + + {opt?.label ?? v} + + + ); + })} +
+ )} +
+ ); +} + const MemberModal = ({ visible, onCancel, @@ -55,47 +129,56 @@ const MemberModal = ({ mode, config, }: MemberModalProps) => { - const [form] = Form.useForm(); const [isSubmitting, setIsSubmitting] = useState(false); - // Reset form and set initial values when modal becomes visible or initialData changes - useEffect(() => { - if (visible) { - if (mode === "edit" && initialData) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const d = initialData as any; - const formValues = { - ...initialData, - role: initialData.role || config.defaultRole, - max_budget_in_team: d.max_budget_in_team || null, - tpm_limit: d.tpm_limit || null, - rpm_limit: d.rpm_limit || null, - allowed_models: d.allowed_models || [], - }; - form.setFieldsValue(formValues); - } else { - // For add mode, reset to defaults - form.resetFields(); - form.setFieldsValue({ - role: config.defaultRole || config.roleOptions[0]?.value, - }); - } - } - }, [visible, initialData, mode, form, config.defaultRole, config.roleOptions]); + const defaultValues = useMemo( + () => ({ + user_email: "", + user_id: "", + role: config.defaultRole || config.roleOptions[0]?.value || "", + max_budget_in_team: null, + tpm_limit: null, + rpm_limit: null, + allowed_models: [], + }), + [config.defaultRole, config.roleOptions], + ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleSubmit = async (values: any) => { + const { + register, + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues, + }); + + useEffect(() => { + if (!visible) return; + if (mode === "edit" && initialData) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = initialData as any; + reset({ + ...initialData, + role: initialData.role || config.defaultRole || "", + max_budget_in_team: d.max_budget_in_team ?? null, + tpm_limit: d.tpm_limit ?? null, + rpm_limit: d.rpm_limit ?? null, + allowed_models: d.allowed_models ?? [], + }); + } else { + reset(defaultValues); + } + }, [visible, initialData, mode, reset, config.defaultRole, defaultValues]); + + const submit = handleSubmit(async (values) => { try { setIsSubmitting(true); const formData = Object.entries(values).reduce((acc, [key, value]) => { if (typeof value === "string") { const trimmedValue = value.trim(); - if ( - trimmedValue === "" && - (key === "max_budget_in_team" || - key === "tpm_limit" || - key === "rpm_limit") - ) { + if (trimmedValue === "" && (key === "max_budget_in_team" || key === "tpm_limit" || key === "rpm_limit")) { return { ...acc, [key]: null }; } return { ...acc, [key]: trimmedValue }; @@ -104,59 +187,84 @@ const MemberModal = ({ }, {}) as T; await Promise.resolve(onSubmit(formData)); - form.resetFields(); + reset(defaultValues); } catch (error) { console.error("Form submission error:", error); } finally { setIsSubmitting(false); } - }; + }); - // Helper function to get role label from value const getRoleLabel = (value: string) => { return config.roleOptions.find((option) => option.value === value)?.label || value; }; - const renderField = (field: { - name: string; - label: string | React.ReactNode; - type: "input" | "select" | "numerical" | "multi-select"; - options?: Array<{ label: string; value: string }>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rules?: any[]; - step?: number; - min?: number; - placeholder?: string; - }) => { + const orderedRoleOptions = useMemo(() => { + if (mode === "edit" && initialData) { + return [ + ...config.roleOptions.filter((o) => o.value === initialData.role), + ...config.roleOptions.filter((o) => o.value !== initialData.role), + ]; + } + return config.roleOptions; + }, [mode, initialData, config.roleOptions]); + + const renderField = (field: AdditionalField) => { switch (field.type) { case "input": - return ; + return ; case "numerical": return ( - ( + rhfField.onChange(v)} + /> + )} /> ); case "select": return ( - + ( + + )} + /> ); case "multi-select": return ( - - +
+ +
+ { + if (!value) return true; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value) || "Please enter a valid email!"; + }, + })} + /> + {errors.user_email &&

{errors.user_email.message as string}

} +
+
)} {config.showEmail && config.showUserId && ( @@ -199,68 +308,60 @@ const MemberModal = ({ )} {config.showUserId && ( - - - +
+ +
+ +
+
)} - + + +
+ ( + + )} + /> + {errors.role &&

{errors.role.message as string}

} +
+ {config.additionalFields?.map((field) => ( - - {renderField(field)} - +
+ +
{renderField(field)}
+
))}
-
- + ); diff --git a/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx b/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx index d2d7cc65f89..0848eb3b54d 100644 --- a/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx +++ b/ui/litellm-dashboard/src/components/team/LoggingSettings.tsx @@ -1,25 +1,14 @@ /* eslint-disable @next/next/no-img-element */ /* eslint-disable react/no-unescaped-entities */ import React from "react"; -import { Select } from "antd"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Ban, Info, Plus, Settings, Trash2 } from "lucide-react"; -import { - callbackInfo, - callback_map, - mapDisplayToInternalNames, -} from "../callback_info_helpers"; -import NumericalInput from "../shared/numerical_input"; - -const { Option } = Select; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Ban, Info, Plus, Settings, Trash2, X } from "lucide-react"; +import { callbackInfo, callback_map, mapDisplayToInternalNames } from "../callback_info_helpers"; interface LoggingConfig { callback_name: string; @@ -34,6 +23,33 @@ interface LoggingSettingsProps { onDisabledCallbacksChange?: (disabledCallbacks: string[]) => void; } +function CallbackLogo({ name, size = "sm" }: { name: string; size?: "sm" | "md" }) { + const logo = callbackInfo[name]?.logo; + const dims = size === "md" ? "w-5 h-5" : "w-4 h-4"; + if (!logo) { + return ( +
{name.charAt(0)}
+ ); + } + return ( + {name} { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + const fallbackDiv = document.createElement("div"); + fallbackDiv.className = `${dims} rounded-full bg-muted flex items-center justify-center text-xs`; + fallbackDiv.textContent = name.charAt(0); + parent.replaceChild(fallbackDiv, target); + } + }} + /> + ); +} + const LoggingSettings: React.FC = ({ value = [], onChange, @@ -53,11 +69,21 @@ const LoggingSettings: React.FC = ({ }; const handleDisabledCallbacksChange = (newDisabledCallbacks: string[]) => { - // Map display names to internal callback values const mappedDisabledCallbacks = mapDisplayToInternalNames(newDisabledCallbacks); onDisabledCallbacksChange?.(mappedDisabledCallbacks); }; + const addDisabledCallback = (name: string) => { + if (!name || disabledCallbacks.includes(name)) return; + handleDisabledCallbacksChange([...disabledCallbacks, name]); + }; + + const removeDisabledCallback = (name: string) => { + handleDisabledCallbacksChange(disabledCallbacks.filter((n) => n !== name)); + }; + + const remainingDisabledOptions = allCallbacks.filter((c) => !disabledCallbacks.includes(c)); + const addLoggingConfig = () => { const newConfig: LoggingConfig = { callback_name: "", @@ -80,7 +106,6 @@ const LoggingSettings: React.FC = ({ ) => { const updatedConfigs = [...value]; if (field === "callback_name") { - // Convert display name to callback value and reset callback_vars when callback changes const callbackValue = callback_map[newValue] || newValue; updatedConfigs[index] = { ...updatedConfigs[index], @@ -111,8 +136,7 @@ const LoggingSettings: React.FC = ({ const renderDynamicParams = (config: LoggingConfig, configIndex: number) => { if (!config.callback_name) return null; - // Find the display name for the callback - const callbackDisplayName = Object.entries(callback_map).find(([_, value]) => value === config.callback_name)?.[0]; + const callbackDisplayName = Object.entries(callback_map).find(([_, v]) => v === config.callback_name)?.[0]; if (!callbackDisplayName) return null; @@ -126,9 +150,7 @@ const LoggingSettings: React.FC = ({
- - Integration Parameters - + Integration Parameters
{Object.entries(dynamicParams).map(([paramName, paramType]) => ( @@ -141,8 +163,7 @@ const LoggingSettings: React.FC = ({ - Environment variable reference recommended: - os.environ/{paramName.toUpperCase()} + Environment variable reference recommended: os.environ/{paramName.toUpperCase()} @@ -158,29 +179,23 @@ const LoggingSettings: React.FC = ({ )} {paramType === "number" && ( - - Value must be between 0 and 1 - + Value must be between 0 and 1 )} {paramType === "number" ? ( - - updateCallbackVar(configIndex, paramName, e.target.value) - } + onChange={(e) => updateCallbackVar(configIndex, paramName, e.target.value)} + onWheel={(event) => (event.currentTarget as HTMLInputElement).blur()} /> ) : ( - updateCallbackVar(configIndex, paramName, e.target.value) - } + onChange={(e) => updateCallbackVar(configIndex, paramName, e.target.value)} /> )}
@@ -196,70 +211,59 @@ const LoggingSettings: React.FC = ({
- - Disabled Callbacks - + Disabled Callbacks - Select callbacks to disable for this key. Disabled callbacks - will not receive any logging data. + Select callbacks to disable for this key. Disabled callbacks will not receive any logging data.
- - addDisabledCallback(v)}> + + + + + {remainingDisabledOptions.length === 0 ? ( +
No more callbacks available
+ ) : ( + remainingDisabledOptions.map((callbackName) => ( + +
+ + {callbackName} +
+
+ )) + )} +
+ {disabledCallbacks.length > 0 && ( +
+ {disabledCallbacks.map((name) => ( + + {name} + + + ))} +
+ )}
- Select callbacks that should be disabled for this key. These - callbacks will not receive any logging data. + Select callbacks that should be disabled for this key. These callbacks will not receive any logging data.
@@ -270,17 +274,13 @@ const LoggingSettings: React.FC = ({
- - Logging Integrations - + Logging Integrations - - Configure callback logging integrations for this team. - + Configure callback logging integrations for this team.
@@ -299,9 +299,8 @@ const LoggingSettings: React.FC = ({
{value.map((config, index) => { const callbackDisplayName = config.callback_name - ? Object.entries(callback_map).find(([_, value]) => value === config.callback_name)?.[0] + ? Object.entries(callback_map).find(([_, v]) => v === config.callback_name)?.[0] : undefined; - const logoUrl = callbackDisplayName ? callbackInfo[callbackDisplayName]?.logo : null; return ( = ({ >
- {logoUrl && ( - {callbackDisplayName} - )} - - {callbackDisplayName || "New Integration"} Configuration - + {callbackDisplayName && } + {callbackDisplayName || "New Integration"} Configuration
- + + + + + + {value ? {value} : null} + + ); }, }, @@ -247,20 +252,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Key Alias", size: 150, enableSorting: true, - cell: (info) => { - const value = info.getValue() as string; - const width = info.cell.column.getSize(); - return ( - - - {value ?? "-"} - - - ); - }, + cell: (info) => , }, { id: "key_name", @@ -286,18 +278,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi 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 ?? "-"} - - - ); + return ; }, }, { @@ -309,17 +290,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi 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 ?? "-"} - - - ); + return ; }, }, { @@ -342,17 +313,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi 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 ?? "-"} - - - ); + return ; }, }, { @@ -372,11 +333,15 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: () => ( Last Active - - + + + + + + This is a new field and is not backfilled. Only new key usage will update this value. + ), @@ -387,9 +352,19 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi if (!value) return "Unknown"; const date = new Date(value as string); return ( - - {date.toLocaleDateString()} - + + + + {date.toLocaleDateString()} + + + {date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "long", + })} + + + ); }, }, @@ -443,80 +418,74 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi 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 (
- {Array.isArray(models) ? ( -
- {models.length === 0 ? ( - - All Proxy Models +
+ {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"} - ) : ( - <> -
- {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)} - - - ), - )} -
- )} -
-
- + )} + {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} +
); }, @@ -542,8 +511,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi const handleSortingChange = useCallback( (updaterOrValue: React.SetStateAction) => { - const newSorting = - typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; setSorting(newSorting); if (newSorting?.length > 0) { const sortState = newSorting[0]; @@ -599,35 +567,37 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{isLoading || isFetching ? ( - + ) : ( - + Page {pageIndex + 1} of {table.getPageCount()} )} {isLoading || isFetching ? ( - + ) : ( - + )} {isLoading || isFetching ? ( - + ) : ( - + )}
@@ -635,16 +605,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
- + {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`, - ); + 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} >
@@ -681,11 +643,11 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
{header.column.getIsSorted() ? ( { - asc: , - desc: , + asc: , + desc: , }[header.column.getIsSorted() as string] ) : ( - + )}
)} @@ -702,7 +664,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi 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", @@ -710,16 +672,16 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }} />
- + ))} ))} - + {isLoading || isFetching ? ( -
+

Loading keys...

@@ -752,7 +714,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ) : ( -
+

No keys found