From 05189c04dda56c0ce8cf0a657d778cd244ffdf29 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Apr 2026 13:01:20 +0000 Subject: [PATCH] Migrate AllModelsTab and RouterConfigBuilder to shadcn (Phase 1) - AllModelsTab.tsx: drop antd Badge/Button/Select/Skeleton/Space/Typography + @ant-design/icons -> shadcn Badge/Button/Select/Skeleton + lucide Info/Settings/Filter/RefreshCw/Search. Pagination buttons become shadcn Button variant=outline. Tremor Grid dropped (already a plain div). Kept Tremor TabPanel (still required wrapper for parent TabGroup). - RouterConfigBuilder.tsx: antd Select/Collapse/Empty/InputNumber -> shadcn Accordion/Select + shadcn Input type=number + custom empty-state div. The tags-mode utterances picker becomes a small UtterancesInput with press-Enter/paste-multiline behavior preserved. Remove-route button is now aria-labeled 'Remove route' on the AccordionTrigger sibling. - RouterConfigBuilder.test.tsx: two delete-button tests repaired to query by /remove route/i instead of the old antd 'delete' aria label. All 16 tests pass. AllModelsTab is added to the raw-tailwind-colors eslint override for the categorical team/view-mode palette dots (bg-blue-500 / bg-emerald-500 / bg-purple-500). Co-authored-by: yuneng-jiang --- ui/litellm-dashboard/.eslintrc.json | 1 + .../components/AllModelsTab.tsx | 347 ++++++++---------- .../add_model/RouterConfigBuilder.test.tsx | 4 +- .../add_model/RouterConfigBuilder.tsx | 339 +++++++++-------- 4 files changed, 359 insertions(+), 332 deletions(-) diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 26c573efd0f..348e5247312 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -137,6 +137,7 @@ "src/app/(dashboard)/teams/components/TeamsTable/ModelsCell.tsx", "src/app/(dashboard)/teams/components/TeamsTable/TeamsTable.tsx", "src/app/onboarding/OnboardingFormBody.tsx", + "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx", "src/components/prompts/prompt_editor_view/conversation_panel/MessageBubble.tsx", "src/components/playground/chat_ui/SessionManagement.tsx", "src/components/playground/chat_ui/SearchResultsDisplay.tsx", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 6169c78b7c8..6191c2b3030 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -8,19 +8,28 @@ import { getDisplayModelName } from "@/components/view_model/model_name_display" import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { modelDeleteCall } from "@/components/networking"; -import { Info as InfoCircleOutlined, Settings as SettingOutlined } from "lucide-react"; +import { Info, Settings, Filter, RefreshCw, Search } from "lucide-react"; import { PaginationState, SortingState } from "@tanstack/react-table"; -// eslint-disable-next-line litellm-ui/no-banned-ui-imports import { useQueryClient } from "@tanstack/react-query"; -import { Grid, TabPanel } from "@tremor/react"; -import { Badge, Button, Select, Skeleton, Space, Typography } from "antd"; +// eslint-disable-next-line litellm-ui/no-banned-ui-imports +import { TabPanel } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import debounce from "lodash/debounce"; import { useEffect, useMemo, useState } from "react"; import { useModelsInfo } from "../../hooks/models/useModels"; import { transformModelData } from "../utils/modelDataTransformer"; + type ModelViewMode = "all" | "current_team"; -const { Text } = Typography; interface AllModelsTabProps { selectedModelGroup: string | null; @@ -60,12 +69,10 @@ const AllModelsTab = ({ const [sorting, setSorting] = useState([]); const [isModelSettingsModalVisible, setIsModelSettingsModalVisible] = useState(false); - // Debounce search input const debouncedUpdateSearch = useMemo( () => debounce((value: string) => { setDebouncedSearch(value); - // Reset to page 1 when search changes setCurrentPage(1); setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); }, 200), @@ -79,18 +86,16 @@ const AllModelsTab = ({ }; }, [modelNameSearch, debouncedUpdateSearch]); - // Determine teamId to pass to the query - only pass if not "personal" const teamIdForQuery = currentTeam === "personal" ? undefined : currentTeam.team_id; - // Convert sorting state to sortBy and sortOrder for API const sortBy = useMemo(() => { if (sorting.length === 0) return undefined; const sort = sorting[0]; const columnIdToServerField: Record = { - input_cost: "costs", // Map input_cost column to "costs" for server-side sorting - model_info_db_model: "status", // Map model_info.db_model column to "status" for server-side sorting - model_info_created_by: "created_at", // Map model_info.created_by column to "created_at" for server-side sorting - model_info_updated_at: "updated_at", // Map model_info.updated_at column to "updated_at" for server-side sorting + input_cost: "costs", + model_info_db_model: "status", + model_info_created_by: "created_at", + model_info_updated_at: "updated_at", }; return columnIdToServerField[sort.id] || sort.id; }, [sorting]); @@ -129,7 +134,6 @@ const AllModelsTab = ({ const [deleteModalModelId, setDeleteModalModelId] = useState(null); const [deleteLoading, setDeleteLoading] = useState(false); - // Get pagination metadata from the response const paginationMeta = useMemo(() => { if (!rawModelData) { return { @@ -152,7 +156,6 @@ const AllModelsTab = ({ return []; } - // Server-side search is now handled by the API, so we only filter by other criteria return modelData.data.filter((model: any) => { const modelNameMatch = selectedModelGroup === "all" || @@ -165,8 +168,6 @@ const AllModelsTab = ({ model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter) || !selectedModelAccessGroupFilter; - // Team filtering is now handled server-side via teamId query parameter - // Only apply client-side filtering for model groups and access groups return modelNameMatch && accessGroupMatch; }); }, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]); @@ -176,13 +177,11 @@ const AllModelsTab = ({ setCurrentPage(1); }, [selectedModelGroup, selectedModelAccessGroupFilter]); - // Reset pagination when team changes useEffect(() => { setCurrentPage(1); setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); }, [teamIdForQuery]); - // Reset pagination when sorting changes useEffect(() => { setCurrentPage(1); setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); @@ -221,103 +220,96 @@ const AllModelsTab = ({ } }; + const currentTeamValue = + currentTeam === "personal" ? "personal" : currentTeam.team_id; + + const onTeamChange = (value: string) => { + if (value === "personal") { + setCurrentTeam("personal"); + setCurrentPage(1); + setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); + } else { + const team = teams?.find((t) => t.team_id === value); + if (team) { + setCurrentTeam(team); + setCurrentPage(1); + setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 })); + } + } + }; + return ( - +
-
+
{/* Current Team and View Mode Selector - Prominent Section */} -
+
- Current Team: + Current Team:
- {isLoading ? ( - + {isLoading || isLoadingTeams ? ( + ) : ( - + + + + + + + + Personal + + + {teams ?.filter((team) => team.team_id) - .map((team) => ({ - value: team.team_id, - label: ( - - - + .map((team) => ( + + + + {team.team_alias ? team.team_alias : team.team_id} - - - ), - })) ?? []), - ]} - /> + + + + ))} + + )}
- View: + View:
{isLoading ? ( - + ) : ( )}
@@ -325,14 +317,14 @@ const AllModelsTab = ({ {modelViewMode === "current_team" && (
- -
+ +
{currentTeam === "personal" ? ( To access these models: Create a Virtual Key without selecting a team on the{" "} Virtual Keys page @@ -344,7 +336,7 @@ const AllModelsTab = ({ the{" "} Virtual Keys page @@ -358,7 +350,6 @@ const AllModelsTab = ({ {/* Search and Filter Controls */}
- {/* Search and Filter Controls */}
{/* Model Name Search */} @@ -367,104 +358,94 @@ const AllModelsTab = ({ type="text" placeholder="Search model names..." data-testid="model-search-input" - className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + className="w-full px-3 py-2 pl-8 border border-input bg-background rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring" value={modelNameSearch} onChange={(e) => setModelNameSearch(e.target.value)} /> - - - +
{/* Filter Button */} - + {/* Reset Filters Button */} - +
{/* Model Settings Button */}
{/* Additional Filters */} {showFilters && (
- {/* Model Name Filter */}
- {/* Model Access Group Filter */}
)} @@ -472,9 +453,9 @@ const AllModelsTab = ({ {/* Results Count and Pagination Controls */}
{isLoading ? ( - + ) : ( - + {paginationMeta.total_count > 0 ? `Showing ${((currentPage - 1) * pageSize) + 1} - ${Math.min(currentPage * pageSize, paginationMeta.total_count)} of ${paginationMeta.total_count} results` : "Showing 0 results"} @@ -483,41 +464,39 @@ const AllModelsTab = ({
{isLoading ? ( - + ) : ( - + )} {isLoading ? ( - + ) : ( - + )}
@@ -549,7 +528,7 @@ const AllModelsTab = ({ />
- +
{ expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); }); - const deleteButton = screen.getByRole("button", { name: "delete" }); + const deleteButton = screen.getByRole("button", { name: /remove route/i }); await user.click(deleteButton); await waitFor(() => { @@ -131,7 +131,7 @@ describe("RouterConfigBuilder", () => { expect(screen.getByText("Route 1: gpt-4")).toBeInTheDocument(); }); - const deleteButton = screen.getByRole("button", { name: "delete" }); + const deleteButton = screen.getByRole("button", { name: /remove route/i }); await user.click(deleteButton); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx index 442211190f3..43a11e81a95 100644 --- a/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx +++ b/ui/litellm-dashboard/src/components/add_model/RouterConfigBuilder.tsx @@ -1,7 +1,21 @@ -import { Info, Plus, Trash2 } from "lucide-react"; -import { Select as AntdSelect, Collapse, Empty, InputNumber } from "antd"; +import { Info, Plus, Trash2, X } from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, @@ -50,12 +64,77 @@ interface RouterConfigBuilderProps { onChange?: (config: any) => void; } +function UtterancesInput({ + value, + onChange, +}: { + value: string[]; + onChange: (next: string[]) => void; +}) { + const [draft, setDraft] = useState(""); + + const addValues = (input: string) => { + const parts = input + .split(/[\n]/) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (parts.length === 0) return; + const next = [...value]; + for (const part of parts) { + if (!next.includes(part)) { + next.push(part); + } + } + onChange(next); + }; + + return ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addValues(draft); + setDraft(""); + } + }} + onPaste={(e) => { + const text = e.clipboardData.getData("text"); + if (text.includes("\n")) { + e.preventDefault(); + addValues(text); + setDraft(""); + } + }} + /> + {value.length > 0 && ( +
+ {value.map((u) => ( + + {u} + + + ))} +
+ )} +
+ ); +} + const RouterConfigBuilder: React.FC = ({ modelInfo, value, onChange }) => { const [routes, setRoutes] = useState([]); const [showJsonPreview, setShowJsonPreview] = useState(false); const [expandedRoutes, setExpandedRoutes] = useState([]); - // Initialize routes from value prop - preserve existing route IDs to avoid focus loss when parent re-renders useEffect(() => { const routesFromValue = value?.routes; if (routesFromValue) { @@ -67,7 +146,7 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va routeIds.push(id); return { id, - model: route.name || route.model || "", // handle both 'name' and 'model' fields + model: route.name || route.model || "", utterances: route.utterances || [], description: route.description || "", score_threshold: route.score_threshold ?? 0.5, @@ -82,7 +161,6 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va } }, [value]); - // Handle adding a new route const addRoute = () => { const newRouteId = `route-${Date.now()}`; const newRoute: Route = { @@ -95,27 +173,22 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va const updatedRoutes = [...routes, newRoute]; setRoutes(updatedRoutes); updateConfig(updatedRoutes); - // Automatically expand the new route setExpandedRoutes((prev) => [...prev, newRouteId]); }; - // Handle removing a route const removeRoute = (routeId: string) => { const updatedRoutes = routes.filter((route) => route.id !== routeId); setRoutes(updatedRoutes); updateConfig(updatedRoutes); - // Remove from expanded routes as well setExpandedRoutes((prev) => prev.filter((id) => id !== routeId)); }; - // Handle updating a route const updateRoute = (routeId: string, field: keyof Route, value: any) => { const updatedRoutes = routes.map((route) => (route.id === routeId ? { ...route, [field]: value } : route)); setRoutes(updatedRoutes); updateConfig(updatedRoutes); }; - // Update the overall configuration const updateConfig = (updatedRoutes: Route[]) => { const config = { routes: updatedRoutes.map((route) => ({ @@ -128,21 +201,6 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va onChange?.(config); }; - // Handle utterances change (convert textarea string to array) - const handleUtterancesChange = (routeId: string, utterancesText: string) => { - const utterancesArray = utterancesText - .split("\n") - .map((line) => line.trim()) // Only trims leading/trailing whitespace, preserves internal spaces - .filter((line) => line.length > 0); - updateRoute(routeId, "utterances", utterancesArray); - }; - - // Prepare model options for dropdowns - const modelOptions = modelInfo.map((model) => ({ - value: model.model_group, - label: model.model_group, - })); - const generateConfig = () => { return { routes: routes.map((route) => ({ @@ -173,133 +231,122 @@ const RouterConfigBuilder: React.FC = ({ modelInfo, va {/* Routes */} {routes.length === 0 ? ( - +
+
+ No routes configured. Click "Add Route" to get started. +
+
) : ( - - setExpandedRoutes( - Array.isArray(keys) ? keys : [keys].filter(Boolean), - ) - } - style={{ width: "100%" }} - items={routes.map((route, index) => ({ - key: route.id, - label: ( - - Route {index + 1}: {route.model || "Unnamed"} - - ), - extra: ( - - ), - children: ( - - {/* Model Selection */} -
- - Model + setExpandedRoutes(next)} + className="w-full" + > + {routes.map((route, index) => ( + +
+ + + Route {index + 1}: {route.model || "Unnamed"} - - updateRoute(route.id, "model", value) - } - placeholder="Select model" - showSearch - style={{ width: "100%" }} - options={modelOptions} - /> -
- - {/* Description */} -
- - Description - -