From 1de40c91e995913f73975bb15d9ec986712bf034 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 18:35:53 -0700 Subject: [PATCH 1/2] refactor(ui): migrate the last antd components off antd onto shadcn Converts the remaining dashboard components that still imported antd: admin panel, agents, MCP toolsets, policies, prompts, bulk user edit, create user, plugin settings, teams, add model, auto router, cloudzero export, BYOK credentials, credential modal, onboarding link, create key and routing groups. Primitives map onto the house shadcn set: Typography onto semantic tags, Select onto ui/select, SearchSelect or MultiSelect, Input onto ui/input, Tooltip onto SimpleTooltip, Card, Table, Tabs, Switch, Checkbox, Radio, Tag onto Badge, Divider onto Separator, Spin onto UiLoadingSpinner, Modal onto Dialog, message onto toast, and Space, Row, Col, Flex and Layout onto flex containers. --- ui/litellm-dashboard/eslint-suppressions.json | 63 -- .../admin-panel/_components/AdminPanel.tsx | 38 +- .../_components/add_agent_form.test.tsx | 15 +- .../agents/_components/add_agent_form.tsx | 178 +++--- .../_components/MCPToolsetsTab.tsx | 40 +- .../policies/_components/add_policy_form.tsx | 5 +- .../prompts/_components/add_prompt_form.tsx | 96 +-- .../users/_components/BulkEditUsers.tsx | 242 ++++---- .../src/components/CreateUserButton.test.tsx | 54 +- .../src/components/CreateUserButton.tsx | 212 ++++--- .../PluginSettings/PluginSettings.tsx | 152 +++-- ui/litellm-dashboard/src/components/Teams.tsx | 65 +- .../src/components/add_model/AddModelForm.tsx | 572 +++++++++--------- .../add_model/add_auto_router_tab.tsx | 368 +++++------ ...loudzero_export_modal.integration.test.tsx | 2 +- .../src/components/cloudzero_export_modal.tsx | 23 +- .../mcp_tools/ByokCredentialModal.tsx | 18 +- .../components/model_add/CredentialModal.tsx | 40 +- .../src/components/onboarding_link.tsx | 6 +- .../create_key_button.integration.test.tsx | 38 +- .../organisms/create_key_button.tsx | 428 +++++++------ .../src/components/routing_groups/index.tsx | 102 ++-- .../update_model_credentials_modal.tsx | 7 +- 23 files changed, 1431 insertions(+), 1333 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2d68ea2aa684..a9a163f3c781 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -5,9 +5,6 @@ } }, "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -22,9 +19,6 @@ "no-nested-ternary": { "count": 3 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -535,9 +529,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -871,9 +862,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/immutability": { "count": 2 }, @@ -997,9 +985,6 @@ "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/prompts/_components/index.tsx": { @@ -1187,9 +1172,6 @@ } }, "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 } @@ -1323,9 +1305,6 @@ } }, "src/components/CreateUserButton.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1373,11 +1352,6 @@ "count": 1 } }, - "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx": { "max-nested-callbacks": { "count": 1 @@ -1428,9 +1402,6 @@ "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 }, @@ -1462,14 +1433,8 @@ } }, "src/components/add_model/AddModelForm.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, "no-nested-ternary": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/add_model/RouterConfigBuilder.tsx": { @@ -1480,9 +1445,6 @@ "src/components/add_model/add_auto_router_tab.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/add_model/add_model_modes.tsx": { @@ -1639,9 +1601,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, @@ -1815,11 +1774,6 @@ "count": 1 } }, - "src/components/mcp_tools/ByokCredentialModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/mcp_tools/MCPToolArgumentsForm.tsx": { "no-nested-ternary": { "count": 1 @@ -1835,11 +1789,6 @@ "count": 1 } }, - "src/components/model_add/CredentialModal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_add/reuse_credentials.tsx": { "local/filename-pascal-case": { "count": 1 @@ -1907,9 +1856,6 @@ "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/organisms/create_key_button.tsx": { @@ -1919,9 +1865,6 @@ "max-lines": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 2 }, @@ -2010,9 +1953,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/preserve-manual-memoization": { "count": 1 } @@ -2377,9 +2317,6 @@ "src/components/update_model_credentials_modal.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/user_agent_activity.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx index 903676d238a1..976fb94acea1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx @@ -7,7 +7,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Space, Tabs, Typography } from "antd"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Info, TriangleAlert } from "lucide-react"; import React, { useEffect, useState } from "react"; import NewBadge from "@/components/common_components/NewBadge"; @@ -35,8 +35,6 @@ import { Input } from "@/components/ui/input"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -const { Title, Paragraph, Text } = Typography; - const allowedIPSchema = z.object({ ip: z.string().min(1, "Please enter an IP address"), }); @@ -223,7 +221,7 @@ const AdminPanel: React.FC = ({ proxySettings }) => { children: ( <> - ✨ Security Settings +

✨ Security Settings

SSO Configuration Deprecated @@ -329,7 +327,9 @@ const AdminPanel: React.FC = ({ proxySettings }) => { Confirm Delete - Are you sure you want to delete the IP address: {ipToDelete}? + + Are you sure you want to delete the IP address: {ipToDelete}? + - - {fileList.length > 0 && ( -
Selected: {fileList[0].name}
+ + + {selectedFile && ( +
+ Selected: {selectedFile.name} + +
)} Upload a .prompt file that follows the Dotprompt specification @@ -196,16 +213,13 @@ const AddPromptForm: React.FC = ({ visible, onClose, accessT - {" "} - , - , ] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx index 5e540baaaf9a..d6d64a4e49f6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx @@ -1,12 +1,15 @@ -import React, { useState } from "react"; -import { Typography, Divider, Table, Select, InputNumber, Card, Space, Checkbox } from "antd"; +import React, { useId, useState } from "react"; import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/components/networking"; import { UserEditView } from "./user_edit_view"; import { toast } from "@/lib/toast"; import { MoneyCell } from "@/components/shared/table_cells"; +import { MultiSelect } from "@/components/shared/MultiSelect"; +import NumericalInput from "@/components/shared/numerical_input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; - -const { Text, Title } = Typography; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface BulkEditUserModalProps { open: boolean; @@ -38,6 +41,10 @@ const BulkEditUserModal: React.FC = ({ const [teamBudget, setTeamBudget] = useState(null); const [addToTeams, setAddToTeams] = useState(false); const [updateAllUsers, setUpdateAllUsers] = useState(false); + const updateAllUsersId = useId(); + const addToTeamsId = useId(); + const selectedTeamsId = useId(); + const teamBudgetId = useId(); const handleCancel = () => { // Reset team management state @@ -210,14 +217,22 @@ const BulkEditUserModal: React.FC = ({ {allowAllUsers && (
- setUpdateAllUsers(e.target.checked)}> - Update ALL users in the system - +
+ setUpdateAllUsers(checked === true)} + aria-label="Update ALL users in the system" + /> + +
{updateAllUsers && ( -
- +
+ ⚠️ This will apply changes to ALL users in the system, not just the selected ones. - +
)}
@@ -225,118 +240,115 @@ const BulkEditUserModal: React.FC = ({ {!updateAllUsers && (
- Selected Users ({selectedUsers.length}): - ( - - {text.length > 20 ? `${text.slice(0, 20)}...` : text} - - ), - }, - { - title: "Email", - dataIndex: "user_email", - key: "user_email", - width: "25%", - render: (text: string) => ( - - {text || "No email"} - - ), - }, - { - title: "Current Role", - dataIndex: "user_role", - key: "user_role", - width: "25%", - render: (role: string) => ( - {possibleUIRoles?.[role]?.ui_label || role} - ), - }, - { - title: "Budget", - dataIndex: "max_budget", - key: "max_budget", - width: "20%", - render: (budget: number | null) => ( - - ), - }, - ]} - /> +
Selected Users ({selectedUsers.length}):
+
+
+ + + User ID + Email + Current Role + Budget + + + + {selectedUsers.map((user) => ( + + + {user.user_id.length > 20 ? `${user.user_id.slice(0, 20)}...` : user.user_id} + + {user.user_email || "No email"} + + {possibleUIRoles?.[user.user_role]?.ui_label || user.user_role} + + + + + + ))} + +
+
)} - +
- +

Instructions: Fill in the fields below with the values you want to apply to all selected users. You can bulk edit: role, budget, models, and metadata. You can also add users to teams. - +

{/* Team Management Section */} - - - setAddToTeams(e.target.checked)}> - Add selected users to teams - - - {addToTeams && ( - <> -
- Select Teams: - onChange(selected.length === 0 ? undefined : selected)} - > - - - {(selected: string[]) => - selected.length === 0 - ? "Select Organization" - : organizationOptions - .filter((option) => selected.includes(option.value)) - .map((option) => option.label) - .join(", ") - } - - - - {organizationOptions.map((option) => ( - - {option.label} - - ))} - - + !open && handleCancel()}> + + + Invite User + +
+

Create a User who can own keys

+ +
+ +
+ + {userEmailField} + {roleField( + labelWithHint( + "Global Proxy Role", + "This role is independent of any team/org specific roles. Configure Team / Organization Admins in the Settings", + ), )} - - - {metadataField} - {sendInviteEmailField} - - - - - Personal Key Creation - - - - {({ value, onChange }) => ( - ({ label: getModelDisplayName(model), value: model })), - ]} - value={value ?? []} - onValueChange={onChange} - placeholder="Select models" - /> - )} - - - - - -
- -
-
-
- + {teamField} + + + {({ id, value, onChange }) => ( + + )} + + + {metadataField} + {sendInviteEmailField} + + + + + Personal Key Creation + + + + {({ value, onChange }) => ( + ({ label: getModelDisplayName(model), value: model })), + ]} + value={value ?? []} + onValueChange={onChange} + placeholder="Select models" + /> + )} + + + + + +
+ +
+ + +
+
{apiuser && ( {v}, - }, - { title: "Display Name", dataIndex: "display_name", key: "display_name" }, - { - title: "URL", - dataIndex: "url", - key: "url", - render: (v: string) => ( - - {v} - - ), - }, - { - title: "Plugin Key", - dataIndex: "plugin_key", - key: "plugin_key", - render: (v?: string) => (v ? {"•".repeat(8)} : ), - }, - { - title: "Actions", - key: "actions", - render: (_: unknown, plugin: Plugin, idx: number) => ( - - - - - ), - }, - ]; + const renderRows = () => { + if (loading) { + return ( + + + + + + ); + } + + if (plugins.length === 0) { + return ( + + + No data + + + ); + } + + return plugins.map((plugin, idx) => ( + + + {plugin.name} + + {plugin.display_name} + + + {plugin.url} + + + + {plugin.plugin_key ? ( + {"•".repeat(8)} + ) : ( + + )} + + +
+ + +
+
+
+ )); + }; return ( - Plugins - - Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in the - top-left of the sidebar. - - - Each plugin must expose GET /api/plugin-manifest returning nav items and capabilities. - - - - - + +

Plugins

+

+ Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in + the top-left of the sidebar. +

+

+ Each plugin must expose GET /api/plugin-manifest returning nav + items and capabilities. +

+
+ + + +
+ + + Name + Display Name + URL + Plugin Key + Actions + + + {renderRows()} +
+ !open && setModalOpen(false)}> diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 8d9fdd7598fe..ccd5500622be 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -15,7 +15,7 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; import { useZodForm } from "@/lib/forms/useZodForm"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; -import { Layout, Tabs } from "antd"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChevronDown, Plus, Users } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { z } from "zod/v4"; @@ -542,8 +542,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser return false; }; - const { Content } = Layout; - const tabItems = [ { key: "your-teams", @@ -611,7 +609,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( - +
{selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - } - title="Teams" - subtitle="Manage teams, members, and their access to models and budgets" - primaryAction={ - canCreateOrManageTeams(userRole, userID, organizations) ? ( - setIsTeamModalVisible(true)} data-testid="create-team-button"> - - Create Team - - ) : undefined - } - tabs={({ leadingControls }) => ( - - )} - /> + + } + title="Teams" + subtitle="Manage teams, members, and their access to models and budgets" + primaryAction={ + canCreateOrManageTeams(userRole, userID, organizations) ? ( + setIsTeamModalVisible(true)} data-testid="create-team-button"> + + Create Team + + ) : undefined + } + tabs={({ leadingControls }) => ( + + {leadingControls} + {tabItems.map((item) => ( + + {item.label} + + ))} + + )} + /> + {tabItems.map((item) => ( + + {item.children} + + ))} + )} {canCreateOrManageTeams(userRole, userID, organizations) && ( @@ -1203,7 +1218,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser
)} - + ); }; diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index b6dddf435881..83c9e5387325 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -5,7 +5,10 @@ import { all_admin_roles, isUserTeamAdminForAnyTeam } from "@/utils/roles"; import { modelCreationScope } from "@/utils/modelPermissions"; import { Switch } from "@/components/ui/switch"; import { Field, FieldLabel } from "@/components/shared/form/field"; -import { Select as AntdSelect, Card, Col, Row, Tooltip, Typography } from "antd"; +import { Card, CardContent } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; import { Info } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { Button } from "@/components/ui/button"; @@ -24,6 +27,7 @@ import type { Team } from "../key_team_helpers/key_list"; import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; import { Providers } from "../provider_info_helpers"; import { ProviderLogo } from "../molecules/models/ProviderLogo"; +import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox"; import AdvancedSettings from "./advanced_settings"; import ConditionalPublicModelName from "./conditional_public_model_name"; import LiteLLMModelNameField from "./litellm_model_name"; @@ -57,8 +61,6 @@ const connectionTestModelName = (values: MountedFormValues): string | undefined return typeof named === "string" ? named : undefined; }; -const { Title, Link } = Typography; - const AddModelForm: React.FC = ({ form, registry, @@ -117,6 +119,34 @@ const AddModelForm: React.FC = ({ return [...providerMetadata].sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name)); }, [providerMetadata]); + const providerOptions: SearchSelectOption[] = useMemo( + () => + sortedProviderMetadata.map((providerInfo) => ({ + label: providerInfo.provider_display_name, + value: providerInfo.provider, + icon: , + })), + [sortedProviderMetadata], + ); + + const credentialOptions: SearchSelectOption[] = useMemo( + () => [ + { label: "None", value: "" }, + ...credentials.map((credential) => ({ + label: credential.credential_name, + value: credential.credential_name, + })), + ], + [credentials], + ); + + const applyProviderSelection = (provider: Providers) => { + setSelectedProvider(provider); + setProviderModelsFn(provider); + form.setValue("model", []); + form.setValue("model_name", undefined); + }; + const providerMetadataErrorText = providerMetadataError ? providerMetadataError instanceof Error ? providerMetadataError.message @@ -132,311 +162,287 @@ const AddModelForm: React.FC = ({ return ( <> - Add Model +

Add Model

- - -
{ - event.preventDefault(); - void handleOk().then((submitted) => { - if (submitted) { - setTeamAdminSelectedTeam(null); - } - }); - }} - > - <> - {requiresTeamScope && ( - <> - - {(control) => ( - { - control.onChange(value); - setTeamAdminSelectedTeam(value); - }} - /> - )} - - {!teamAdminSelectedTeam && ( - - - Team Selection Required - - As a team admin, you need to select your team first before adding models. - - - )} - - )} - {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( - <> - - {(control) => ( - { - control.onChange(value); - setSelectedProvider(value as Providers); - setProviderModelsFn(value as Providers); - form.setValue("model", []); - form.setValue("model_name", undefined); - }} - > - {providerMetadataErrorText && sortedProviderMetadata.length === 0 && ( - - {providerMetadataErrorText} - - )} - {sortedProviderMetadata.map((providerInfo) => { - const displayName = providerInfo.provider_display_name; - const providerKey = providerInfo.provider; - - return ( - -
- - {displayName} -
-
- ); - })} -
- )} -
- - - {/* Conditionally Render "Public Model Name" */} - - - {/* Select Mode */} - - {(control) => ( - { - control.onChange(value); - setTestMode(value); - }} - options={TEST_MODES} - /> + + + + { + event.preventDefault(); + void handleOk().then((submitted) => { + if (submitted) { + setTeamAdminSelectedTeam(null); + } + }); + }} + > + <> + {requiresTeamScope && ( + <> + + {(control) => ( + { + control.onChange(value); + setTeamAdminSelectedTeam(value); + }} + /> + )} + + {!teamAdminSelectedTeam && ( + + + Team Selection Required + + As a team admin, you need to select your team first before adding models. + + )} - - - - -

- Optional - LiteLLM endpoint to use when health checking this model{" "} - - Learn more - -

- -
- - {/* Credentials */} -
- - Either select existing credentials OR enter new provider credentials below - -
+ + )} + {(isAdmin || (isTeamAdmin && teamAdminSelectedTeam)) && ( + <> + + {(control) => ( + { + control.onChange(value); + applyProviderSelection(value as Providers); + }} + /> + )} + + - - {(control) => ( - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - value={control.value as string | null | undefined} - onChange={control.onChange} - onBlur={control.onBlur} - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - )} - + {/* Conditionally Render "Public Model Name" */} + - {/* Only show provider specific fields if no credentials selected */} - {!selectedCredentialName && ( - <> -
-
- OR -
+ {/* Select Mode */} + + {(control) => ( + + )} + +
+
+
+

+ Optional - LiteLLM endpoint to use when health checking this model{" "} + + Learn more + +

- - - )} -
-
- Additional Model Info Settings -
-
- {/* Team-only Model Switch - Only show for proxy admins, not team admins */} - {(isAdmin || !isTeamAdmin) && ( - - - {labelWithHint( - "Team-BYOK Model", - "Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.", - )} - - - - { - setIsTeamOnly(checked); - if (!checked) { - form.setValue("team_id", undefined); - } - }} - disabled={!premiumUser} - aria-label="Team-BYOK Model" - /> - - - - )} +
+ + {/* Credentials */} +
+ + Either select existing credentials OR enter new provider credentials below + +
- {/* Conditional Team Selection */} - {isTeamOnly && !requiresTeamScope && ( {(control) => ( - control.onChange(value === "" ? null : value)} /> )} - )} - {isAdmin && ( - <> + + {/* Only show provider specific fields if no credentials selected */} + {!selectedCredentialName && ( + <> +
+
+ OR +
+
+ + + )} +
+
+ Additional Model Info Settings +
+
+ {/* Team-only Model Switch - Only show for proxy admins, not team admins */} + {(isAdmin || !isTeamAdmin) && ( + + + {labelWithHint( + "Team-BYOK Model", + "Only use this model + credential combination for this team. Useful when teams want to onboard their own OpenAI keys.", + )} + + + + { + setIsTeamOnly(checked); + if (!checked) { + form.setValue("team_id", undefined); + } + }} + disabled={!premiumUser} + aria-label="Team-BYOK Model" + /> + + + + )} + + {/* Conditional Team Selection */} + {isTeamOnly && !requiresTeamScope && ( {(control) => ( - ({ - value: group, - label: group, - }))} - maxTagCount="responsive" - allowClear + disabled={!premiumUser} /> )} - - )} - - - )} -
- - Need Help? - -
- - + )} + {isAdmin && ( + <> + + {(control) => ( + + )} + + + )} + + + )} +
+ + + Need Help? + + +
+ + +
-
- - - - + + + + + {/* Test Connection Results Modal */} diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 64d1519f915b..66564c3e007b 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useWatch } from "react-hook-form"; -import { Card } from "antd"; import { ChevronDown, ChevronRight, CircleHelp } from "lucide-react"; import { z } from "zod/v4"; import { FieldGroup } from "@/components/shared/form/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -483,205 +483,207 @@ const AddAutoRouterTab: React.FC = ({ return ( -
handleAutoRouterSubmit())} noValidate> - - - {({ ref, ...field }) => } - - -
- - - {modelsUnverifiable && ( -
- Could not load available models.{" "} - -
- )} -
- - {requiresTeamScope && ( + + handleAutoRouterSubmit())} noValidate> + - {({ id, value, onChange }) => } + {({ ref, ...field }) => } - )} - -
- +
+ )} +
+ + {requiresTeamScope && ( + - {!detailsExpanded && ( - - {tierConfigSummary(complexityRouterConfig.tiers)} + > + {({ id, value, onChange }) => } + + )} + +
+ + {detailsExpanded && ( +
+ +
)} - - {detailsExpanded && ( -
- -
+
+ + {isAdmin && ( + + {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( + + )} + )} -
- {isAdmin && ( - - {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - + + + Need Help? + + } /> - )} - - )} - -
- - Get help on our github + +
+ + + - - - - - + + + +
-
- - + + +
!open && setIsRoutingTestVisible(false)}> diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx index 7f52342ceb32..fbcd9c2fdccc 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.integration.test.tsx @@ -139,7 +139,7 @@ describe("CloudZeroExportModal", () => { await screen.findByLabelText("CloudZero API Key"); await user.click(screen.getByRole("combobox")); - await user.click(await screen.findByText("Export to CSV")); + await user.click(await screen.findByRole("option", { name: "Export to CSV" })); expect(screen.queryByLabelText("CloudZero API Key")).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Export CSV" })); diff --git a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx index c8ef22180936..81d62fc398e9 100644 --- a/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx +++ b/ui/litellm-dashboard/src/components/cloudzero_export_modal.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from "react"; -import { Spin, Select } from "antd"; import { CircleCheck, FileDown } from "lucide-react"; import { z } from "zod/v4"; import { getGlobalLitellmHeaderName } from "@/components/networking"; @@ -10,6 +9,7 @@ import { FieldGroup } from "@/components/shared/form/field"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -251,13 +251,18 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC {/* Export Type Selection */}

Export Destination

- value && setExportType(value)}> + + + + + {exportOptions.map((option) => ( + + {option.label} + + ))} + +
{/* CloudZero Configuration */} @@ -265,7 +270,7 @@ const CloudZeroExportModal: React.FC = ({ isOpen, onC
{settingsLoading ? (
- +
) : ( <> diff --git a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx index f21af04d5d58..61fb5a44c66f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/ByokCredentialModal.tsx @@ -1,13 +1,14 @@ "use client"; -import React, { useState } from "react"; -import { Input, Switch } from "antd"; +import React, { useId, useState } from "react"; import { toast } from "@/lib/toast"; import { fetchClient } from "@/lib/http/api"; import { ApiError } from "@/lib/http/client"; import { ArrowLeft, ArrowRight, Check, Key, Link2, Lock, X } from "lucide-react"; import { MCPServer } from "./types"; import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { PasswordInput } from "@/components/shared/PasswordInput"; +import { Switch } from "@/components/ui/switch"; const byokSaveErrorMessage = (e: unknown): string => { if (e instanceof ApiError) { @@ -29,6 +30,7 @@ export const ByokCredentialModal: React.FC = ({ server const [apiKey, setApiKey] = useState(""); const [saveKey, setSaveKey] = useState(true); const [loading, setLoading] = useState(false); + const apiKeyInputId = useId(); const serverDisplayName = server.alias || server.server_name || "Service"; const firstLetter = serverDisplayName.charAt(0).toUpperCase(); @@ -169,13 +171,15 @@ export const ByokCredentialModal: React.FC = ({ server

Enter your {serverDisplayName} API key to authorize this connection.

- - + {serverDisplayName} API Key + + setApiKey(e.target.value)} - size="large" - className="rounded-lg" + groupClassName="rounded-lg" /> {server.byok_api_key_help_url && ( = ({ server Save key for future use
- +
{/* Security note */} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index 0fba7c1cd9f8..4f25b2a201a8 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -1,5 +1,6 @@ import { Input } from "@/components/ui/input"; -import { Select as AntdSelect, Tooltip, Typography } from "antd"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { SimpleTooltip } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; @@ -19,7 +20,11 @@ import { Logo } from "@/components/molecules/logo/Logo"; import { resetCredentialFormOnProviderChange } from "./credential_form_helpers"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -const { Link } = Typography; +const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({ + label: providerDisplayName, + value: providerEnum, + icon: , +})); interface CredentialModalProps { open: boolean; @@ -122,34 +127,27 @@ export default function CredentialModal({ className="mb-4" > {(control) => ( - { + { control.onChange(value); resetCredentialFormOnProviderChange(formAdapter, value as Providers, setSelectedProvider); }} - > - {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( - -
- - {providerDisplayName} -
-
- ))} -
+ /> )}
- - Need Help? - + + + Need Help? + +
+ + + Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"} + +
+
+ + setDeletingGroup(g)} + proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""} /> - - - - - Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"} - - - - - setDeletingGroup(g)} - proxyBaseUrl={proxySettings.LITELLM_UI_API_DOC_BASE_URL?.trim() || proxySettings.PROXY_BASE_URL || ""} - /> +
{ Delete routing group? - - Models in {deletingGroup?.group_name} will fall back to the proxy's top-level - routing strategy. This cannot be undone. - +

+ Models in {deletingGroup?.group_name} will fall back to the + proxy's top-level routing strategy. This cannot be undone. +

); }; diff --git a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx index 4db8c83ba9dd..1198e7510ed2 100644 --- a/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx +++ b/ui/litellm-dashboard/src/components/update_model_credentials_modal.tsx @@ -1,4 +1,3 @@ -import { Typography } from "antd"; import { TriangleAlert } from "lucide-react"; import { useState } from "react"; import { z } from "zod/v4"; @@ -13,8 +12,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; -const { Text } = Typography; - const updateCredentialsSchema = z.object({ api_key: z.string().min(1, "Enter a new API key"), }); @@ -77,10 +74,10 @@ export default function UpdateModelCredentialsModal({ Update API Key - + Update this model's API key. Only the new key is sent; the rest of the deployment configuration is left untouched. - + From 3dc475c886821d03277ffdb0270990da94223a1a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 19:51:10 -0700 Subject: [PATCH 2/2] test(e2e): replace antd class selectors with role and slot queries The dashboard no longer renders antd, so every `.ant-*` locator in the Playwright suite matched nothing. Most were already dead before this branch, broken by the earlier shadcn ports; the models multi-select in the key specs and the provider picker in addModel are the ones this branch broke. Queries now go through roles, labels and test ids. Select popups portal to the body, so option lookups are page-scoped rather than scoped to the dialog that opened them. Two locators fall back to `data-slot` because the elements have no distinguishing accessible name. --- tests/e2e/ui/helpers/mcp.ts | 18 +++--- tests/e2e/ui/tests/mcp/mcpServers.spec.ts | 27 ++++---- .../e2e/ui/tests/modelsPage/addModel.spec.ts | 61 +++++++++---------- .../ui/tests/modelsPage/credentials.spec.ts | 2 +- tests/e2e/ui/tests/proxy-admin/keys.spec.ts | 27 +++----- tests/e2e/ui/tests/proxy-admin/teams.spec.ts | 13 ++-- .../ui/tests/settings/routerSettings.spec.ts | 26 ++++---- .../e2e/ui/tests/team-admin/teamAdmin.spec.ts | 9 +-- tests/e2e/ui/tests/usage/usagePage.spec.ts | 3 +- tests/e2e/ui/tests/users/searchUsers.spec.ts | 2 +- .../ui/tests/users/viewInternalUsers.spec.ts | 2 +- 11 files changed, 87 insertions(+), 103 deletions(-) diff --git a/tests/e2e/ui/helpers/mcp.ts b/tests/e2e/ui/helpers/mcp.ts index b41aec59dede..554177e11bc0 100644 --- a/tests/e2e/ui/helpers/mcp.ts +++ b/tests/e2e/ui/helpers/mcp.ts @@ -12,23 +12,21 @@ export async function createMcpServer(page: PwPage, url: string): Promise { await expect(discovery).toBeVisible({ timeout: 5_000 }); await discovery.getByRole("button", { name: /Custom Server/i }).click(); - const formModal = page.locator(".ant-modal:visible").filter({ hasText: "MCP Server Name" }); + const formModal = page.getByRole("dialog").filter({ hasText: "MCP Server Name" }); await expect(formModal).toBeVisible({ timeout: 5_000 }); // Name — no spaces or hyphens per validateMCPServerName const uniqueName = `e2e_mcp_${Date.now()}`; createdServerName = uniqueName; - await formModal.locator('input[id="server_name"]').fill(uniqueName); + await formModal.getByLabel("MCP Server Name").fill(uniqueName); - // Transport: Streamable HTTP — the only value the proxy actually accepts is "http" - const transportField = formModal.locator(".ant-form-item", { hasText: "Transport Type" }); - await transportField.locator(".ant-select").click(); - await page.locator(".ant-select-dropdown:visible").getByText("Streamable HTTP").click(); + // Transport: Streamable HTTP — the only value the proxy actually accepts is "http". + // Select popups are portaled to the body, so the option lookup is page-scoped. + await formModal.getByRole("combobox", { name: "Transport Type" }).click(); + await page.getByRole("option", { name: "Streamable HTTP" }).click(); // URL — use a fake URL; the form just persists it, it doesn't have to be reachable - await formModal.locator('input[id="url"]').fill("https://e2e-fake-mcp.test.local/mcp"); + await formModal.getByLabel("MCP Server URL").fill("https://e2e-fake-mcp.test.local/mcp"); - // Authentication: None - // The auth_type Form.Item has no label prop (CreateMCPServer.tsx), so - // it can't be anchored by label text. Scope via the enclosing Collapse - // panel ("Authentication") instead — that anchor is stable even if the - // placeholder copy changes. - const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ }); - const authField = authSection.locator(".ant-form-item").first(); - await authField.locator(".ant-select").click(); - await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click(); + // Authentication: None. "Authentication" is exact so it can't also match the + // "Authentication Value" field that some auth types reveal below it. + await formModal.getByRole("combobox", { name: "Authentication", exact: true }).click(); + await page.getByRole("option", { name: "None", exact: true }).click(); // Submit await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click(); diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index bd5373e1569a..dad716b4c83c 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -21,15 +21,22 @@ async function findDeploymentByName(page: PlaywrightPage, modelName: string): Pr return body.data.find((row) => row.model_name === modelName); } +/** Anchors a substring match to the whole string, escaping regex metacharacters. */ +const exactly = (text: string): RegExp => new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`); + /** - * Helper to select a provider from the Add Model form dropdown. + * Helper to select a provider from the Add Model form dropdown. The field is a + * searchable combobox: it only opens on click, typing filters the list, and the + * option has to be picked explicitly because nothing is highlighted by default. + * Options are matched on their visible text, not their accessible name, which + * also carries the provider logo's alt text ("Anthropic logo Anthropic"). */ -async function selectProvider(page: any, providerName: string) { - const providerDropdown = page.getByRole("combobox", { name: /Provider/i }); +async function selectProvider(page: PlaywrightPage, providerName: string) { + const providerDropdown = page.getByRole("combobox", { name: "Provider", exact: true }); + await providerDropdown.click(); await providerDropdown.fill(providerName); - await page.waitForTimeout(1000); - await providerDropdown.press("Enter"); - await page.waitForTimeout(2000); + await page.getByRole("option").filter({ hasText: exactly(providerName) }).click(); + await expect(providerDropdown).toHaveValue(providerName); } test.describe("Add Model", () => { @@ -64,11 +71,10 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // The model field should be a multi-select dropdown; click to open it - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); // Verify provider-specific models are listed - await expect(page.getByTitle("claude-haiku-4-5", { exact: true })).toBeVisible(); + await expect(page.getByRole("option", { name: "claude-haiku-4-5", exact: true })).toBeVisible(); }); test("Edit team model TPM and RPM limits", async ({ page }) => { @@ -156,14 +162,14 @@ test.describe("Add Model", () => { await page.getByRole("tab", { name: "Add Model" }).click(); // Labels come from /public/providers/fields, not the frontend Providers enum, and the two differ. - await selectProvider(page, "OpenAI-Compatible Endpoints"); + await selectProvider(page, "OpenAI-Compatible Endpoints (Together AI, etc.)"); const publicName = `e2e-ui-added-${Date.now()}`; uiAddedModelName = publicName; // The model picker's "custom" entry reveals the free-text name field. - await page.locator(".ant-select-selection-overflow").first().click(); - await page.locator(".ant-select-dropdown:visible").getByText("Custom Model Name (Enter below)").click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "Custom Model Name (Enter below)" }).click(); await page.keyboard.press("Escape"); await page.getByPlaceholder("Enter custom model name").fill(publicName); @@ -177,8 +183,8 @@ test.describe("Add Model", () => { await expect(page.getByTestId("connection-success-msg")).toBeVisible({ timeout: 30_000 }); // The modal swallows the Add click. Scope to the footer: the dismiss X is also named "Close". - const resultsModal = page.locator(".ant-modal:visible").filter({ hasText: "Connection Test Results" }); - await resultsModal.locator(".ant-modal-footer").getByRole("button", { name: "Close" }).click(); + const resultsModal = page.getByRole("dialog", { name: "Connection Test Results" }); + await resultsModal.locator('[data-slot="dialog-footer"]').getByRole("button", { name: "Close" }).click(); await expect(resultsModal).toBeHidden({ timeout: 5_000 }); const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => { @@ -213,9 +219,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - await page.getByTitle("claude-haiku-4-5", { exact: true }).click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "claude-haiku-4-5", exact: true }).click(); await page.keyboard.press("Escape"); // Enter bad API key @@ -239,9 +244,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Anthropic"); // Select model: claude-haiku-4-5 - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - await page.getByTitle("claude-haiku-4-5", { exact: true }).click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "claude-haiku-4-5", exact: true }).click(); await page.keyboard.press("Escape"); // Enter any API key @@ -315,18 +319,15 @@ test.describe("Add Model", () => { await selectProvider(page, "Cohere"); - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - const wildcardOption = page.getByTitle(/All .* Models \(Wildcard\)/); - await wildcardOption.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: /All .* Models \(Wildcard\)/ }).click(); await page.keyboard.press("Escape"); const apiKeyInput = page.locator('input[type="password"]').first(); await apiKeyInput.fill("sk-any-key-for-team-byok-test"); - // Flip the Team-BYOK switch on (Form.Item label "Team-BYOK Model") - const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" }); - await teamByokRow.getByRole("switch").click(); + // Flip the Team-BYOK switch on; the Switch carries its own aria-label. + await page.getByRole("switch", { name: "Team-BYOK Model" }).click(); // TeamDropdown options show the alias above the team id, so match on the id line by text. const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox"); @@ -376,10 +377,8 @@ test.describe("Add Model", () => { await selectProvider(page, "Cohere"); // Select All Cohere Models (Wildcard) - const modelDropdown = page.locator(".ant-select-selection-overflow").first(); - await modelDropdown.click(); - const wildcardOption = page.getByTitle(/All .* Models \(Wildcard\)/); - await wildcardOption.click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: /All .* Models \(Wildcard\)/ }).click(); await page.keyboard.press("Escape"); // Enter any API key diff --git a/tests/e2e/ui/tests/modelsPage/credentials.spec.ts b/tests/e2e/ui/tests/modelsPage/credentials.spec.ts index 7c8360685674..ceedc959ccc6 100644 --- a/tests/e2e/ui/tests/modelsPage/credentials.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/credentials.spec.ts @@ -41,7 +41,7 @@ test.describe("Edit LLM credential", () => { await row.getByTestId(`credential-actions-${credentialName}`).click(); await page.getByTestId("credential-action-edit").click(); - const modal = page.locator(".ant-modal-content").filter({ hasText: "Edit Credential" }); + const modal = page.getByRole("dialog", { name: "Edit Credential" }); await expect(modal).toBeVisible({ timeout: 10_000 }); const apiKeyField = modal.locator("#api_key"); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index 004fedb32634..0c38641dcc7a 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -45,9 +45,9 @@ test.describe("Proxy Admin - Keys", () => { await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); - // Select models - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); + // Select models — the popup is portaled to the body, so scope options to the page. + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Team Models", exact: true }).click(); await page.keyboard.press("Escape"); // Submit @@ -86,7 +86,7 @@ test.describe("Proxy Admin - Keys", () => { // Scope to the modal — the Regenerate button has an icon whose aria-label // ("sync") is concatenated into the button's accessible name, and the // "Regenerate Key" button is still in the DOM behind the modal. - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Regenerate Virtual Key" }); await modal.getByRole("button", { name: /Regenerate/ }).click(); // Success view shows a Copy button in the footer (text varies between modal versions) @@ -198,8 +198,8 @@ test.describe("Proxy Admin - Keys", () => { // Select models — open the multi-select and pick the all-models meta-option. // With no team selected the modal offers "All Proxy Models"; the team-scoped // "All Team Models" option only appears once a team is picked. - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click(); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Proxy Models", exact: true }).click(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Create Key", exact: true }).click(); @@ -221,17 +221,10 @@ test.describe("Proxy Admin - Keys", () => { const keyName = `e2e-admin-specific-${Date.now()}`; await page.getByLabel(/Key Name/).fill(keyName); - // Open the model multi-select and pick a single specific model. Use - // getByRole("option", ...) to avoid the strict-mode collision between - // the option container and its inner text node. + // Open the model multi-select and pick a single specific model. const modelName = "fake-openai-gpt-4"; - await page.locator(".ant-select-selection-overflow").click(); - const option = page.locator(".ant-select-dropdown:visible").getByRole("option", { name: modelName, exact: true }); - await option.waitFor({ state: "attached" }); - // Dispatch the click via the DOM — antd's dropdown can render the option - // off-viewport during the open animation, which trips Playwright's - // visibility/stability checks. The click handler fires regardless. - await option.evaluate((el: HTMLElement) => el.click()); + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: modelName, exact: true }).click(); await page.keyboard.press("Escape"); await page.getByRole("button", { name: "Create Key", exact: true }).click(); @@ -242,7 +235,7 @@ test.describe("Proxy Admin - Keys", () => { // verify it can call /chat/completions for the model it was scoped to. // The mock LLM server (fixtures/mock_llm_server/server.py) replies with // a fixed "This is a mock response." body. - const apiKey = (await page.locator(".ant-modal:visible pre").innerText()).trim(); + const apiKey = (await page.getByRole("dialog", { name: "Save your Key" }).locator("pre").innerText()).trim(); expect(apiKey).toMatch(/^sk-/); const response = await page.request.post("/chat/completions", { diff --git a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts index 172fde173df4..7383b4521626 100644 --- a/tests/e2e/ui/tests/proxy-admin/teams.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/teams.spec.ts @@ -41,11 +41,12 @@ test.describe("Proxy Admin - Teams", () => { .click(); // Wait for the Create Team modal - const dialog = page.locator(".ant-modal:visible"); + const dialog = page.getByRole("dialog", { name: "Create Team" }); await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Fill Team Name — the input has id="team_alias" - await dialog.locator("#team_alias").fill(uniqueAlias); + // Fill Team Name — FormField derives the control id from React.useId(), so + // the input is only addressable by its label or its test id. + await dialog.getByTestId("team-name-input").fill(uniqueAlias); // Select models — the models multi-select is inside the modal. Its popup is // portaled to the body, so scope the option lookup to the page, not the dialog. @@ -75,7 +76,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByRole("button", { name: /Add Member/i }).click(); // Wait for Add Team Member modal - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Add Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); // The email field is a Select — type to search, then select from dropdown @@ -112,7 +113,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByTestId("edit-member").first().click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Edit Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.getByRole("button", { name: /Save Changes/i }).click(); @@ -155,7 +156,7 @@ test.describe("Proxy Admin - Teams", () => { await page.getByTestId("edit-member").first().click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Edit Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); await modal.getByRole("button", { name: /Save Changes/i }).click(); diff --git a/tests/e2e/ui/tests/settings/routerSettings.spec.ts b/tests/e2e/ui/tests/settings/routerSettings.spec.ts index 9784abff040e..1188e8f201ea 100644 --- a/tests/e2e/ui/tests/settings/routerSettings.spec.ts +++ b/tests/e2e/ui/tests/settings/routerSettings.spec.ts @@ -67,29 +67,25 @@ test.describe("Router Settings - Fallbacks", () => { await page.getByRole("button", { name: /Add Fallbacks/i }).click(); await modelsLoaded; - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Configure Model Fallbacks" }); await expect(modal).toBeVisible({ timeout: 5_000 }); - // FallbackGroupConfig.tsx renders both selects with `showSearch`. The - // most stable interaction is: click to open + focus, type the model name to - // narrow the listbox to a single highlighted option, then press Enter. - // Verify each selection landed by watching the dialog's own state transition - // (the tab title updates to the picked primary; the fallback chain list - // populates) rather than by asserting on the dropdown popup, which sits in - // a custom getPopupContainer and is awkward to scope reliably. - const primarySelect = modal.locator(".ant-select").filter({ hasText: "Select primary model" }); - await primarySelect.click(); + // FallbackGroupConfig.tsx renders both fields as searchable comboboxes: they + // open on click, typing filters the listbox, and the option has to be picked + // explicitly. Verify each selection landed by watching the dialog's own state + // transition (the tab title updates to the picked primary; the fallback chain + // list populates) rather than by asserting on the popup, which is portaled + // out of the dialog. + await modal.getByRole("combobox", { name: /Primary Model/ }).click(); await page.keyboard.type(PRIMARY); - await page.keyboard.press("Enter"); + await page.getByRole("option", { name: PRIMARY, exact: true }).click(); await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({ timeout: 10_000, }); - const fallbackSelect = modal.locator(".ant-select").filter({ hasText: "Select fallback models" }); - await fallbackSelect.click(); + await modal.getByRole("combobox", { name: /Select fallback models/ }).click(); await page.keyboard.type(FALLBACK); - await page.keyboard.press("Enter"); - await page.keyboard.press("Escape"); + await page.getByRole("option", { name: FALLBACK, exact: true }).click(); // The Fallback Chain helper text reads "(N/10 used)"; once it ticks to 1 the // selection has been recorded. await expect(modal.getByText("(1/10 used)")).toBeVisible({ diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index 0ef74e71529a..f93cca753479 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -61,7 +61,7 @@ test.describe("Team Admin", () => { await page.getByRole("tab", { name: "Members" }).click(); await page.getByRole("button", { name: /Add Member/i }).click(); - const modal = page.locator(".ant-modal:visible"); + const modal = page.getByRole("dialog", { name: "Add Team Member" }); await expect(modal).toBeVisible({ timeout: 5_000 }); // Use a dedicated invitee user so this doesn't race with the proxy-admin @@ -144,9 +144,10 @@ test.describe("Team Admin", () => { await page.keyboard.type(E2E_TEAM_CRUD_ALIAS); await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click(); - // Models — pick "All Team Models" - await page.locator(".ant-select-selection-overflow").click(); - await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click(); + // Models — pick "All Team Models". The popup is portaled to the body, so + // scope the option lookup to the page. + await page.getByRole("combobox", { name: "Select models" }).click(); + await page.getByRole("option", { name: "All Team Models", exact: true }).click(); await page.keyboard.press("Escape"); const generate = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/generate" }, async () => { diff --git a/tests/e2e/ui/tests/usage/usagePage.spec.ts b/tests/e2e/ui/tests/usage/usagePage.spec.ts index 6031aa540550..8fa59beb905c 100644 --- a/tests/e2e/ui/tests/usage/usagePage.spec.ts +++ b/tests/e2e/ui/tests/usage/usagePage.spec.ts @@ -22,7 +22,8 @@ async function openUsage(page: PlaywrightPage): Promise { const card = topKeysCard(page); await expect(card).toBeVisible({ timeout: 30_000 }); // Widen past the default top-5 so other keys in the database cannot crowd this one out. - await card.locator(".ant-segmented-item").filter({ hasText: /^50$/ }).click(); + // The radio itself is sr-only and its label covers it, so click the label. + await card.getByRole("radiogroup", { name: "Number of top keys to show" }).getByText("50", { exact: true }).click(); return card; } diff --git a/tests/e2e/ui/tests/users/searchUsers.spec.ts b/tests/e2e/ui/tests/users/searchUsers.spec.ts index a9b0e329a2bc..e87218b5a5e7 100644 --- a/tests/e2e/ui/tests/users/searchUsers.spec.ts +++ b/tests/e2e/ui/tests/users/searchUsers.spec.ts @@ -11,7 +11,7 @@ test.skip("Internal Users Search", () => { await tab.click(); await expect(page.locator("tbody tr").first()).toBeVisible(); - await expect(page.locator(".ant-skeleton")).toHaveCount(0); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0); } test("can search users by email", async ({ page }) => { diff --git a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts index ea61c238c025..614191372d06 100644 --- a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts +++ b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts @@ -13,7 +13,7 @@ test.skip("Internal Users Page", () => { const firstRow = page.locator("tbody tr").first(); await expect(firstRow).toBeVisible(); - await expect(page.locator(".ant-skeleton")).toHaveCount(0); + await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0); } test("renders internal users table correctly", async ({ page }) => {