From 0cb48cf23c8ec7d9951a56f1eb3e84ebaba2f4be Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 01:20:57 -0700 Subject: [PATCH 01/13] refactor(ui): migrate Navbar off antd to shadcn Replaces Ant Design with the in-repo shadcn layer across every Navbar component, removing the last antd imports from src/components/Navbar. - CommunityEngagementButtons, NotificationsBell, ViewSwitcher, BlogDropdown, WorkerDropdown and UserDropdown now compose @/components/ui primitives - antd icons render at 1em while lucide defaults to 24px, so every icon carries an explicit size class matching what it replaced - UserDropdown uses Popover rather than DropdownMenu: its panel holds switches and badges, and form controls inside role="menu" are invalid - WorkerDropdown moves to Combobox since shadcn Select has no search - drops the nine no-restricted-imports suppressions these files no longer need --- ui/litellm-dashboard/eslint-suppressions.json | 28 --- .../Navbar/BlogDropdown/BlogDropdown.test.tsx | 23 +- .../Navbar/BlogDropdown/BlogDropdown.tsx | 119 +++++----- .../CommunityEngagementButtons.tsx | 60 +++-- .../NotificationsBell/NotificationsBell.tsx | 41 ++-- .../Navbar/UserDropdown/UserDropdown.tsx | 221 +++++++++--------- .../src/components/Navbar/ViewSwitcher.tsx | 75 +++--- .../WorkerDropdown/WorkerDropdown.test.tsx | 175 ++++++++------ .../Navbar/WorkerDropdown/WorkerDropdown.tsx | 63 +++-- 9 files changed, 459 insertions(+), 346 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..02982f677bf 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1855,39 +1855,11 @@ "count": 12 } }, - "src/components/Navbar/BlogDropdown/BlogDropdown.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/Navbar/NotificationsBell/NotificationsBell.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Navbar/UserDropdown/UserDropdown.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/Navbar/ViewSwitcher.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/SCIM.tsx": { "no-restricted-imports": { "count": 2 diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index 8871780ae96..bad740fa361 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -66,6 +66,27 @@ describe("BlogDropdown", () => { expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); + it("should not render menu content before the trigger is hovered", () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } }; + renderWithProviders(); + + expect(screen.queryByRole("link", { name: /view all posts/i })).not.toBeInTheDocument(); + expect(screen.queryByText("Post One")).not.toBeInTheDocument(); + }); + + it("should open the menu on hover", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } }; + renderWithProviders(); + + expect(screen.queryByText("Post One")).not.toBeInTheDocument(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + }); + }); + describe("loading state", () => { it("should show a loading spinner", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; @@ -74,7 +95,7 @@ describe("BlogDropdown", () => { await openDropdown(); await waitFor(() => { - expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index be659967a0f..5f6b5eacc0f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -1,13 +1,17 @@ import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; -import { DownOutlined, LoadingOutlined } from "@ant-design/icons"; -import { Button, Dropdown, Space, Typography } from "antd"; -import type { MenuProps } from "antd"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDown, LoaderCircle } from "lucide-react"; import React from "react"; -const { Text, Title, Paragraph } = Typography; - function formatDate(dateStr: string): string { const date = new Date(dateStr + "T00:00:00"); return date.toLocaleDateString("en-US", { @@ -26,63 +30,70 @@ export const BlogDropdown: React.FC = () => { return null; } - let items: MenuProps["items"]; + const renderMenuContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } - if (isLoading) { - items = [{ key: "loading", label: , disabled: true }]; - } else if (isError) { - items = [ - { - key: "error", - label: ( - - Failed to load posts - - - ), - disabled: true, - }, - ]; - } else if (!data || data.posts.length === 0) { - items = [{ key: "empty", label: No posts available, disabled: true }]; - } else { - items = [ - ...data.posts.slice(0, 5).map((post: BlogPost) => ({ - key: post.url, - label: ( - - - {post.title} - - - {formatDate(post.date)} - - {post.description} - - ), - })), - { type: "divider" as const }, - { - key: "view-all", - label: ( + if (isError) { + return ( +
+ Failed to load posts + +
+ ); + } + + if (!data || data.posts.length === 0) { + return
No posts available
; + } + + return ( + <> + {data.posts.slice(0, 5).map((post: BlogPost) => ( + + +
+ {post.title} +
+ + {formatDate(post.date)} + +

{post.description}

+
+
+ ))} + + View all posts - ), - }, - ]; - } + + + ); + }; // Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment. return ( - - - + + + + {renderMenuContent()} + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx index f6a43196a32..8ec31d74cd6 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx @@ -1,6 +1,6 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; -import { GithubOutlined, SlackOutlined } from "@ant-design/icons"; -import { Tooltip } from "antd"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Github, Slack } from "lucide-react"; import React from "react"; const iconBtnClass = @@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => { className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0" aria-label="Community links" > - - - - - - - - - - + + + + } + > + + + LiteLLM Slack community + + + + } + > + + + LiteLLM on GitHub + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index a3d5db4afd7..f3adcf6d8be 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -5,8 +5,11 @@ import { useHideAutoRouterAnnouncement, } from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement"; import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils"; -import { BellOutlined } from "@ant-design/icons"; -import { Badge, Button, Popover, Typography } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/cva.config"; +import { Bell } from "lucide-react"; import React, { useState } from "react"; export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing"; @@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => { const content = (
- - LiteLLM Auto Router - - + LiteLLM Auto Router + Route every request to the cheapest model that can handle it, no prompt changes needed. - +
- + {hasUnread ? ( - ) : null} @@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => { ); return ( - - + + + {hasUnread ? : null} + + + {content} ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 28e981c57a1..1d92ef246cd 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -9,23 +9,17 @@ import { setLocalStorageItem, } from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; -import { - CrownOutlined, - DownOutlined, - LogoutOutlined, - MailOutlined, - SafetyOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; -import { ChevronsUpDown } from "lucide-react"; +import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyButton from "@/components/shared/CopyButton"; import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; -const { Text } = Typography; - function hueFromString(seed: string): number { let h = 0; for (let i = 0; i < seed.length; i += 1) { @@ -80,60 +74,57 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar setDisableShowNewBadge(storedValue === "true"); }, []); - const userItems: MenuProps["items"] = [ - { - key: "logout", - label: ( - - - Logout - - ), - onClick: onLogout, - }, - ]; - const renderUserInfoSection = () => ( - - - - - {userEmail || "-"} - +
+
+
+ + {userEmail || "-"} +
{premiumUser ? ( - } color="gold"> + + Premium - + ) : ( - - }>Standard - + + + }> + + Standard + + Upgrade to Premium for advanced features + + )} - - - - - - User ID - - - {userId || "-"} - - - - - - Role - - {userRole} - - - - Hide New Feature Indicators +
+ +
+
+ + User ID +
+
+ + {userId || "-"} + + +
+
+
+
+ + Role +
+ {userRole} +
+ +
+ Hide New Feature Indicators { + onCheckedChange={(checked) => { setDisableShowNewBadge(checked); if (checked) { setLocalStorageItem("disableShowNewBadge", "true"); @@ -145,13 +136,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide new feature indicators" /> - - - Hide All Prompts +
+
+ Hide All Prompts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableShowPrompts", "true"); emitLocalStorageChange("disableShowPrompts"); @@ -162,13 +153,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide all prompts" /> - - - Hide Blog Posts +
+
+ Hide Blog Posts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBlogPosts", "true"); emitLocalStorageChange("disableBlogPosts"); @@ -179,13 +170,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide blog posts" /> - - - Hide Bouncing Icon +
+
+ Hide Bouncing Icon { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBouncingIcon", "true"); emitLocalStorageChange("disableBouncingIcon"); @@ -196,8 +187,8 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide bouncing icon" /> - - +
+
); const seed = userEmail || userId || "user"; @@ -206,30 +197,21 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar const displayName = navAccountDisplayName(userEmail, userId); return ( - ( -
- {renderUserInfoSection()} - - {React.cloneElement(menu as React.ReactElement, { - style: { boxShadow: "none" }, - })} -
- )} - > + {variant === "sidebar" ? ( - + ) : ( - + + )} -
+ + {renderUserInfoSection()} + + + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 09a4538ae18..f1dec137305 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,9 +1,12 @@ import React from "react"; import { usePathname } from "next/navigation"; -import { Dropdown } from "antd"; -import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons"; -import { ChevronsUpDown } from "lucide-react"; -import type { MenuProps } from "antd"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { migratedHref } from "@/utils/migratedPages"; @@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; +interface ViewSwitcherItem { + key: string; + label: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +} + export default function ViewSwitcher() { const { mode, setMode, plugins } = usePluginMode(); const { data: uiSettings } = useUISettings(); @@ -29,15 +39,25 @@ export default function ViewSwitcher() { ...plugins.map((p) => ({ key: p.name, label: p.display_name })), ]; - const chatItem = chatEnabled + const selectMode = (key: string) => { + setMode(key); + // The chat route lives outside the dashboard SPA shell that reacts to `mode`, + // so switching modes from there needs a real navigation, not just state. + if (isChatRoute) { + window.location.assign(migratedHref("")); + } + }; + + const chatItem: ViewSwitcherItem = chatEnabled ? { key: CHAT, label: (
Chat - {isChatRoute && } + {isChatRoute && }
), + onClick: () => window.location.assign(migratedHref(CHAT)), } : { key: CHAT, @@ -52,44 +72,43 @@ export default function ViewSwitcher() { ), }; - const items: MenuProps["items"] = [ + const items: ViewSwitcherItem[] = [ ...modeEntries.map((e) => ({ key: e.key, label: (
{e.label} - {!isChatRoute && e.key === mode && } + {!isChatRoute && e.key === mode && }
), + onClick: () => selectMode(e.key), })), chatItem, ]; - const onClick: MenuProps["onClick"] = ({ key }) => { - if (key === CHAT) { - window.location.assign(migratedHref(CHAT)); - return; - } - setMode(key); - // The chat route lives outside the dashboard SPA shell that reacts to `mode`, - // so switching modes from there needs a real navigation, not just state. - if (isChatRoute) { - window.location.assign(migratedHref("")); - } - }; - return ( - - - + + + {items.map((item) => ( + + {item.label} + + ))} + + ); } diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx index a51d6ba055d..0930270eb4b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -1,32 +1,21 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi, beforeEach } from "vitest"; -// Mock the useWorker hook const mockUseWorker = vi.fn(); vi.mock("@/hooks/useWorker", () => ({ useWorker: () => mockUseWorker(), })); -// Mock antd Select -vi.mock("antd", () => ({ - Select: ({ value, options, onChange, style, disabled, ...props }: any) => ( - - ), -})); - -// Mock icon -vi.mock("@ant-design/icons", () => ({ - CloudServerOutlined: () => , -})); - import WorkerDropdown from "./WorkerDropdown"; +async function openWorkerList(user: ReturnType) { + await user.click(screen.getByRole("combobox")); + await waitFor(() => { + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true"); + }); +} + describe("WorkerDropdown", () => { const mockOnWorkerSwitch = vi.fn(); const workers = [ @@ -61,31 +50,7 @@ describe("WorkerDropdown", () => { expect(container).toBeEmptyDOMElement(); }); - it("renders the select when isControlPlane and selectedWorker exist", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByTestId("worker-select")).toBeInTheDocument(); - }); - - it("renders all worker options", () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - expect(screen.getByText("Worker 1")).toBeInTheDocument(); - expect(screen.getByText("Worker 2")).toBeInTheDocument(); - expect(screen.getByText("Worker 3")).toBeInTheDocument(); - }); - - it("sets current worker as selected value", () => { + it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => { mockUseWorker.mockReturnValue({ isControlPlane: true, selectedWorker: workers[1], @@ -93,37 +58,109 @@ describe("WorkerDropdown", () => { }); render(); - const select = screen.getByTestId("worker-select") as HTMLSelectElement; - expect(select.value).toBe("w2"); + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); }); - it("disables the currently selected worker in options", () => { + it("reveals every worker only once the combobox is opened", async () => { mockUseWorker.mockReturnValue({ isControlPlane: true, - selectedWorker: workers[0], + selectedWorker: workers[1], workers, }); - - render(); - const options = screen.getAllByRole("option"); - const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1"); - expect(selectedOption).toBeDisabled(); - }); - - it("calls onWorkerSwitch when selection changes", async () => { - mockUseWorker.mockReturnValue({ - isControlPlane: true, - selectedWorker: workers[0], - workers, - }); - - render(); - const select = screen.getByTestId("worker-select"); - - const { default: userEvent } = await import("@testing-library/user-event"); const user = userEvent.setup(); - await user.selectOptions(select, "w2"); - expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2"); + render(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Worker 3")).not.toBeInTheDocument(); + + await openWorkerList(user); + + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + it("marks exactly one option as selected, the current worker", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + + await waitFor(() => { + const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true"); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName("Worker 2"); + }); + }); + + it("calls onWorkerSwitch with the id of the worker that was picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Worker 3")); + + expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3"); + }); + + it("does not call onWorkerSwitch when the already-current worker is picked", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 3")).toBeInTheDocument(); + }); + + for (const currentWorkerNode of screen.getAllByText("Worker 2")) { + fireEvent.click(currentWorkerNode); + } + + expect(mockOnWorkerSwitch).not.toHaveBeenCalled(); + }); + + it("filters the worker options by the typed search text", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await openWorkerList(user); + await waitFor(() => { + expect(screen.getByText("Worker 1")).toBeInTheDocument(); + }); + + await user.clear(screen.getByRole("combobox")); + await user.type(screen.getByRole("combobox"), "worker 3"); + + await waitFor(() => { + expect(screen.queryByText("Worker 1")).not.toBeInTheDocument(); + }); + expect(screen.getByText("Worker 3")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx index 186cc611117..432bab8c9ef 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx @@ -1,35 +1,66 @@ "use client"; import React from "react"; -import { Select } from "antd"; -import { CloudServerOutlined } from "@ant-design/icons"; +import { Server } from "lucide-react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { InputGroupAddon } from "@/components/ui/input-group"; import { useWorker } from "@/hooks/useWorker"; interface WorkerDropdownProps { onWorkerSwitch: (workerId: string) => void; } +interface WorkerOption { + label: string; + value: string; + disabled: boolean; +} + const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { const { isControlPlane, selectedWorker, workers } = useWorker(); if (!isControlPlane || !selectedWorker) return null; + const options: WorkerOption[] = workers.map((w) => ({ + label: w.name, + value: w.worker_id, + disabled: w.worker_id === selectedWorker.worker_id, + })); + return ( - setDomainFilter(val)} - style={{ width: 160 }} - options={domains.map((d) => ({ label: d, value: d }))} - /> - } - placeholder="Search by name, namespace, or tag…" - value={search} - onChange={(e) => setSearch(e.target.value)} - style={{ width: 280 }} - allowClear - /> + items={domainItems} + value={domainFilter ?? ALL_DOMAINS} + onValueChange={(val) => setDomainFilter(val === null || val === ALL_DOMAINS ? undefined : val)} + > + + + + + {domainItems.map((item) => ( + + {item.label} + + ))} + + + + + + + setSearch(e.target.value)} + /> + {search !== "" && ( + + setSearch("")} + > + + + + )} +
= ({ accessTok }; return ( - +
setIsExpanded(!isExpanded)}>
- Link Management +

Link Management

Manage the links that are displayed under 'Useful Links' on the public model hub.

@@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok {isExpanded && (
- Add New Link +

Add New Link

@@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC = ({ accessTok
- Manage Existing Links +

Manage Existing Links

= ({ accessTok
- + - Display Name - URL - Actions + Display Name + URL + Actions - + {links.map((link, index) => ( diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx index 67c6d7d6cc9..d72cfcbc037 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.test.tsx @@ -12,67 +12,8 @@ vi.mock("../../networking", () => ({ import { makeAgentsPublicCall } from "../../networking"; const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeAgentPublicForm", () => { const mockProps = { @@ -143,7 +84,7 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument(); // Select all agents using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -169,7 +110,7 @@ describe("MakeAgentPublicForm", () => { render(); // Select all agents - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -232,6 +173,8 @@ describe("MakeAgentPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -256,8 +199,8 @@ describe("MakeAgentPublicForm", () => { expect(screen.getByText("No agents available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -332,7 +275,7 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display skills overflow text when agent has more than 3 skills", () => { @@ -395,7 +338,7 @@ describe("MakeAgentPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -420,9 +363,11 @@ describe("MakeAgentPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -441,7 +386,7 @@ describe("MakeAgentPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument(); }); @@ -500,6 +445,6 @@ describe("MakeAgentPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx index 0ed73872cee..82336206858 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeAgentPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeAgentsPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Agents", "Confirm"]; interface MakeAgentPublicFormProps { visible: boolean; @@ -25,12 +29,10 @@ const MakeAgentPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedAgents, setSelectedAgents] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedAgents(new Set()); - form.resetFields(); onClose(); }; @@ -113,29 +115,30 @@ const MakeAgentPublicForm: React.FC = ({ return (
- Select Agents to Make Public +

Select Agents to Make Public

- handleSelectAll(e.target.checked)} - disabled={agentHubData.length === 0} - > +
- +

Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these agents. - +

{agentHubData.length === 0 ? (
- No agents available. +

No agents available.

) : ( agentHubData.map((agent) => { @@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC = ({
handleAgentSelection(agentId, e.target.checked)} + onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)} /> -
+
- {agent.name} - - v{agent.version} - +

{agent.name}

+ v{agent.version}
- {agent.description} +

{agent.description}

{agent.skills && agent.skills.length > 0 && (
{agent.skills.slice(0, 3).map((skill) => ( - + {skill.name} ))} {agent.skills.length > 3 && ( - +{agent.skills.length - 3} more +

+{agent.skills.length - 3} more

)}
)} @@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC = ({ {selectedAgents.size > 0 && (
- +

{selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} selected - +

)}
@@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Agents Public +

Confirm Making Agents Public

- +

Warning: Once you make these agents public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Agents to be made public: +

Agents to be made public:

{Array.from(selectedAgents).map((agentId) => { const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId); return (
-
+
- {agent?.name || agentId} - {agent && ( - - v{agent.version} - - )} +

{agent?.name || agentId}

+ {agent && v{agent.version}}
- {agent?.description && {agent.description}} + {agent?.description && ( +

{agent.description}

+ )}
); @@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC = ({
- +

Total: {selectedAgents.size} agent{selectedAgents.size !== 1 ? "s" : ""} will be made public - +

); @@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Agents Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx index 994a920b2e4..dda6a56146a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -12,83 +12,8 @@ vi.mock("../../networking", () => ({ import { makeMCPPublicCall } from "../../networking"; const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Additional @tremor/react mocks. -// NOTE: the comment used to say "Button is already mocked globally" — that was -// incorrect. A file-level vi.mock fully replaces the setup-level mock from -// tests/setupTests.ts, so we must re-apply the Button/Tooltip overrides here. -// Without them, the real Tremor Button leaks through and its useTooltip(300) -// schedules a native setTimeout that can fire post-teardown -> "window is not defined". -vi.mock("@tremor/react", async (importOriginal) => { - const actual = await importOriginal(); - const React = await import("react"); - return { - ...actual, - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), - Button: React.forwardRef(({ children, ...props }, ref) => ( - - )), - Tooltip: ({ children }: any) => <>{children}, - }; -}); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); describe("MakeMCPPublicForm", () => { const mockProps = { @@ -182,7 +107,7 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument(); // Select all servers using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -208,7 +133,7 @@ describe("MakeMCPPublicForm", () => { render(); // Select all servers - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -271,6 +196,8 @@ describe("MakeMCPPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -295,8 +222,8 @@ describe("MakeMCPPublicForm", () => { expect(screen.getByText("No MCP servers available.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -371,7 +298,7 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display tools overflow text when server has more than 3 tools", () => { @@ -428,7 +355,7 @@ describe("MakeMCPPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -453,9 +380,11 @@ describe("MakeMCPPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -474,7 +403,7 @@ describe("MakeMCPPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument(); }); @@ -569,6 +498,6 @@ describe("MakeMCPPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx index b590c3cc1dd..7ef42883400 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx @@ -1,11 +1,25 @@ import React, { useState, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeMCPPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; -const { Step } = Steps; +const STEP_TITLES = ["Select Servers", "Confirm"]; + +const statusVariant = (status?: string) => { + if (status === "active" || status === "healthy") { + return "default" as const; + } + if (status === "inactive" || status === "unhealthy") { + return "destructive" as const; + } + return "outline" as const; +}; interface MakeMCPPublicFormProps { visible: boolean; @@ -25,12 +39,10 @@ const MakeMCPPublicForm: React.FC = ({ const [currentStep, setCurrentStep] = useState(0); const [selectedServers, setSelectedServers] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedServers(new Set()); - form.resetFields(); onClose(); }; @@ -114,29 +126,30 @@ const MakeMCPPublicForm: React.FC = ({ return (
- Select MCP Servers to Make Public +

Select MCP Servers to Make Public

- handleSelectAll(e.target.checked)} - disabled={mcpHubData.length === 0} - > +
- +

Select the MCP servers you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these servers. - +

{mcpHubData.length === 0 ? (
- No MCP servers available. +

No MCP servers available.

) : ( mcpHubData.map((server) => { @@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC = ({ > handleServerSelection(server.server_id, e.target.checked)} + onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)} /> -
-
- {server.server_name} - {isPublic && ( - - Public - - )} - - {server.transport} - - - {server.status || "unknown"} - +
+
+

{server.server_name}

+ {isPublic && Public} + {server.transport} + {server.status || "unknown"}
- {server.description || server.url} +

{server.description || server.url}

{server.allowed_tools && server.allowed_tools.length > 0 && (
{server.allowed_tools.slice(0, 3).map((tool, idx) => ( - + {tool} ))} {server.allowed_tools.length > 3 && ( - +{server.allowed_tools.length - 3} more +

+{server.allowed_tools.length - 3} more

)}
)} @@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC = ({ {selectedServers.size > 0 && (
- +

{selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} selected - +

)}
@@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making MCP Servers Public +

Confirm Making MCP Servers Public

- +

Warning: Once you make these MCP servers public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- MCP Servers to be made public: +

MCP Servers to be made public:

{Array.from(selectedServers).map((serverId) => { const server = mcpHubData.find((s) => s.server_id === serverId); return (
-
-
- {server?.server_name || serverId} +
+
+

{server?.server_name || serverId}

{server && ( <> - - {server.transport} - - - {server.status || "unknown"} - + {server.transport} + {server.status || "unknown"} )}
- {server?.description && {server.description}} - {server?.url && {server.url}} + {server?.description && ( +

{server.description}

+ )} + {server?.url &&

{server.url}

}
); @@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC = ({
- +

Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made public - +

); @@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make MCP Servers Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx index 2b57535f3ad..d7d3b0935dd 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -29,67 +29,8 @@ vi.mock("../../networking", () => ({ import { makeModelGroupPublic } from "../../networking"; const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic); -// Mock antd components -vi.mock("antd", () => ({ - Modal: ({ open, title, children, onCancel, footer }: any) => - open ? ( -
-
{title}
- {children} - {footer} -
- ) : null, - Form: Object.assign(({ children, form }: any) =>
{children}, { - useForm: () => [ - { - resetFields: vi.fn(), - validateFields: vi.fn(), - getFieldsValue: vi.fn(), - setFieldsValue: vi.fn(), - }, - vi.fn(), - ], - Item: ({ children }: any) =>
{children}
, - }), - Steps: Object.assign( - ({ children, current, className }: any) => ( -
- {children} -
- ), - { - Step: ({ title }: any) =>
{title}
, - }, - ), - Button: ({ children, onClick, disabled, loading, ...props }: any) => ( - - ), - Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => ( - - ), -})); - -// Mock @tremor/react components -vi.mock("@tremor/react", () => ({ - Text: ({ children, className }: any) => {children}, - Title: ({ children }: any) =>

{children}

, - Badge: ({ children, color, size }: any) => ( - - {children} - - ), -})); +const expectDisabledControl = (element: HTMLElement) => + expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true); // Mock ModelFilters component vi.mock("../../model_filters", () => ({ @@ -190,7 +131,7 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument(); // Select all models using the select all checkbox - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -216,7 +157,7 @@ describe("MakeModelPublicForm", () => { render(); // Select all models - const selectAllCheckbox = screen.getByLabelText("Select All (2)"); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" }); await act(async () => { fireEvent.click(selectAllCheckbox); }); @@ -279,6 +220,8 @@ describe("MakeModelPublicForm", () => { const checkboxes = screen.getAllByRole("checkbox"); await act(async () => { fireEvent.click(checkboxes[0]); // Click select all to select all + }); + await act(async () => { fireEvent.click(checkboxes[0]); // Click select all again to deselect all }); @@ -303,8 +246,8 @@ describe("MakeModelPublicForm", () => { expect(screen.getByText("No models match the current filters.")).toBeInTheDocument(); // Select All checkbox should be disabled - const selectAllCheckbox = screen.getByLabelText("Select All"); - expect(selectAllCheckbox).toBeDisabled(); + const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" }); + expectDisabledControl(selectAllCheckbox); // Next button should be disabled const nextButton = screen.getByRole("button", { name: "Next" }); @@ -379,7 +322,7 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate now const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should display model badges and information", () => { @@ -428,7 +371,7 @@ describe("MakeModelPublicForm", () => { expect(mockProps.onClose).not.toHaveBeenCalled(); }); - it("should show loading state during submit", async () => { + it("should not complete the flow until the submit request resolves", async () => { let resolvePromise: (value: any) => void = () => {}; const pendingPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -453,9 +396,11 @@ describe("MakeModelPublicForm", () => { fireEvent.click(submitButton); }); - // Check loading state - expect(submitButton).toHaveAttribute("data-loading", "true"); - expect(submitButton).toBeDisabled(); + // While the request is in flight the flow must not have completed + expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1); + expect(mockProps.onSuccess).not.toHaveBeenCalled(); + expect(mockProps.onClose).not.toHaveBeenCalled(); + expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument(); // Resolve the promise resolvePromise({}); @@ -474,7 +419,7 @@ describe("MakeModelPublicForm", () => { render(); // Modal should not be rendered - expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument(); }); @@ -521,15 +466,14 @@ describe("MakeModelPublicForm", () => { // Select all should be indeterminate const selectAllCheckbox = checkboxes[0]; - expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true"); + expect(selectAllCheckbox).toBePartiallyChecked(); }); it("should show selected count", () => { render(); // Should show that 1 model is selected (gpt-3.5-turbo is preselected) - expect(screen.getByText("1")).toBeInTheDocument(); - expect(screen.getByText("model selected")).toBeInTheDocument(); + expect(screen.getByText("model selected")).toHaveTextContent("1 model selected"); }); it("should show confirmation step with selected models", async () => { diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx index 2d0ae1a0e2b..28a34ee1f1a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx @@ -1,11 +1,15 @@ import React, { useState, useCallback, useEffect } from "react"; -import { Modal, Form, Steps, Button, Checkbox } from "antd"; -import { Text, Title, Badge } from "@tremor/react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/cva.config"; import { makeModelGroupPublic } from "../../networking"; import ModelFilters from "../../model_filters"; import NotificationsManager from "../../molecules/notifications_manager"; -const { Step } = Steps; +const STEP_TITLES = ["Select Models", "Confirm"]; interface ModelGroupInfo { model_group: string; @@ -44,13 +48,11 @@ const MakeModelPublicForm: React.FC = ({ const [selectedModels, setSelectedModels] = useState>(new Set()); const [filteredData, setFilteredData] = useState([]); const [loading, setLoading] = useState(false); - const [form] = Form.useForm(); const handleClose = () => { setCurrentStep(0); setSelectedModels(new Set()); setFilteredData([]); - form.resetFields(); onClose(); }; @@ -138,23 +140,24 @@ const MakeModelPublicForm: React.FC = ({ return (
- Select Models to Make Public +

Select Models to Make Public

- handleSelectAll(e.target.checked)} - disabled={filteredData.length === 0} - > +
- +

Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key to use these models. - +

{/* Filters */} = ({
{filteredData.length === 0 ? (
- No models match the current filters. +

No models match the current filters.

) : ( filteredData.map((model) => ( @@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC = ({ > handleModelSelection(model.model_group, e.target.checked)} + onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)} /> -
-
- {model.model_group} - {model.mode && ( - - {model.mode} - - )} +
+
+

{model.model_group}

+ {model.mode && {model.mode}}
{model.providers.map((provider) => ( - + {provider} ))} @@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC = ({ {selectedModels.size > 0 && (
- +

{selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} selected - +

)}
@@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC = ({ const renderStep2Content = () => { return (
- Confirm Making Models Public +

Confirm Making Models Public

- +

Warning: Once you make these models public, anyone who can go to the{" "} /ui/model_hub_table will be able to know they exist on the proxy. - +

- Models to be made public: +

Models to be made public:

{Array.from(selectedModels).map((modelGroup) => { const model = modelHubData.find((m) => m.model_group === modelGroup); return (
-
- {modelGroup} +
+

{modelGroup}

{model && (
{model.providers.map((provider) => ( - + {provider} ))} @@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC = ({
- +

Total: {selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""} will be made public - +

); @@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC = ({ const renderStepButtons = () => { return (
- @@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC = ({ )} {currentStep === 1 && ( - )} @@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC = ({ }; return ( - -
- - - - + !open && handleClose()} disablePointerDismissal> + + + Make Models Public + - {renderStepContent()} - {renderStepButtons()} - -
+
+
    + {STEP_TITLES.map((title, index) => ( +
  1. + + {index + 1} + + + {title} + +
  2. + ))} +
+ + {renderStepContent()} + {renderStepButtons()} +
+ + ); }; From 07492314a84fbbeb121e6717f6400000db7e8470 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 02:23:24 -0700 Subject: [PATCH 04/13] fix(ui): announce the account popover as a dialog, not a menu The panel holds switches and ordinary buttons rather than menu items, so menu semantics promised keyboard behavior it does not provide. --- .../src/components/Navbar/UserDropdown/UserDropdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 1d92ef246cd..50c44367020 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -208,7 +208,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar collapsed ? "justify-center px-0 py-1" : "gap-2.5 px-2 py-1.5 text-left", )} aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} - aria-haspopup="menu" + aria-haspopup="dialog" title={collapsed ? displayName : undefined} /> } @@ -235,7 +235,7 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar type="button" className="flex! max-w-[min(200px,34vw)] items-center gap-2 rounded-md! py-0.5! pl-1! pr-2! transition-colors hover:bg-gray-100!" aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} - aria-haspopup="menu" + aria-haspopup="dialog" /> } > From c344b9a0520cfc2f7f8a84611a1e98afdbe7c69c Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 02:25:18 -0700 Subject: [PATCH 05/13] fix(ui): give the request details drawer an accessible name Screen readers announced an unnamed dialog. The visible header is a custom layout, so the title is visually hidden to keep the drawer layout unchanged. --- .../view_logs/LogDetailsDrawer/LogDetailContent.test.tsx | 3 --- .../view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx | 5 ++++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx index 000e6ccd567..01b040b5c17 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -318,8 +318,6 @@ describe("LogDetailContent", () => { render(); expect(screen.getByText("Response Cache")).toBeInTheDocument(); - // Response Cache is the only metric with an info tooltip in this fixture, so an - // unscoped lookup still pins the docs link to that label. const infoIcons = screen.getAllByRole("img", { name: /info/i }); expect(infoIcons).toHaveLength(1); await user.hover(infoIcons[0]); @@ -345,7 +343,6 @@ describe("LogDetailContent", () => { ); expect(screen.getByText("Prompt Cache Read Tokens")).toBeInTheDocument(); - // Prompt Cache Read Tokens is the only metric with an info tooltip in this fixture. const infoIcons = screen.getAllByRole("img", { name: /info/i }); expect(infoIcons).toHaveLength(1); await user.hover(infoIcons[0]); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index 40e6e6f2051..83049a12fd2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Bot, Check, ChevronLeft, ChevronRight, Copy, Sparkles, Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LogEntry } from "../columns"; import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; @@ -310,6 +310,9 @@ export function LogDetailsDrawer({ className="gap-0 overflow-hidden p-0 data-[side=right]:sm:max-w-none" style={{ width: DRAWER_WIDTH }} > + + {logEntry?.request_id ? `Request ${logEntry.request_id} details` : "Request details"} +
{!isSidebarCollapsed ? ( + + + + ); } diff --git a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx index c3540ce757a..9b89d85507b 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelAliasManager.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from "react"; import { PlusCircleIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; -import { Card, Title, Text, Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; +import { Card, CardTitle } from "@/components/ui/card"; +import { Table, TableHeader, TableHead, TableBody, TableRow, TableCell } from "@/components/ui/table"; import ModelSelector from "./ModelSelector"; import NotificationsManager from "../molecules/notifications_manager"; @@ -141,7 +142,7 @@ const ModelAliasManager: React.FC = ({ return (
- Add New Alias +

Add New Alias

@@ -186,17 +187,17 @@ const ModelAliasManager: React.FC = ({
- Manage Existing Aliases +

Manage Existing Aliases

- + - Alias Name - Target Model - Actions + Alias Name + Target Model + Actions - + {aliases.map((alias) => ( @@ -284,9 +285,9 @@ const ModelAliasManager: React.FC = ({ {/* Configuration Example */} {showExampleConfig && ( - - Configuration Example - Here's how your current aliases would look in the config: + + Configuration Example +

Here's how your current aliases would look in the config:

model_aliases: diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx index 857e4d3296b..bd7b56f1817 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.test.tsx @@ -1,28 +1,20 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; import ModelSelector from "./ModelSelector"; vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModels: vi.fn().mockResolvedValue([]), })); -const openCustomModelInput = () => { - const selector = document.querySelector(".ant-select-selector"); - expect(selector).toBeTruthy(); - act(() => { - fireEvent.mouseDown(selector!); - }); - act(() => { - fireEvent.click(screen.getByText("Enter custom model")); - }); +const openCustomModelInput = async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Enter custom model")); return screen.getByPlaceholderText("Enter custom model name"); }; describe("ModelSelector custom model debounce", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { act(() => { vi.runOnlyPendingTimers(); @@ -30,11 +22,12 @@ describe("ModelSelector custom model debounce", () => { vi.useRealTimers(); }); - it("does not call onChange before the debounce wait elapses", () => { + it("does not call onChange before the debounce wait elapses", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); @@ -49,11 +42,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).not.toHaveBeenCalled(); }); - it("calls onChange exactly once with the last typed value after the wait", () => { + it("calls onChange exactly once with the last typed value after the wait", async () => { const onChange = vi.fn(); render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "g" } }); @@ -71,11 +65,12 @@ describe("ModelSelector custom model debounce", () => { expect(onChange).toHaveBeenCalledWith("gpt-5.2"); }); - it("does not call onChange when unmounted mid-wait", () => { + it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn(); const { unmount } = render(); - const input = openCustomModelInput(); + const input = await openCustomModelInput(); + vi.useFakeTimers(); act(() => { fireEvent.change(input, { target: { value: "gpt-4o" } }); diff --git a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx index f2621cd1acb..a50131256fa 100644 --- a/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/ModelSelector.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; -import { TextInput, Text } from "@tremor/react"; -import { Select } from "antd"; -import { RobotOutlined } from "@ant-design/icons"; +import { Bot } from "lucide-react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { Input } from "@/components/ui/input"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; const MODEL_SELECT_DEBOUNCE_MS = 500; @@ -80,32 +80,30 @@ const ModelSelector: React.FC = ({ return (
{showLabel && ( - - {labelText} - +

+ {labelText} +

)} - { - if (!option) return false; - const org = organizations?.find((o) => o.organization_id === option.key); - if (!org) return false; - - const searchTerm = input.toLowerCase().trim(); - const orgAlias = (org.organization_alias || "").toLowerCase(); - const orgId = (org.organization_id || "").toLowerCase(); - - return orgAlias.includes(searchTerm) || orgId.includes(searchTerm); - }} - > - {organizations?.map((org) => ( - - {org.organization_alias}{" "} - ({org.organization_id}) - - ))} - +
+ ({ + label: org.organization_alias || org.organization_id, + value: org.organization_id, + sublabel: org.organization_id, + }))} + value={value} + onValueChange={(organizationId) => onChange?.(organizationId)} + placeholder={placeholder} + emptyText={loading ? "Loading organizations…" : "No organizations found"} + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx index e02125dea56..330f852383d 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Select } from "antd"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; import { getPassThroughEndpointsCall } from "../networking"; interface PassThroughRoutesSelectorProps { @@ -17,6 +17,11 @@ interface PassThroughEndpoint { methods?: string[]; } +const routeOption = (endpoint: PassThroughEndpoint): MultiSelectOption => ({ + label: endpoint.methods?.length ? `${endpoint.methods.join(", ")} ${endpoint.path}` : endpoint.path, + value: endpoint.path, +}); + const PassThroughRoutesSelector: React.FC = ({ onChange, value, @@ -26,7 +31,7 @@ const PassThroughRoutesSelector: React.FC = ({ disabled = false, teamId, }) => { - const [passThroughRoutes, setPassThroughRoutes] = useState>([]); + const [passThroughRoutes, setPassThroughRoutes] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -37,27 +42,7 @@ const PassThroughRoutesSelector: React.FC = ({ try { const response = await getPassThroughEndpointsCall(accessToken, teamId); if (response.endpoints) { - const routes = response.endpoints.flatMap((endpoint: PassThroughEndpoint) => { - const path = endpoint.path; - const methods = endpoint.methods; - - // If methods are specified, create one entry per method - if (methods && methods.length > 0) { - return methods.map((method) => ({ - label: `${method} ${path}`, - value: path, // Keep value as path for backward compatibility - })); - } - - // If no methods specified, show just the path (all methods supported) - return [ - { - label: path, - value: path, - }, - ]; - }); - setPassThroughRoutes(routes); + setPassThroughRoutes(response.endpoints.map(routeOption)); } } catch (error) { console.error("Error fetching pass through routes:", error); @@ -70,19 +55,16 @@ const PassThroughRoutesSelector: React.FC = ({ }, [accessToken, teamId]); return ( - ({ + label: project.project_alias || project.project_id, + value: project.project_id, + sublabel: project.project_id, + })) + } value={value} - onChange={onChange} + onValueChange={(projectId) => onChange?.(projectId)} + placeholder="Search or select a project" + emptyText={loading ? "Loading projects…" : "No projects found"} disabled={disabled} - loading={loading} - allowClear - notFoundContent={loading ? } size="small" /> : undefined} - filterOption={(input, option) => { - if (!option) return false; - const project = filtered?.find((p) => p.project_id === option.key); - if (!project) return false; - - const searchTerm = input.toLowerCase().trim(); - const alias = (project.project_alias || "").toLowerCase(); - const id = (project.project_id || "").toLowerCase(); - - return alias.includes(searchTerm) || id.includes(searchTerm); - }} - optionFilterProp="children" - > - {!loading && - filtered?.map((project) => ( - - {project.project_alias || project.project_id}{" "} - ({project.project_id}) - - ))} - + inputId={id} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 5ac3b8b2b64..6f819607e3f 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -21,14 +21,6 @@ vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ ), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value, diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 56227abe9ea..7570806182d 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { getRouterSettingsCall } from "../networking"; @@ -344,13 +344,13 @@ const RouterSettingsAccordion = forwardRef - - - Loadbalancing - Fallbacks - - - + + + Loadbalancing + Fallbacks + +
+ - - + + - - - + +
+
); }, diff --git a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx index 4db36f27553..e283f0550ec 100644 --- a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx @@ -1,10 +1,16 @@ import React from "react"; -import { Select } from "antd"; - -const { Option } = Select; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; export const NEVER_RESETS_BUDGET_DURATION = "none"; +const DURATION_LABELS: Record = { + [NEVER_RESETS_BUDGET_DURATION]: "Never resets", + "1h": "hourly", + "24h": "daily", + "7d": "weekly", + "30d": "monthly", +}; + interface BudgetDurationDropdownProps { value?: string | null; onChange?: (value: string | undefined) => void; @@ -24,18 +30,21 @@ const BudgetDurationDropdown: React.FC = ({ }) => { return ( ); }; diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx index b924021863a..01a4d8373c4 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { CustomLegend, CustomTooltip } from "./chartUtils"; -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; +type TooltipPayload = NonNullable; + describe("CustomTooltip", () => { const mockPayload = [ { @@ -28,9 +30,9 @@ describe("CustomTooltip", () => { ]; it("should render", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -38,9 +40,9 @@ describe("CustomTooltip", () => { }); it("should return null when not active", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: false, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -48,9 +50,9 @@ describe("CustomTooltip", () => { }); it("should return null when payload is empty", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: [], + payload: [] as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -58,9 +60,9 @@ describe("CustomTooltip", () => { }); it("should display formatted category names", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -89,9 +91,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUnderscores, + payload: payloadWithUnderscores as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -120,9 +122,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: spendPayload, + payload: spendPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -130,9 +132,9 @@ describe("CustomTooltip", () => { }); it("should format non-spend numeric values with locale string", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -161,9 +163,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithUndefined, + payload: payloadWithUndefined as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -211,9 +213,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: multiplePayload, + payload: multiplePayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -222,9 +224,9 @@ describe("CustomTooltip", () => { }); it("should convert color names to hex values", () => { - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: mockPayload, + payload: mockPayload as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -254,9 +256,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithHexColor, + payload: payloadWithHexColor as unknown as TooltipPayload, label: "2024-01-15", }; const { container } = render(); @@ -286,9 +288,9 @@ describe("CustomTooltip", () => { }, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutDataKey as any, + payload: payloadWithoutDataKey as unknown as TooltipPayload, label: "2024-01-15", }; render(); @@ -304,9 +306,9 @@ describe("CustomTooltip", () => { payload: undefined, }, ]; - const props: CustomTooltipProps = { + const props: ChartTooltipProps = { active: true, - payload: payloadWithoutPayload as any, + payload: payloadWithoutPayload as unknown as TooltipPayload, label: "2024-01-15", }; render(); diff --git a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx index c0930f290f6..0abf004803b 100644 --- a/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx +++ b/ui/litellm-dashboard/src/components/common_components/chartUtils.tsx @@ -1,4 +1,4 @@ -import type { CustomTooltipProps } from "@tremor/react"; +import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip"; import { SpendMetrics } from "../UsagePage/types"; interface ChartDataPoint { @@ -16,7 +16,7 @@ const colorNameToHex: { [key: string]: string } = { emerald: "#37bc7d", }; -export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { +export const CustomTooltip = ({ active, payload, label }: ChartTooltipProps) => { if (active && payload && payload.length) { const formatCategoryName = (name: string): string => { return name diff --git a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx index d6ed0ae07db..15c52e71e8f 100644 --- a/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/routerSettingsWiring.test.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from "@testing-library/react"; -import type { ReactElement, ReactNode } from "react"; +import type { ReactElement } from "react"; import { describe, expect, it, vi } from "vitest"; import type { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig"; import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; @@ -16,14 +16,6 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([]), })); -vi.mock("@tremor/react", () => ({ - TabGroup: ({ children }: { children: ReactNode }) =>
{children}
, - TabList: ({ children }: { children: ReactNode }) =>
{children}
, - Tab: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanels: ({ children }: { children: ReactNode }) =>
{children}
, - TabPanel: ({ children }: { children: ReactNode }) =>
{children}
, -})); - vi.mock("../router_settings/RouterSettingsForm", () => ({ default: ({ value }: { value: RouterSettingsFormValue }) => (
{JSON.stringify(value.routerSettings)}
diff --git a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx index 4a858a346d4..17e8d46d21d 100644 --- a/ui/litellm-dashboard/src/components/common_components/simple_table.tsx +++ b/ui/litellm-dashboard/src/components/common_components/simple_table.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Table, TableHead, TableRow, TableHeaderCell, TableBody, TableCell, Text } from "@tremor/react"; +import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; export interface SimpleTableColumn { header: string; @@ -31,20 +31,20 @@ export function SimpleTable({ }: SimpleTableProps) { return (
- + {columns.map((column, index) => ( - + {column.header} - + ))} - + {isLoading ? ( - {loadingMessage} + {loadingMessage} ) : data.length > 0 ? ( @@ -60,7 +60,7 @@ export function SimpleTable({ ) : ( - {emptyMessage} + {emptyMessage} )} diff --git a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx index 7d27886c7f5..35121f41598 100644 --- a/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx +++ b/ui/litellm-dashboard/src/components/common_components/team_dropdown.tsx @@ -1,13 +1,8 @@ -import React, { useMemo, useState, type UIEvent } from "react"; -import { Select, Typography } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import React, { useMemo, useState } from "react"; +import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { Team } from "../key_team_helpers/key_list"; -const { Text } = Typography; - interface TeamDropdownProps { value?: string; onChange?: (value: string) => void; @@ -17,10 +12,9 @@ interface TeamDropdownProps { /** Filter teams by organization. */ organizationId?: string | null; pageSize?: number; + id?: string; } -const SCROLL_THRESHOLD = 0.8; - const TeamDropdown: React.FC = ({ value, onChange, @@ -28,15 +22,13 @@ const TeamDropdown: React.FC = ({ disabled, organizationId, pageSize = 20, + id, }) => { - const [searchInput, setSearchInput] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_WAIT_MS, - }); + const [search, setSearch] = useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams( pageSize, - debouncedSearch || undefined, + search || undefined, organizationId, ); @@ -54,59 +46,35 @@ const TeamDropdown: React.FC = ({ return result; }, [data]); - const handlePopupScroll = (e: UIEvent) => { - const target = e.currentTarget; - const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; - if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { - fetchNextPage(); - } - }; - - const handleSearch = (val: string) => { - setSearchInput(val); - setDebouncedSearch(val); - }; - - const handleChange = (teamId: string | undefined) => { - onChange?.(teamId ?? ""); + const handleChange = (teamId: string) => { + onChange?.(teamId); if (onTeamSelect) { - const team = teamId ? teams.find((t) => t.team_id === teamId) ?? null : null; - onTeamSelect(team); + onTeamSelect(teamId ? teams.find((t) => t.team_id === teamId) ?? null : null); } }; return ( - +
+ ({ + label: team.team_alias || team.team_id, + value: team.team_id, + sublabel: team.team_id, + }))} + value={value || undefined} + onValueChange={handleChange} + onSearchChange={setSearch} + onLoadMore={fetchNextPage} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search or select a team" + emptyText="No teams found" + loadingText="Loading teams…" + disabled={disabled} + inputId={id} + /> +
); }; diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index f67a1cffa1d..1bfc19cbbe2 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -24,6 +24,7 @@ interface SearchSelectProps { emptyText?: string; disabled?: boolean; className?: string; + inputId?: string; } const matchesQuery = (option: SearchSelectOption, query: string): boolean => { @@ -40,6 +41,7 @@ export function SearchSelect({ emptyText = "No results", disabled = false, className, + inputId, }: SearchSelectProps) { const selected = options.find((option) => option.value === value) ?? null; @@ -54,6 +56,7 @@ export function SearchSelect({ disabled={disabled} > { const user = userEvent.setup({ delay: null }); const resetBudgetItem = await openSettingsEditorForTeam(user, { budget_duration: "30d" }); - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await user.click(within(resetBudgetItem).getByRole("combobox")); + await user.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -1554,13 +1553,14 @@ describe("TeamInfoView", () => { await user.click(within(routesFormItem).getByRole("combobox")); - const option = await screen.findByTitle("POST /bedrock-passthrough"); + const option = await screen.findByText("POST /bedrock-passthrough"); await user.click(option); await waitFor(() => { expect(within(routesFormItem).getByText(/\/bedrock-passthrough/)).toBeInTheDocument(); }); + await user.keyboard("{Escape}"); await user.click(screen.getByRole("button", { name: /save changes/i })); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index caad6fc30fc..cdbd3197f7d 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -961,9 +961,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - const clearIcon = resetBudgetItem.querySelector(".ant-select-clear"); - expect(clearIcon).not.toBeNull(); - fireEvent.mouseDown(clearIcon as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await waitFor(() => { expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument(); @@ -995,7 +994,8 @@ describe("KeyEditView", () => { ); const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement; - fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element); + await userEvent.click(within(resetBudgetItem).getByRole("combobox")); + await userEvent.click(await screen.findByText("Never resets")); await userEvent.click(screen.getByRole("button", { name: /save changes/i })); @@ -1251,9 +1251,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeTruthy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(screen.queryByText("Engineering")).not.toBeInTheDocument(); }); it("should not disable the organization dropdown for admin users", async () => { @@ -1273,9 +1274,10 @@ describe("KeyEditView", () => { expect(screen.getByText("Organization")).toBeInTheDocument(); }); - const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); - const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); - expect(disabledSelect).toBeFalsy(); + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item") as HTMLElement; + await userEvent.click(within(orgFormItem).getByRole("combobox")); + + expect(await screen.findByText("Engineering")).toBeInTheDocument(); }); it("should initialize organization from keyData", async () => { @@ -1296,8 +1298,9 @@ describe("KeyEditView", () => { />, ); + const orgFormItem = (await screen.findByText("Organization")).closest(".ant-form-item") as HTMLElement; await waitFor(() => { - expect(screen.getByText("Engineering")).toBeInTheDocument(); + expect(within(orgFormItem).getByRole("combobox")).toHaveValue("Engineering"); }); }); }); From d7ec4d98b100b90cf6c08f10cd2c310d839c2d33 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:22:06 -0700 Subject: [PATCH 08/13] test(ui): spread the real lucide-react module in the KeyInfoView mock The mock returned only CopyIcon and CheckIcon, so any icon a child later imports resolves to undefined. DeleteResourceModal now renders CircleAlert, which broke all twelve cases in this file. --- .../templates/KeyInfoView.handleKeyUpdate.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx index 374e36029a0..b87beed048a 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyInfoView.handleKeyUpdate.test.tsx @@ -182,7 +182,8 @@ vi.mock("@heroicons/react/outline", async () => { return { ArrowLeftIcon, TrashIcon, RefreshIcon }; }); -vi.mock("lucide-react", async () => { +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); const React = await import("react"); function CopyIcon() { return React.createElement("span"); @@ -192,7 +193,7 @@ vi.mock("lucide-react", async () => { return React.createElement("span"); } (CheckIcon as any).displayName = "CheckIcon"; - return { CopyIcon, CheckIcon }; + return { ...actual, CopyIcon, CheckIcon }; }); // Heavy children -> async factories & local React From 362875a7e695fb4a14f11526667f34e14e787bb9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:29:49 -0700 Subject: [PATCH 09/13] fix(ui): hold the delete dialog open mid-deletion and keep unmatched select values DeleteResourceModal let escape, the backdrop and the close button dismiss it while the delete request was still in flight. SearchSelect blanked its field whenever the value was missing from options, which happens while they load; it now falls back to the raw value the way PaginatedSearchSelect already did. --- .../common_components/DeleteResourceModal.test.tsx | 14 ++++++++++++++ .../common_components/DeleteResourceModal.tsx | 2 +- .../src/components/shared/SearchSelect.test.tsx | 7 +++++++ .../src/components/shared/SearchSelect.tsx | 9 +++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx index e27a60cc866..465f7fcfcf0 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.test.tsx @@ -159,6 +159,20 @@ describe("DeleteResourceModal", () => { expect(cancelButton).toBeDisabled(); }); + it("should call onCancel when escape is pressed and no deletion is in flight", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + it("should ignore escape while confirmLoading is true so the modal cannot close mid-deletion", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.keyboard("{Escape}"); + expect(mockOnCancel).not.toHaveBeenCalled(); + }); + it("should disable delete button when confirmLoading is true even if requiredConfirmation matches", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx index b45f164b2a5..42baae3d86c 100644 --- a/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/DeleteResourceModal.tsx @@ -44,7 +44,7 @@ export default function DeleteResourceModal({ }, [isOpen]); return ( - !open && onCancel()}> + !open && !confirmLoading && onCancel()}> {title} diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx index acf50d282b4..5e8d63eda08 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.test.tsx @@ -21,6 +21,13 @@ describe("SearchSelect", () => { expect(screen.getByRole("combobox")).toHaveValue("Growth"); }); + it("shows a value the options do not carry yet instead of blanking the field", () => { + const { rerender } = render(); + expect(screen.getByRole("combobox")).toHaveValue("team-2"); + rerender(); + expect(screen.getByRole("combobox")).toHaveValue("Growth"); + }); + it("shows a clear control only when a value is selected", () => { const { rerender } = render(); expect(document.querySelector('[data-slot="combobox-clear"]')).toBeNull(); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index 1bfc19cbbe2..c6ae11f5729 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -43,11 +43,16 @@ export function SearchSelect({ className, inputId, }: SearchSelectProps) { - const selected = options.find((option) => option.value === value) ?? null; + const selected = + value === undefined || value === "" + ? null + : options.find((option) => option.value === value) ?? { label: value, value }; + const items = + selected !== null && !options.some((option) => option.value === selected.value) ? [selected, ...options] : options; return ( onValueChange(item?.value ?? "")} isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} From 15a331f6df9051ae4251e4159bdcbf51c9a60d82 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 06:52:06 -0700 Subject: [PATCH 10/13] refactor(ui): move the root-level dashboard components onto shadcn primitives Rebuilds nine components under src/components on the in-repo shadcn layer: both banners, the navbar chrome, the onboarding link dialog, the model filters, the model group alias table, the object permissions and logging settings views, and the user dashboard grid. Every public prop signature is unchanged, so no caller moves. --- ui/litellm-dashboard/eslint-suppressions.json | 31 ------- .../src/components/DebugWarningBanner.tsx | 26 +++--- .../components/LicenseExpiryBanner.test.tsx | 21 +++-- .../src/components/LicenseExpiryBanner.tsx | 30 +++--- .../src/components/logging_settings_view.tsx | 22 +++-- .../src/components/model_filters.tsx | 12 +-- .../components/model_group_alias_settings.tsx | 39 ++++---- .../src/components/navbar.tsx | 18 ++-- .../components/object_permissions_view.tsx | 17 ++-- .../src/components/onboarding_link.test.tsx | 91 ++++++++++++++++++- .../src/components/onboarding_link.tsx | 65 ++++++------- .../src/components/user_dashboard.tsx | 9 +- 12 files changed, 223 insertions(+), 158 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..2e72df1225b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1784,11 +1784,6 @@ "count": 1 } }, - "src/components/DebugWarningBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/DeprecationBanner.tsx": { "no-restricted-imports": { "count": 1 @@ -1840,11 +1835,6 @@ "count": 1 } }, - "src/components/LicenseExpiryBanner.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/ModelSelect/ModelSelect.tsx": { "no-restricted-imports": { "count": 1 @@ -2696,9 +2686,6 @@ "src/components/logging_settings_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/mcp_server_management/MCPServerSelector.tsx": { @@ -2764,18 +2751,12 @@ "src/components/model_filters.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/model_group_alias_settings.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2837,9 +2818,6 @@ "src/components/navbar.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/networking.tsx": { @@ -2865,17 +2843,11 @@ "src/components/object_permissions_view.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/components/organisms/RegenerateKeyModal.tsx": { @@ -3486,9 +3458,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "prefer-const": { "count": 1 }, diff --git a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx index 94474e78b14..9591fe4bb1f 100644 --- a/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx +++ b/ui/litellm-dashboard/src/components/DebugWarningBanner.tsx @@ -1,7 +1,8 @@ "use client"; import React from "react"; -import { Alert } from "antd"; +import { TriangleAlert } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; interface DebugWarningBannerProps { @@ -17,19 +18,14 @@ export const DebugWarningBanner: React.FC = ({ accessTo } return ( - - Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive - diagnostic information and will significantly degrade performance. It should only be used for troubleshooting - and disabled in production environments. - - } - type="warning" - showIcon - banner - style={{ marginBottom: 0, borderRadius: 0 }} - /> + + + Performance Warning: Detailed Debug Mode Active + + Detailed debug logging (LITELLM_LOG=DEBUG) is currently enabled. This mode logs extensive + diagnostic information and will significantly degrade performance. It should only be used for troubleshooting + and disabled in production environments. + + ); }; diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx index d6b419ace7c..627d108e65f 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -42,18 +42,22 @@ describe("LicenseExpiryBannerView", () => { expect(container).toBeEmptyDOMElement(); }); - it("shows a dismissible amber warning within 30 days", () => { + it("shows a dismissible warning within 30 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-triangle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument(); - expect(screen.queryByRole("button")).toBeInTheDocument(); + expect(screen.getByText(/Renew before it lapses to keep enterprise features/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai"); }); - it("shows a non-dismissible red critical alert within 7 days", () => { + it("shows a non-dismissible critical alert within 7 days", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.getByText(/Renew now to avoid losing enterprise features/)).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -62,18 +66,19 @@ describe("LicenseExpiryBannerView", () => { expect(screen.getByText(/expires today/)).toBeInTheDocument(); }); - it("shows a non-dismissible red expired alert stating features are disabled", () => { + it("shows a non-dismissible expired alert stating features are disabled", () => { const { container } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(container.querySelector(".lucide-circle-alert")).toBeInTheDocument(); expect(screen.getByText(/expired on/)).toBeInTheDocument(); expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument(); - expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); it("hides the warning after dismissal and stays hidden within the session", () => { const expiration = daysFromNow(20); const { unmount } = render(); - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getByRole("button", { name: /close/i })); expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); unmount(); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx index c3b20b5fac0..5867a45bc31 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -1,7 +1,9 @@ "use client"; import React, { useState } from "react"; -import { Alert } from "antd"; +import { CircleAlert, TriangleAlert, X } from "lucide-react"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; import { LicenseInfo } from "@/components/networking"; import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils"; @@ -76,16 +78,22 @@ export const LicenseExpiryBannerView: React.FC = ( }; return ( - + + {tier === "warning" ? ( + + ) : ( + + )} + {message} + {description} + {isDismissible && ( + + + + )} + ); }; diff --git a/ui/litellm-dashboard/src/components/logging_settings_view.tsx b/ui/litellm-dashboard/src/components/logging_settings_view.tsx index 97eca9d6247..ac3d688308d 100644 --- a/ui/litellm-dashboard/src/components/logging_settings_view.tsx +++ b/ui/litellm-dashboard/src/components/logging_settings_view.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; import { CogIcon, BanIcon } from "@heroicons/react/outline"; import { callbackInfo, callback_map, reverse_callback_map } from "./callback_info_helpers"; import { Logo } from "@/components/molecules/logo/Logo"; @@ -29,16 +29,16 @@ export function LoggingSettingsView({ return callbackDisplayName || callbackName; }; - const getEventTypeColor = (eventType: string): string | undefined => { + const getEventTypeVariant = (eventType: string): React.ComponentProps["variant"] => { switch (eventType) { case "success": - return "green"; + return "default"; case "failure": - return "red"; + return "destructive"; case "success_and_failure": - return "blue"; + return "secondary"; default: - return undefined; + return "outline"; } }; @@ -62,7 +62,7 @@ export function LoggingSettingsView({
Logging Integrations - {loggingConfigs.length} + {loggingConfigs.length}
{loggingConfigs.length > 0 ? ( @@ -88,7 +88,9 @@ export function LoggingSettingsView({ - {getEventTypeLabel(config.callback_type)} + + {getEventTypeLabel(config.callback_type)} + ); })} @@ -106,7 +108,7 @@ export function LoggingSettingsView({
Disabled Callbacks - {disabledCallbacks.length} + {disabledCallbacks.length}
{disabledCallbacks.length > 0 ? ( @@ -131,7 +133,7 @@ export function LoggingSettingsView({ Disabled for this key - Disabled + Disabled ); })} diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx index bc9ca6bab44..5db144a6be4 100644 --- a/ui/litellm-dashboard/src/components/model_filters.tsx +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Card, Text } from "@tremor/react"; +import { Card } from "@/components/ui/card"; interface ModelGroupInfo { model_group: string; @@ -125,7 +125,7 @@ const ModelFilters: React.FC = ({ const filtersContent = (
- Search Models: +

Search Models:

= ({ />
- Provider: +

Provider:

- Mode: +

Mode:

- Features: +

Features:

- + - Alias Name - Target Model Group - Actions + Alias Name + Target Model Group + Actions - + {aliases.map((alias) => ( @@ -275,8 +276,12 @@ const ModelGroupAliasSettings: React.FC = ({ ) : ( <> - {alias.aliasName} - {alias.targetModelGroup} + + {alias.aliasName} + + + {alias.targetModelGroup} +
{/* Configuration Example */} - - Configuration Example - - Here's how your current aliases would look in the config.yaml: - + + Configuration Example +

Here's how your current aliases would look in the config.yaml:

router_settings: diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index c999ee8035e..6ce92fb9449 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -8,8 +8,8 @@ import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; -import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; -import { Tag } from "antd"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import Link from "next/link"; import React from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; @@ -71,7 +71,13 @@ const Navbar: React.FC = ({ className="mr-2 flex h-9 w-9 items-center justify-center rounded-md text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} > - {sidebarCollapsed ? : } + + {sidebarCollapsed ? ( + + ) : ( + + )} + )} @@ -98,7 +104,7 @@ const Navbar: React.FC = ({ 🌑 )} - + = ({ > v{version} - +
)}
@@ -138,7 +144,7 @@ const Navbar: React.FC = ({ > Docs {/* Layout parity with Blog chevron — intentional single-level link */} - + diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 327e127e8fe..c7baa3d52c2 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Text } from "@tremor/react"; import VectorStorePermissions from "./permissions/VectorStorePermissions"; import MCPServerPermissions from "./permissions/MCPServerPermissions"; import AgentPermissions from "./permissions/AgentPermissions"; @@ -38,14 +37,14 @@ export function ObjectPermissionsView({ accessToken={accessToken} /> -
- Search tools +
+

Search tools

{searchTools.length === 0 ? ( - +

No restriction — all configured search tools are allowed for this team. - +

) : ( - {searchTools.join(", ")} +

{searchTools.join(", ")}

)}
@@ -56,8 +55,8 @@ export function ObjectPermissionsView({
- Object Permissions - Access control for Vector Stores and MCP Servers +

Object Permissions

+

Access control for Vector Stores and MCP Servers

{content} @@ -67,7 +66,7 @@ export function ObjectPermissionsView({ return (
- Object Permissions +

Object Permissions

{content}
); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx index 039d5e250da..a7d5a2cd4f6 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -1,5 +1,22 @@ -import { describe, it, expect } from "vitest"; -import { buildOnboardingUrl } from "./onboarding_link"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import OnboardingModal, { buildOnboardingUrl, InvitationLink } from "./onboarding_link"; + +vi.mock("./molecules/notifications_manager", () => ({ default: { success: vi.fn() } })); + +const invitation: InvitationLink = { + id: "inv-123", + user_id: "user-abc", + is_accepted: false, + accepted_at: null, + expires_at: new Date("2026-09-01"), + created_at: new Date("2026-08-01"), + created_by: "admin", + updated_at: new Date("2026-08-01"), + updated_by: "admin", + has_user_setup_sso: false, +}; describe("buildOnboardingUrl", () => { it("points the invitation link at the dedicated /ui/onboarding route", () => { @@ -68,3 +85,73 @@ describe("buildOnboardingUrl", () => { ).toBe(""); }); }); + +describe("OnboardingModal", () => { + it("renders nothing until it is opened", () => { + render( + , + ); + + expect(screen.queryByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).not.toBeInTheDocument(); + }); + + it("shows the invitation url, the user id and an invitation-flavoured copy button", async () => { + render( + , + ); + + expect(await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).toBeInTheDocument(); + expect(screen.getByText("user-abc")).toBeInTheDocument(); + expect(screen.getAllByText("Invitation Link").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Copy invitation link" })).toBeInTheDocument(); + expect(screen.getByText(/Copy and send the generated link to onboard this user/)).toBeInTheDocument(); + }); + + it("switches every label and the url to the reset-password flow", async () => { + render( + , + ); + + expect( + await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"), + ).toBeInTheDocument(); + expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Copy password reset link" })).toBeInTheDocument(); + expect( + screen.getByText(/Copy and send the generated link to the user to reset their password/), + ).toBeInTheDocument(); + }); + + it("closes through setIsInvitationLinkModalVisible when the close control is used", async () => { + const user = userEvent.setup(); + const setVisible = vi.fn(); + render( + , + ); + + await user.click(await screen.findByRole("button", { name: /close/i })); + + expect(setVisible).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index b5f3c6d3e17..f24e3709794 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { Button, Modal, Typography } from "antd"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Text } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import NotificationsManager from "./molecules/notifications_manager"; export interface InvitationLink { @@ -58,10 +58,7 @@ export default function OnboardingModal({ invitationLinkData, modalType = "invitation", }: OnboardingProps) { - const { Paragraph } = Typography; - const handleInvitationOk = () => { - setIsInvitationLinkModalVisible(false); - }; + const linkLabel = modalType === "invitation" ? "Invitation Link" : "Reset Password Link"; const handleInvitationCancel = () => { setIsInvitationLinkModalVisible(false); @@ -76,36 +73,30 @@ export default function OnboardingModal({ }); return ( - - - {modalType === "invitation" - ? "Copy and send the generated link to onboard this user to the proxy." - : "Copy and send the generated link to the user to reset their password."} - -
- User ID - {invitationLinkData?.user_id} -
-
- {modalType === "invitation" ? "Invitation Link" : "Reset Password Link"} - - {getInvitationUrl()} - -
-
- NotificationsManager.success("Copied!")}> - - -
-
+ !open && handleInvitationCancel()}> + + + {linkLabel} + +

+ {modalType === "invitation" + ? "Copy and send the generated link to onboard this user to the proxy." + : "Copy and send the generated link to the user to reset their password."} +

+
+ User ID + {invitationLinkData?.user_id} +
+
+ {linkLabel} + {getInvitationUrl()} +
+
+ NotificationsManager.success("Copied!")}> + + +
+
+
); } diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1de232fadb8..ce5337aa7a7 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -1,6 +1,5 @@ "use client"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; -import { Col, Grid } from "@tremor/react"; import { jwtDecode } from "jwt-decode"; import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; @@ -218,8 +217,8 @@ const UserDashboard: React.FC = ({ return (
- -
+
+
= ({ ) : undefined } /> - - +
+
); }; From 4544f7dbaddef304799718c7f3899f1bf95b7ba4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 07:10:13 -0700 Subject: [PATCH 11/13] revert(ui): keep the onboarding link modal on antd The invitation dialog opens over the still-antd Invite User modal. Lifting only the shadcn dialog content above antd's mask leaves its own backdrop underneath, so an outside click reaches the wrong modal. Adding a second backdrop stops that but does not restore dismissal, and the same hazard already ships in three guardrails modals, so the stacking needs one shared fix rather than a fourth local workaround. --- ui/litellm-dashboard/eslint-suppressions.json | 3 + .../src/components/onboarding_link.test.tsx | 91 +------------------ .../src/components/onboarding_link.tsx | 65 +++++++------ 3 files changed, 42 insertions(+), 117 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 2e72df1225b..e0ad8ca1606 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2848,6 +2848,9 @@ "src/components/onboarding_link.tsx": { "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/components/organisms/RegenerateKeyModal.tsx": { diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx index a7d5a2cd4f6..039d5e250da 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -1,22 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import OnboardingModal, { buildOnboardingUrl, InvitationLink } from "./onboarding_link"; - -vi.mock("./molecules/notifications_manager", () => ({ default: { success: vi.fn() } })); - -const invitation: InvitationLink = { - id: "inv-123", - user_id: "user-abc", - is_accepted: false, - accepted_at: null, - expires_at: new Date("2026-09-01"), - created_at: new Date("2026-08-01"), - created_by: "admin", - updated_at: new Date("2026-08-01"), - updated_by: "admin", - has_user_setup_sso: false, -}; +import { describe, it, expect } from "vitest"; +import { buildOnboardingUrl } from "./onboarding_link"; describe("buildOnboardingUrl", () => { it("points the invitation link at the dedicated /ui/onboarding route", () => { @@ -85,73 +68,3 @@ describe("buildOnboardingUrl", () => { ).toBe(""); }); }); - -describe("OnboardingModal", () => { - it("renders nothing until it is opened", () => { - render( - , - ); - - expect(screen.queryByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).not.toBeInTheDocument(); - }); - - it("shows the invitation url, the user id and an invitation-flavoured copy button", async () => { - render( - , - ); - - expect(await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123")).toBeInTheDocument(); - expect(screen.getByText("user-abc")).toBeInTheDocument(); - expect(screen.getAllByText("Invitation Link").length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: "Copy invitation link" })).toBeInTheDocument(); - expect(screen.getByText(/Copy and send the generated link to onboard this user/)).toBeInTheDocument(); - }); - - it("switches every label and the url to the reset-password flow", async () => { - render( - , - ); - - expect( - await screen.findByText("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"), - ).toBeInTheDocument(); - expect(screen.getAllByText("Reset Password Link").length).toBeGreaterThan(0); - expect(screen.getByRole("button", { name: "Copy password reset link" })).toBeInTheDocument(); - expect( - screen.getByText(/Copy and send the generated link to the user to reset their password/), - ).toBeInTheDocument(); - }); - - it("closes through setIsInvitationLinkModalVisible when the close control is used", async () => { - const user = userEvent.setup(); - const setVisible = vi.fn(); - render( - , - ); - - await user.click(await screen.findByRole("button", { name: /close/i })); - - expect(setVisible).toHaveBeenCalledWith(false); - }); -}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index f24e3709794..b5f3c6d3e17 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -1,7 +1,7 @@ import React from "react"; +import { Button, Modal, Typography } from "antd"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Text } from "@tremor/react"; import NotificationsManager from "./molecules/notifications_manager"; export interface InvitationLink { @@ -58,7 +58,10 @@ export default function OnboardingModal({ invitationLinkData, modalType = "invitation", }: OnboardingProps) { - const linkLabel = modalType === "invitation" ? "Invitation Link" : "Reset Password Link"; + const { Paragraph } = Typography; + const handleInvitationOk = () => { + setIsInvitationLinkModalVisible(false); + }; const handleInvitationCancel = () => { setIsInvitationLinkModalVisible(false); @@ -73,30 +76,36 @@ export default function OnboardingModal({ }); return ( - !open && handleInvitationCancel()}> - - - {linkLabel} - -

- {modalType === "invitation" - ? "Copy and send the generated link to onboard this user to the proxy." - : "Copy and send the generated link to the user to reset their password."} -

-
- User ID - {invitationLinkData?.user_id} -
-
- {linkLabel} - {getInvitationUrl()} -
-
- NotificationsManager.success("Copied!")}> - - -
-
-
+ + + {modalType === "invitation" + ? "Copy and send the generated link to onboard this user to the proxy." + : "Copy and send the generated link to the user to reset their password."} + +
+ User ID + {invitationLinkData?.user_id} +
+
+ {modalType === "invitation" ? "Invitation Link" : "Reset Password Link"} + + {getInvitationUrl()} + +
+
+ NotificationsManager.success("Copied!")}> + + +
+
); } From 3170fff768e0c196dc472b80ffb4483541bd7091 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 14 Aug 2026 07:49:33 -0700 Subject: [PATCH 12/13] refactor(ui): move the settings page and bulk user invite onto shadcn primitives Rebuilds settings.tsx and bulk_create_users_button.tsx on the in-repo shadcn layer. The settings callback form moves from antd Form to react-hook-form with the shared Field primitives, and the CSV drop zone replaces antd Upload with a native file input plus drag handlers. Both public prop signatures are unchanged, so no caller moves. --- ui/litellm-dashboard/eslint-suppressions.json | 9 - .../bulk_create_users_button.test.tsx | 49 +- .../components/bulk_create_users_button.tsx | 789 +++++++++--------- .../src/components/settings.test.tsx | 179 ++-- .../src/components/settings.tsx | 639 +++++++------- 5 files changed, 900 insertions(+), 765 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..aad9b63f8b3 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2295,9 +2295,6 @@ "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3061,15 +3058,9 @@ "local/filename-pascal-case": { "count": 1 }, - "local/no-complex-jsx-arrow": { - "count": 4 - }, "no-nested-ternary": { "count": 2 }, - "no-restricted-imports": { - "count": 3 - }, "prefer-const": { "count": 4 } diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx index 7397eaa6b24..fff03ed8e82 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx @@ -1,4 +1,5 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import BulkCreateUsersButton from "./bulk_create_users_button"; @@ -20,9 +21,55 @@ vi.mock("./molecules/notifications_manager", () => ({ }, })); +const csvFile = () => + new File(["user_email,user_role\nnew.hire@example.com,internal_user\n"], "users.csv", { type: "text/csv" }); + +const openUploadStep = async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("+ Bulk Invite Users")); + return user; +}; + describe("BulkCreateUsersButton", () => { it("should render", () => { const { getByText } = render(); expect(getByText("+ Bulk Invite Users")).toBeInTheDocument(); }); + + it("parses a CSV chosen through the file input", async () => { + await openUploadStep(); + + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(fileInput, { target: { files: [csvFile()] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("parses a CSV dropped onto the drop zone", async () => { + await openUploadStep(); + + const dropZone = screen.getByLabelText(/drag and drop your csv file here/i).closest("label"); + fireEvent.drop(dropZone as HTMLLabelElement, { dataTransfer: { files: [csvFile()], types: ["Files"] } }); + + expect(await screen.findByText("new.hire@example.com")).toBeInTheDocument(); + }); + + it("exposes the drop zone as a label for a keyboard-reachable file input", async () => { + await openUploadStep(); + + const fileInput = screen.getByLabelText(/drag and drop your csv file here/i) as HTMLInputElement; + expect(fileInput).toHaveAttribute("type", "file"); + expect(fileInput).toHaveAttribute("accept", ".csv"); + expect(fileInput).toBeVisible(); + + const dropZone = fileInput.closest("label") as HTMLLabelElement; + expect(fileInput.id).not.toBe(""); + expect(dropZone.htmlFor).toBe(fileInput.id); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); }); diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 8faff9ca72f..faaf2cbf7a5 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -1,14 +1,8 @@ import React, { useState, useEffect } from "react"; -import { Text } from "@tremor/react"; -import { Button, Modal, Table, Upload, Typography } from "antd"; -import { - UploadOutlined, - DownloadOutlined, - WarningOutlined, - FileTextOutlined, - DeleteOutlined, - FileExclamationOutlined, -} from "@ant-design/icons"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react"; import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking"; import Papa from "papaparse"; import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline"; @@ -38,6 +32,8 @@ interface UserData { invitation_link?: string; } +const PREVIEW_PAGE_SIZE = 5; + // Define an interface for the UI settings interface UISettings { PROXY_BASE_URL: string | null; @@ -61,6 +57,9 @@ const BulkCreateUsersButton: React.FC = ({ const [selectedFile, setSelectedFile] = useState(null); const [uiSettings, setUISettings] = useState(null); const [baseUrl, setBaseUrl] = useState("http://localhost:4000"); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const [pageIndex, setPageIndex] = useState(0); + const csvInputId = React.useId(); useEffect(() => { // Get UI settings @@ -93,7 +92,7 @@ const BulkCreateUsersButton: React.FC = ({ if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { setFileError(`Invalid file type: ${file.name}. Please upload a CSV file (.csv extension).`); NotificationsManager.fromBackend("Invalid file type. Please upload a CSV file."); - return false; + return; } // Check file size (limit to 5MB) @@ -101,7 +100,7 @@ const BulkCreateUsersButton: React.FC = ({ setFileError( `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Please upload a CSV file smaller than 5MB.`, ); - return false; + return; } Papa.parse(file, { @@ -262,7 +261,27 @@ const BulkCreateUsersButton: React.FC = ({ }, header: false, }); - return false; + }; + + const handleFileInputChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleFileUpload(file); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + const file = event.dataTransfer.files?.[0]; + if (file) { + handleFileUpload(file); + } }; const removeSelectedFile = () => { @@ -273,6 +292,12 @@ const BulkCreateUsersButton: React.FC = ({ setFileError(null); }; + const resetParsedData = () => { + setParsedData([]); + setParseError(null); + setPageIndex(0); + }; + const handleBulkCreate = async () => { setIsProcessing(true); const updatedData = parsedData.map((user) => ({ ...user, status: "pending" })); @@ -434,340 +459,395 @@ const BulkCreateUsersButton: React.FC = ({ window.URL.revokeObjectURL(url); }; - const columns = [ - { - title: "Row", - dataIndex: "rowNumber", - key: "rowNumber", - width: 80, - }, - { - title: "Email", - dataIndex: "user_email", - key: "user_email", - }, - { - title: "Role", - dataIndex: "user_role", - key: "user_role", - }, - { - title: "Teams", - dataIndex: "teams", - key: "teams", - }, - { - title: "Budget", - dataIndex: "max_budget", - key: "max_budget", - }, - { - title: "Status", - key: "status", - render: (_: any, record: UserData) => { - if (!record.isValid) { - return ( -
-
- - Invalid -
- {record.error && {record.error}} -
- ); - } - if (!record.status || record.status === "pending") { - return Pending; - } - if (record.status === "success") { - return ( -
-
- - Success -
- {record.invitation_link && ( -
-
- {record.invitation_link} - NotificationsManager.success("Invitation link copied!")} - > - - -
-
- )} -
- ); - } - return ( -
-
- - Failed -
- {record.error && {JSON.stringify(record.error)}} + const renderStatusCell = (record: UserData) => { + if (!record.isValid) { + return ( +
+
+ + Invalid
- ); - }, - }, - ]; + {record.error && {record.error}} +
+ ); + } + if (!record.status || record.status === "pending") { + return Pending; + } + if (record.status === "success") { + return ( +
+
+ + Success +
+ {record.invitation_link && ( +
+
+ {record.invitation_link} + NotificationsManager.success("Invitation link copied!")} + > + + +
+
+ )} +
+ ); + } + return ( +
+
+ + Failed +
+ {record.error && {JSON.stringify(record.error)}} +
+ ); + }; + + const pageCount = Math.max(1, Math.ceil(parsedData.length / PREVIEW_PAGE_SIZE)); + const currentPage = Math.min(pageIndex, pageCount - 1); + const visibleRows = parsedData.slice(currentPage * PREVIEW_PAGE_SIZE, (currentPage + 1) * PREVIEW_PAGE_SIZE); return ( <> - - setIsModalVisible(false)} - bodyStyle={{ maxHeight: "70vh", overflow: "auto" }} - footer={null} - > -
- {/* Step indicator */} - {parsedData.length === 0 ? ( -
-
-
- 1 -
-

Download and fill the template

-
- -
-

Add multiple users at once by following these steps:

-
    -
  1. Download our CSV template
  2. -
  3. Add your users' information to the spreadsheet
  4. -
  5. Save the file and upload it here
  6. -
  7. After creation, download the results file containing the Virtual Keys for each user
  8. -
- -
-

Template Column Names

-
-
-
-
-

user_email

-

User's email address (required)

-
-
-
-
-
-

user_role

-

- User's role (one of: "proxy_admin", "proxy_admin_viewer", - "internal_user", "internal_user_viewer") -

-
-
-
-
-
-

teams

-

- Comma-separated team IDs (e.g., "team-1,team-2") -

-
-
-
-
-
-

max_budget

-

Maximum budget as a number (e.g., "100")

-
-
-
-
-
-

budget_duration

-

- Budget reset period (e.g., "30d", "1mo") -

-
-
-
-
-
-

models

-

- Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") -

-
-
+ !open && setIsModalVisible(false)}> + + + Bulk Invite Users + +
+ {/* Step indicator */} + {parsedData.length === 0 ? ( +
+
+
+ 1
+

Download and fill the template

- -
+
+

Add multiple users at once by following these steps:

+
    +
  1. Download our CSV template
  2. +
  3. Add your users' information to the spreadsheet
  4. +
  5. Save the file and upload it here
  6. +
  7. After creation, download the results file containing the Virtual Keys for each user
  8. +
-
-
- 2 -
-

Upload your completed CSV

-
- -
- {selectedFile ? ( -
-
-
- {fileError ? ( - - ) : ( - - )} +
+

Template Column Names

+
+
+
- - {selectedFile.name} - - - {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} - +

user_email

+

User's email address (required)

- -
- - {fileError ? ( -
- - {fileError} -
- ) : ( - !csvStructureError && ( -
-
-
-
- Processing... +
+
+
+

user_role

+

+ User's role (one of: "proxy_admin", "proxy_admin_viewer", + "internal_user", "internal_user_viewer") +

+
+
+
+
+
+

teams

+

+ Comma-separated team IDs (e.g., "team-1,team-2") +

+
+
+
+
+
+

max_budget

+

Maximum budget as a number (e.g., "100")

+
+
+
+
+
+

budget_duration

+

+ Budget reset period (e.g., "30d", "1mo") +

+
+
+
+
+
+

models

+

+ Comma-separated allowed models (e.g., "gpt-3.5-turbo,gpt-4") +

- ) - )} -
- ) : ( - -
- -

Drag and drop your CSV file here

-

or

- -

Only CSV files (.csv) are supported

-
-
- )} - - {csvStructureError && ( -
-
- -
- - CSV Structure Error - - - {csvStructureError} - - - Please download our template and ensure your CSV follows the required format. -
- )} -
-
- ) : ( -
-
-
- 3 + +
-

- {parsedData.some((user) => user.status === "success" || user.status === "failed") - ? "User Creation Results" - : "Review and create users"} -

-
- {parseError && ( -
-
- -
- {parseError} - {parsedData.some((user) => !user.isValid) && ( -
    -
  • Check the table below for specific errors in each row
  • -
  • - Common issues include invalid email formats, missing required fields, or incorrect role - values -
  • -
  • Fix these issues in your CSV file and upload again
  • -
+
+
+ 2 +
+

Upload your completed CSV

+
+ +
+ {selectedFile ? ( +
+
+
+ {fileError ? ( + + ) : ( + + )} +
+ + {selectedFile.name} + + + {(selectedFile.size / 1024).toFixed(1)} KB • {new Date().toLocaleDateString()} + +
+
+ +
+ + {fileError ? ( +
+ + {fileError} +
+ ) : ( + !csvStructureError && ( +
+
+
+
+ Processing... +
+ ) )}
-
-
- )} + ) : ( + + )} -
-
-
- {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( -
- Creation Summary - - {parsedData.filter((d) => d.status === "success").length} Successful - - {parsedData.some((d) => d.status === "failed") && ( - - {parsedData.filter((d) => d.status === "failed").length} Failed - + {csvStructureError && ( +
+
+ +
+ CSV Structure Error +

{csvStructureError}

+

+ Please download our template and ensure your CSV follows the required format. +

+
+
+
+ )} +
+
+ ) : ( +
+
+
+ 3 +
+

+ {parsedData.some((user) => user.status === "success" || user.status === "failed") + ? "User Creation Results" + : "Review and create users"} +

+
+ + {parseError && ( +
+
+ +
+

{parseError}

+ {parsedData.some((user) => !user.isValid) && ( +
    +
  • Check the table below for specific errors in each row
  • +
  • + Common issues include invalid email formats, missing required fields, or incorrect role + values +
  • +
  • Fix these issues in your CSV file and upload again
  • +
)}
- ) : ( -
- User Preview - - {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid - +
+
+ )} + +
+
+
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") ? ( +
+

Creation Summary

+

+ {parsedData.filter((d) => d.status === "success").length} Successful +

+ {parsedData.some((d) => d.status === "failed") && ( +

+ {parsedData.filter((d) => d.status === "failed").length} Failed +

+ )} +
+ ) : ( +
+

User Preview

+

+ {parsedData.filter((d) => d.isValid).length} of {parsedData.length} users valid +

+
+ )} +
+ + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
)}
- {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
+ {parsedData.some((user) => user.status === "success") && ( +
+
+
+ +
+
+

User creation complete

+

+ Next step: Download the credentials file containing + Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests + through LiteLLM. +

+
+
+
+ )} + +
+
+ + + Row + Email + Role + Teams + Budget + Status + + + + {visibleRows.map((record) => ( + + {record.rowNumber} + {record.user_email} + {record.user_role} + {record.teams} + {record.max_budget} + {renderStatusCell(record)} + + ))} + +
+
+ + {pageCount > 1 && ( +
+ + Page {currentPage + 1} of {pageCount} + + +
+ )} + + {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+
)} -
- {parsedData.some((user) => user.status === "success") && ( -
-
-
- -
-
- User creation complete - - Next step: Download the credentials file containing - Virtual Keys and invitation links. Users will need these Virtual Keys to make LLM requests - through LiteLLM. - -
+ {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( +
+ +
-
- )} - - (!record.isValid ? "bg-red-50" : "")} - /> - - {!parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} - - {parsedData.some((user) => user.status === "success" || user.status === "failed") && ( -
- - -
- )} + )} + - - )} - - + )} + + + ); }; diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index c9bcc1eb5b9..62efa1dc372 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -1,8 +1,8 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Form } from "antd"; +import { FormProvider, useForm } from "react-hook-form"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall } from "./networking"; +import { alertingSettingsCall, getCallbackConfigsCall, getCallbacksCall, setCallbacksCall } from "./networking"; import Settings, { backendCallbackLogoSrc, CallbackSelector } from "./settings"; vi.mock("./networking", () => ({ @@ -114,42 +114,20 @@ describe("Settings", () => { }); }); - it("should display edit modal with fields when edit is clicked", async () => { - const mockCallback = { - name: "langfuse", - variables: { - LANGFUSE_PUBLIC_KEY: "test-public-key", - LANGFUSE_SECRET_KEY: "test-secret-key", - LANGFUSE_HOST: "https://test.langfuse.com", - SLACK_WEBHOOK_URL: null, - OPENMETER_API_KEY: null, - }, - }; - - const mockCallbackConfig = { - id: "langfuse", - displayName: "Langfuse", - dynamic_params: { - LANGFUSE_PUBLIC_KEY: { - type: "text", - ui_name: "Public Key", - required: true, - }, - LANGFUSE_SECRET_KEY: { - type: "password", - ui_name: "Secret Key", - required: true, - }, - LANGFUSE_HOST: { - type: "text", - ui_name: "Host", - required: false, - }, - }, - }; - + const openLangfuseEditModal = async () => { mockGetCallbacksCall.mockResolvedValue({ - callbacks: [mockCallback], + callbacks: [ + { + name: "langfuse", + variables: { + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://test.langfuse.com", + SLACK_WEBHOOK_URL: null, + OPENMETER_API_KEY: null, + }, + }, + ], available_callbacks: { langfuse: { litellm_callback_name: "langfuse", @@ -160,30 +138,118 @@ describe("Settings", () => { alerts: [], }); - mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); + mockGetCallbackConfigsCall.mockResolvedValue([ + { + id: "langfuse", + displayName: "Langfuse", + dynamic_params: { + LANGFUSE_PUBLIC_KEY: { type: "text", ui_name: "Public Key", required: true }, + LANGFUSE_SECRET_KEY: { type: "password", ui_name: "Secret Key", required: true }, + LANGFUSE_HOST: { type: "text", ui_name: "Host", required: false }, + }, + }, + ]); const user = userEvent.setup(); - const { getByText } = render(); + render(); await waitFor(() => { - expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + expect(screen.getByText("Active Logging Callbacks")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Langfuse")).toBeInTheDocument(); + expect(screen.getByText("Langfuse")).toBeInTheDocument(); }); await user.click(screen.getByTestId("callback-actions-langfuse-success")); await user.click(await screen.findByTestId("callback-action-edit")); await waitFor(() => { - expect(getByText("Edit Callback Settings")).toBeInTheDocument(); + expect(screen.getByText("Edit Callback Settings")).toBeInTheDocument(); + }); + + return user; + }; + + it("should display edit modal with fields when edit is clicked", async () => { + await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByText("Public Key")).toBeInTheDocument(); + expect(screen.getByText("Secret Key")).toBeInTheDocument(); + expect(screen.getByText("Host")).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText("Public Key")).toBeInTheDocument(); - expect(getByText("Secret Key")).toBeInTheDocument(); - expect(getByText("Host")).toBeInTheDocument(); + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + expect(screen.getByLabelText("Secret Key")).toHaveValue("test-secret-key"); + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + + const danglingLabels = [...document.querySelectorAll("label[for]")].filter( + (label) => document.getElementById(label.getAttribute("for") as string) === null, + ); + expect(danglingLabels).toEqual([]); + }); + + it("should post the edited callback variables when the edit modal is saved", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Host")).toHaveValue("https://test.langfuse.com"); + }); + + await user.clear(screen.getByLabelText("Host")); + await user.type(screen.getByLabelText("Host"), "https://edited.langfuse.com"); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + environment_variables: { + callback: "langfuse", + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://edited.langfuse.com", + }, + litellm_settings: { success_callback: ["langfuse"] }, + }); + }); + }); + + it("should block the edit submit when a required field is emptied", async () => { + const user = await openLangfuseEditModal(); + + await waitFor(() => { + expect(screen.getByLabelText("Public Key")).toHaveValue("test-public-key"); + }); + + await user.clear(screen.getByLabelText("Public Key")); + await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Save Changes" })); + + expect(await screen.findByText("Please enter the public key")).toBeInTheDocument(); + expect(vi.mocked(setCallbacksCall)).not.toHaveBeenCalled(); + }); + + it("should send the typed webhook url for an alert type when the alerting tab is saved", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole("tab", { name: "Alerting Types" })); + + const webhookInput = document.querySelector('input[name="llm_exceptions"]') as HTMLInputElement; + expect(webhookInput).not.toBeNull(); + await user.type(webhookInput, "https://hooks.example.com/llm-exceptions"); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledWith("token", { + general_settings: expect.objectContaining({ + alert_to_webhook_url: expect.objectContaining({ + llm_exceptions: "https://hooks.example.com/llm-exceptions", + }), + }), + }); }); }); @@ -252,6 +318,19 @@ describe("backendCallbackLogoSrc", () => { }); }); +const CallbackSelectorHarness = ({ + callbackConfigs, +}: { + callbackConfigs: { id: string; displayName: string; logo?: string }[]; +}) => { + const form = useForm>(); + return ( + + + + ); +}; + describe("CallbackSelector logos", () => { it("resolves backend logos per entry: bare filename, external url, and missing logo", async () => { const callbackConfigs = [ @@ -260,13 +339,9 @@ describe("CallbackSelector logos", () => { { id: "nologo", displayName: "NoLogo" }, ]; - render( -
- - , - ); + render(); - fireEvent.mouseDown(screen.getByRole("combobox")); + await userEvent.click(screen.getByRole("combobox")); expect(await screen.findByAltText("Langfuse logo")).toHaveAttribute("src", "/ui/assets/logos/langfuse.png"); expect(screen.getByAltText("Hosted logo")).toHaveAttribute("src", "https://logos.example.com/hosted.png"); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 904fd4d611e..34fd9af06db 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -1,31 +1,26 @@ -import { - Button, - Card, - Grid, - SelectItem, - Switch, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, -} from "@tremor/react"; import React, { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"; -import { Button as Button2, Form, Input, Modal, Select } from "antd"; +import { Field, FieldError, FieldLabel } from "@/components/shared/form/field"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import EmailSettings from "./email_settings"; import { Logo } from "@/components/molecules/logo/Logo"; import NotificationsManager from "./molecules/notifications_manager"; -import FormItem from "antd/es/form/FormItem"; import AlertingSettings from "./alerting/alerting_settings"; import CloudZeroCostTracking from "./CloudZeroCostTracking/CloudZeroCostTracking"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; @@ -46,6 +41,8 @@ interface SettingsPageProps { premiumUser: boolean; } +type CallbackFormValues = Record; + const assetsLogoFolder = "/ui/assets/logos/"; export const backendCallbackLogoSrc = (logo: string | null | undefined): string | undefined => { @@ -61,6 +58,9 @@ interface DynamicParamsFieldsProps { } const DynamicParamsFields: React.FC = ({ params, callbackConfigs, selectedCallback }) => { + const { register, formState } = useFormContext(); + const fieldIdPrefix = React.useId(); + if (!params || params.length === 0) { return null; } @@ -73,54 +73,51 @@ const DynamicParamsFields: React.FC = ({ params, callb const paramType = paramConfig.type || "text"; const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); const isRequired = paramConfig.required || false; + const fieldId = `${fieldIdPrefix}-${param}`; + const registration = register( + param, + isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined, + ); return ( - {fieldLabel} } - name={param} - key={param} - className="mb-4" - rules={ - isRequired - ? [ - { - required: true, - message: `Please enter the ${fieldLabel.toLowerCase()}`, - }, - ] - : undefined - } - > + + + {fieldLabel} + {paramType === "password" ? ( - ) : paramType === "number" ? ( ) : ( - + )} - + + ); })} ); }; +interface CallbackConfigOption { + id: string; + displayName: string; + logo?: string | null; +} + // Shared component for rendering callback selector interface CallbackSelectorProps { callbackConfigs: any[]; @@ -135,42 +132,64 @@ export const CallbackSelector: React.FC = ({ onCallbackChange, disabled = false, }) => { + const { control } = useFormContext(); + const inputId = React.useId(); + const selectedConfig = callbackConfigs.find((config) => config.id === selectedCallback) ?? null; + return ( - - - + rules={disabled ? undefined : { required: "Please select a callback" }} + render={({ field, fieldState }) => ( + + Callback + { + field.onChange(config?.id ?? ""); + onCallbackChange(config?.id ?? ""); + }} + isItemEqualToValue={(a: CallbackConfigOption, b: CallbackConfigOption) => a.id === b.id} + itemToStringLabel={(config: CallbackConfigOption) => config.displayName} + filter={(config: CallbackConfigOption, query: string) => + config.id.toLowerCase().includes(query.trim().toLowerCase()) + } + disabled={disabled} + > + + + No results + + {(callbackConfig: CallbackConfigOption) => ( + +
+
+ +
+ {callbackConfig.displayName} +
+
+ )} +
+
+
+ +
+ )} + /> ); }; @@ -206,8 +225,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [callbacks, setCallbacks] = useState([]); const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true); const [alerts, setAlerts] = useState([]); - const [addForm] = Form.useForm(); - const [editForm] = Form.useForm(); + const addForm = useForm({ shouldUnregister: true }); + const editForm = useForm({ shouldUnregister: true }); const [selectedCallback, setSelectedCallback] = useState(null); const [catchAllWebhookURL, setCatchAllWebhookURL] = useState(""); const [alertToWebhooks, setAlertToWebhooks] = useState>({}); @@ -254,7 +273,7 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const normalized = Object.fromEntries( Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]), ); - editForm.setFieldsValue({ + editForm.reset({ ...normalized, callback: selectedEditCallback.name, }); @@ -337,11 +356,11 @@ const Settings: React.FC = ({ accessToken, userRole, userID, if (isEdit) { setShowEditCallback(false); - editForm.resetFields(); + editForm.reset(); setSelectedEditCallback(null); } else { setShowAddCallbacksModal(false); - addForm.resetFields(); + addForm.reset(); setSelectedCallback(null); setSelectedCallbackParams([]); } @@ -383,6 +402,23 @@ const Settings: React.FC = ({ accessToken, userRole, userID, setSelectedCallbackParams(params); }; + const closeAddCallbackModal = () => { + setShowAddCallbacksModal(false); + setSelectedCallback(null); + setSelectedCallbackParams([]); + }; + + const cancelAddCallback = () => { + closeAddCallbackModal(); + addForm.reset(); + }; + + const closeEditCallbackModal = () => { + setShowEditCallback(false); + setSelectedEditCallback(null); + editForm.reset(); + }; + const handleSaveAlerts = async () => { if (!accessToken) { return; @@ -447,257 +483,216 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return (
- - - - Logging Callbacks - CloudZero Cost Tracking - Alerting Types - Alerting Settings - Email Alerts - - - - setShowAddCallbacksModal(true)} - onEdit={(cb) => { - setSelectedEditCallback(cb); - setShowEditCallback(true); - }} - onDelete={(cb) => handleDeleteCallback(cb)} - onTest={async (cb) => { - try { - await serviceHealthCheck(accessToken, cb.name); - NotificationsManager.success("Health check triggered"); - } catch (error) { - NotificationsManager.fromBackend(parseErrorMessage(error)); - } - }} - /> - - -
- -
-
- - - - Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} - - here - - -
- - - - - Slack Webhook URL - - +
+ + + Logging Callbacks + CloudZero Cost Tracking + Alerting Types + Alerting Settings + Email Alerts + + + setShowAddCallbacksModal(true)} + onEdit={(cb) => { + setSelectedEditCallback(cb); + setShowEditCallback(true); + }} + onDelete={(cb) => handleDeleteCallback(cb)} + onTest={async (cb) => { + try { + await serviceHealthCheck(accessToken, cb.name); + NotificationsManager.success("Health check triggered"); + } catch (error) { + NotificationsManager.fromBackend(parseErrorMessage(error)); + } + }} + /> + + +
+ +
+
+ + +

+ Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "} + + here + +

+
+ + + + + Slack Webhook URL + + - - {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( - - - {key == "region_outage_alerts" ? ( - premiumUser ? ( - handleSwitchChange(key)} - /> - ) : ( - - ) - ) : ( + + {Object.entries(alerts_to_UI_NAME).map(([key, value], index) => ( + + + {key == "region_outage_alerts" ? ( + premiumUser ? ( handleSwitchChange(key)} + onCheckedChange={() => handleSwitchChange(key)} /> - )} - - - {value} - - - - - - ))} - -
- + ) : ( + + ) + ) : ( + handleSwitchChange(key)} + /> + )} + + +

{value}

+
+ + + + + ))} + + + - - - - - - - - - - - - + + + + + + + + + + +
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - }} - footer={null} - > - - {" "} - LiteLLM Docs: Logging - + !open && closeAddCallbackModal()}> + + + Add Logging Callback + + + {" "} + LiteLLM Docs: Logging + -
- - - - -
- { - setShowAddCallbacksModal(false); - setSelectedCallback(null); - setSelectedCallbackParams([]); - addForm.resetFields(); - }} - disabled={isAddingCallback} - > - Cancel - - - {isAddingCallback ? "Adding..." : "Add Callback"} - -
- -
- - { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - footer={null} - > -
- {selectedEditCallback && ( - <> + + {}} - disabled={true} + selectedCallback={selectedCallback} + onCallbackChange={handleSelectedCallbackChange} /> - - )} -
- { - setShowEditCallback(false); - setSelectedEditCallback(null); - editForm.resetFields(); - }} - disabled={isUpdatingCallback} - > - Cancel - - { - editForm.submit(); - }} - loading={isUpdatingCallback} - disabled={isUpdatingCallback} - > - {isUpdatingCallback ? "Saving..." : "Save Changes"} - -
- -
+
+ + +
+ + + + + + !open && closeEditCallbackModal()}> + + + Edit Callback Settings + + +
+ {selectedEditCallback && ( + <> + {}} + disabled={true} + /> + + + + )} + +
+ + +
+ +
+
+
Date: Fri, 14 Aug 2026 09:36:16 -0700 Subject: [PATCH 13/13] refactor(ui): move the cost tracking components onto shadcn primitives Rebuilds the provider discount and margin tables, the pricing calculator and its multi-cost results on the in-repo shadcn layer, and swaps the imperative antd modal.confirm removals for AlertDialog. Row actions gained accessible names, which replace the Tremor stub mocks the tests used to drive. cost_tracking_settings keeps its two antd Modals and Forms, since they wrap the two add forms that stay on antd for now. --- ui/litellm-dashboard/eslint-suppressions.json | 14 +- .../cost_tracking_settings.test.tsx | 83 ++++- .../_components/cost_tracking_settings.tsx | 285 +++++++------- .../pricing_calculator/index.test.tsx | 39 +- .../_components/pricing_calculator/index.tsx | 227 ++++++------ .../multi_cost_results.test.tsx | 90 +++-- .../pricing_calculator/multi_cost_results.tsx | 349 +++++++++--------- .../provider_discount_table.test.tsx | 204 ++++++---- .../_components/provider_discount_table.tsx | 106 +++--- .../provider_margin_table.test.tsx | 158 +++++--- .../_components/provider_margin_table.tsx | 129 ++++--- 11 files changed, 982 insertions(+), 702 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 5e322598a10..7738a9e46d1 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -228,7 +228,7 @@ "count": 2 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": { @@ -239,9 +239,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx": { @@ -252,9 +249,6 @@ "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 2 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": { @@ -275,17 +269,11 @@ "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/use_discount_config.ts": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx index 0dae83ba808..c53b7b618b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.test.tsx @@ -8,25 +8,29 @@ import CostTrackingSettings from "./cost_tracking_settings"; // Mock sub-hooks so we can control their state without network calls const mockDiscountConfig = vi.fn(() => ({})); const mockMarginConfig = vi.fn(() => ({})); +const mockRemoveDiscount = vi.fn(); +const mockRemoveMargin = vi.fn(); + +const stableDiscountCallbacks = { + fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), + handleAddProvider: vi.fn().mockResolvedValue(true), + handleRemoveProvider: mockRemoveDiscount, + handleDiscountChange: vi.fn().mockResolvedValue(undefined), +}; + +const stableMarginCallbacks = { + fetchMarginConfig: vi.fn().mockResolvedValue(undefined), + handleAddMargin: vi.fn().mockResolvedValue(true), + handleRemoveMargin: mockRemoveMargin, + handleMarginChange: vi.fn().mockResolvedValue(undefined), +}; vi.mock("./use_discount_config", () => ({ - useDiscountConfig: () => ({ - discountConfig: mockDiscountConfig(), - fetchDiscountConfig: vi.fn().mockResolvedValue(undefined), - handleAddProvider: vi.fn().mockResolvedValue(true), - handleRemoveProvider: vi.fn().mockResolvedValue(undefined), - handleDiscountChange: vi.fn().mockResolvedValue(undefined), - }), + useDiscountConfig: () => ({ discountConfig: mockDiscountConfig(), ...stableDiscountCallbacks }), })); vi.mock("./use_margin_config", () => ({ - useMarginConfig: () => ({ - marginConfig: mockMarginConfig(), - fetchMarginConfig: vi.fn().mockResolvedValue(undefined), - handleAddMargin: vi.fn().mockResolvedValue(true), - handleRemoveMargin: vi.fn().mockResolvedValue(undefined), - handleMarginChange: vi.fn().mockResolvedValue(undefined), - }), + useMarginConfig: () => ({ marginConfig: mockMarginConfig(), ...stableMarginCallbacks }), })); vi.mock("./pricing_calculator/index", () => ({ @@ -153,6 +157,57 @@ describe("CostTrackingSettings", () => { }); }); + describe("removing a configured provider", () => { + const expandAndRemove = async (section: string, actionName: string) => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByText(section).closest("button")!); + await user.click(await screen.findByRole("button", { name: actionName })); + + return user; + }; + + it("should ask to confirm before removing a discount", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + await expandAndRemove("Provider Discounts", "Remove discount for openai"); + + expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument(); + expect(screen.getByText(/are you sure you want to remove the discount for openai\?/i)).toBeInTheDocument(); + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + }); + + it("should remove the discount once removal is confirmed", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveDiscount).toHaveBeenCalledWith("openai"); + }); + + it("should leave the discount in place when the confirmation is cancelled", async () => { + mockDiscountConfig.mockReturnValue({ openai: 0.05 }); + + const user = await expandAndRemove("Provider Discounts", "Remove discount for openai"); + await user.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(mockRemoveDiscount).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Remove" })).not.toBeInTheDocument(); + }); + + it("should remove the margin once removal is confirmed", async () => { + mockMarginConfig.mockReturnValue({ openai: 0.1 }); + + const user = await expandAndRemove("Fee/Price Margin", "Remove margin for openai"); + expect(screen.getByText(/are you sure you want to remove the margin for openai\?/i)).toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Remove" })); + + expect(mockRemoveMargin).toHaveBeenCalledWith("openai"); + }); + }); + describe("empty state messages", () => { it("should show the empty state message when no discount config is loaded", async () => { mockDiscountConfig.mockReturnValue({}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index b32e7afd756..ba2d830ae7b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -1,25 +1,25 @@ import React, { useState, useEffect } from "react"; -import { - Title, - Text, - Button, - Accordion, - AccordionHeader, - AccordionBody, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, -} from "@tremor/react"; +import { ChevronDown } from "lucide-react"; import { Modal, Form } from "antd"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; import ProviderMarginTable from "./provider_margin_table"; import AddMarginForm from "./add_margin_form"; import PricingCalculator from "./pricing_calculator/index"; -import { ExclamationCircleOutlined } from "@ant-design/icons"; import { DocsMenu } from "@/components/HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; @@ -31,6 +31,29 @@ const DOCS_LINKS = [ { label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" }, ]; +const REMOVAL_COPY = { + discount: { title: "Remove Provider Discount", noun: "discount" }, + margin: { title: "Remove Provider Margin", noun: "margin" }, +} as const; + +interface PendingRemoval { + kind: keyof typeof REMOVAL_COPY; + provider: string; + displayName: string; +} + +const SECTION_HEADER_CLASS = "group/section flex w-full items-center justify-between px-6 py-4 text-left"; + +const SectionHeader: React.FC<{ title: string; description: string }> = ({ title, description }) => ( + +
+ {title} + {description} +
+ +
+); + const CostTrackingSettings: React.FC = ({ userID, userRole, accessToken }) => { const [selectedProvider, setSelectedProvider] = useState(undefined); const [newDiscount, setNewDiscount] = useState(""); @@ -42,9 +65,9 @@ const CostTrackingSettings: React.FC = ({ userID, use const [percentageValue, setPercentageValue] = useState(""); const [fixedAmountValue, setFixedAmountValue] = useState(""); const [models, setModels] = useState([]); + const [pendingRemoval, setPendingRemoval] = useState(null); const [form] = Form.useForm(); const [marginForm] = Form.useForm(); - const [modal, contextHolder] = Modal.useModal(); const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; @@ -104,16 +127,18 @@ const CostTrackingSettings: React.FC = ({ userID, use handleAddProvider(); }; - const handleRemoveProvider = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Discount", - icon: , - content: `Are you sure you want to remove the discount for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeProvider(provider), - }); + const handleRemoveProvider = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "discount", provider, displayName: providerDisplayName }); + }; + + const handleConfirmRemoval = () => { + if (!pendingRemoval) return; + if (pendingRemoval.kind === "discount") { + removeProvider(pendingRemoval.provider); + } else { + removeMargin(pendingRemoval.provider); + } + setPendingRemoval(null); }; const handleAddMargin = async () => { @@ -141,16 +166,8 @@ const CostTrackingSettings: React.FC = ({ userID, use setMarginType("percentage"); }; - const handleRemoveMargin = async (provider: string, providerDisplayName: string) => { - modal.confirm({ - title: "Remove Provider Margin", - icon: , - content: `Are you sure you want to remove the margin for ${providerDisplayName}?`, - okText: "Remove", - okType: "danger", - cancelText: "Cancel", - onOk: () => removeMargin(provider), - }); + const handleRemoveMargin = (provider: string, providerDisplayName: string) => { + setPendingRemoval({ kind: "margin", provider, displayName: providerDisplayName }); }; if (!accessToken) { @@ -159,18 +176,16 @@ const CostTrackingSettings: React.FC = ({ userID, use return (
- {contextHolder} - {/* Header Section - Outside the card */}
- Cost Tracking Settings +

Cost Tracking Settings

- +

Configure cost discounts and margins for different LLM providers. Changes are saved automatically. - +

@@ -178,90 +193,78 @@ const CostTrackingSettings: React.FC = ({ userID, use
{/* Accordion 1: Provider Discounts - Only for proxy admins */} {isProxyAdmin && ( - - -
- Provider Discounts - - Apply percentage-based discounts to reduce costs for specific providers - -
-
- - - - Discounts - Test It - - - -
-
- + + + + + + Discounts + Test It + + +
+
+ +
+ {isFetching ? ( +
+

Loading configuration...

- {isFetching ? ( -
- Loading configuration... -
- ) : Object.keys(discountConfig).length > 0 ? ( - - ) : ( -
- - - - No provider discounts configured - - Click "Add Provider Discount" to get started - -
- )} -
- - -
- -
-
- - - - + ) : Object.keys(discountConfig).length > 0 ? ( + + ) : ( +
+ + + +

No provider discounts configured

+

Click "Add Provider Discount" to get started

+
+ )} +
+ + +
+ +
+
+ + + )} {/* Accordion 2: Fee/Price Margin - Only for proxy admins */} {isProxyAdmin && ( - - -
- Fee/Price Margin - - Add fees or margins to LLM costs for internal billing and cost recovery - -
-
- + + +
{isFetching ? (
- Loading configuration... +

Loading configuration...

) : Object.keys(marginConfig).length > 0 ? ( = ({ userID, use d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> - No provider margins configured - Click "Add Provider Margin" to get started +

No provider margins configured

+

Click "Add Provider Margin" to get started

)}
-
-
+ + )} {/* Accordion 3: Pricing Calculator - Available to all roles */} - - -
- Pricing Calculator - - Estimate LLM costs based on expected token usage and request volume - -
-
- + + +
-
-
+ +
+ {pendingRemoval && ( + !open && setPendingRemoval(null)}> + + + {REMOVAL_COPY[pendingRemoval.kind].title} + + Are you sure you want to remove the {REMOVAL_COPY[pendingRemoval.kind].noun} for{" "} + {pendingRemoval.displayName}? + + + + Cancel + + Remove + + + + + )} + @@ -328,10 +347,10 @@ const CostTrackingSettings: React.FC = ({ userID, use }} >
- +

Select a provider and set its discount percentage. Enter a value between 0% and 100% (e.g., 5 for a 5% discount). - +

= ({ userID, use }} >
- +

Select a provider (or "Global" for all providers) and configure the margin. You can use percentage-based or fixed amount. - +

+ within(screen.getByRole("table")) + .getAllByRole("row") + .filter((row) => within(row).queryAllByRole("combobox").length > 0); + +const deleteButtonIn = (row: HTMLElement): HTMLElement => { + const cells = within(row).getAllByRole("cell"); + return within(cells[cells.length - 1]).getByRole("button"); +}; + describe("PricingCalculator", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,8 +134,31 @@ describe("PricingCalculator", () => { it("should render column headers for Model, Input Tokens, and Output Tokens", () => { renderWithProviders(); - expect(screen.getByText("Model")).toBeInTheDocument(); - expect(screen.getByText("Input Tokens")).toBeInTheDocument(); - expect(screen.getByText("Output Tokens")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Input Tokens" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Output Tokens" })).toBeInTheDocument(); + }); + + it("should render a numeric field for input tokens, output tokens and requests", () => { + renderWithProviders(); + expect(screen.getAllByRole("spinbutton")).toHaveLength(3); + }); + + it("should offer a model picker per row", () => { + renderWithProviders(); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); + + it("should remove a row when its delete button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /add another model/i })); + const withTwoRows = dataRows(); + expect(withTwoRows).toHaveLength(2); + + await user.click(deleteButtonIn(withTwoRows[1])); + + expect(dataRows()).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx index 9b355e55c1c..f3bd74260ad 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx @@ -1,6 +1,10 @@ import React, { useState, useCallback } from "react"; -import { Table, Select, InputNumber, Button, Radio } from "antd"; -import { DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import { Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { SearchSelect } from "@/components/shared/SearchSelect"; import { PricingCalculatorProps, ModelEntry } from "./types"; import MultiCostResults from "./multi_cost_results"; import { useMultiCostEstimate } from "./use_multi_cost_estimate"; @@ -63,132 +67,115 @@ const PricingCalculator: React.FC = ({ accessToken, mode const multiModelResult = getMultiModelResult(entries); - const columns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - width: "35%", - render: (_: string, record: ModelEntry) => ( - + handleEntryChange(record.id, "input_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange(record.id, "output_tokens", e.target.value === "" ? 0 : Number(e.target.value)) + } + /> + + + + handleEntryChange( + record.id, + requestsField, + e.target.value === "" ? undefined : Number(e.target.value), + ) + } + /> + + + + + + ))} + + + + + + + + +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx index 04ef60469f0..b17dd2cb859 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.test.tsx @@ -85,6 +85,14 @@ function emptyMultiResult(): MultiModelResult { }; } +const expandToggle = (): HTMLElement => screen.getByRole("button", { name: /cost breakdown for / }); + +const shownBreakdown = (): HTMLElement | null => { + const label = screen.queryByText("Total/Request"); + if (label === null) return null; + return label.closest("[style*='display: none']") === null ? label : null; +}; + describe("MultiCostResults", () => { beforeEach(() => { vi.clearAllMocks(); @@ -200,40 +208,78 @@ describe("MultiCostResults", () => { expect(screen.getByRole("button", { name: /export/i })).toBeInTheDocument(); }); + it("should render a column header for each summary column", () => { + renderWithProviders(); + + expect(screen.getByRole("columnheader", { name: "Model" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Per Request" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Margin Fee" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Daily" })).toBeInTheDocument(); + }); + + it("should not show the model breakdown before the row is expanded", () => { + renderWithProviders(); + expect(shownBreakdown()).toBeNull(); + }); + it("should expand the model breakdown row when the expand button is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); - // The expand column renders a button (RightOutlined icon) for rows without errors - const expandButtons = screen.getAllByRole("button"); - // Find the small expand button (not the Export button) - const expandButton = expandButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - expect(expandButton).toBeDefined(); + await user.click(expandToggle()); - await user.click(expandButton!); - - // After expanding, the SingleModelBreakdown should be visible - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + expect(shownBreakdown()).toBeVisible(); + expect(screen.getByText("Daily Total (100 req)")).toBeInTheDocument(); }); - it("should show the collapse icon after expanding a row", async () => { + it("should collapse the model breakdown again on a second click", async () => { const user = userEvent.setup(); renderWithProviders(); - const getExpandButton = () => { - const allButtons = screen.getAllByRole("button"); - return allButtons.find((btn) => !btn.textContent?.toLowerCase().includes("export")); - }; + await user.click(expandToggle()); + expect(shownBreakdown()).toBeVisible(); - // Before expand: button has the "down" aria-label (RightOutlined renders as down in ant icons) - // Just verify clicking works and the breakdown content appears - await user.click(getExpandButton()!); - expect(screen.getByText("Total/Request")).toBeInTheDocument(); + await user.click(expandToggle()); + expect(shownBreakdown()).toBeNull(); + }); - // After a second click, the row collapses — content may be hidden or removed - await user.click(getExpandButton()!); - // The expanded content should no longer be visible - expect(screen.queryByText("Total/Request")).not.toBeVisible(); + it("should name the breakdown toggle and report its expanded state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByRole("button", { name: "Show cost breakdown for gpt-4" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggle); + + const collapseToggle = screen.getByRole("button", { name: "Hide cost breakdown for gpt-4" }); + expect(collapseToggle).toHaveAttribute("aria-expanded", "true"); + }); + + it("should not offer an expand toggle for a row that failed", () => { + renderWithProviders( + , + ); + + expect(screen.getAllByRole("button", { name: /cost breakdown for / })).toHaveLength(1); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx index 3ea7ea58127..b8375b930c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx @@ -1,7 +1,11 @@ import React, { useState } from "react"; -import { Text, Button } from "@tremor/react"; -import { Card, Statistic, Row, Col, Divider, Spin, Table, Tag } from "antd"; -import { LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { CostEstimateResponse } from "../types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { MultiModelResult } from "./types"; @@ -41,55 +45,57 @@ const SingleModelBreakdown: React.FC<{
{loading && (
- } size="small" /> + Updating...
)}
-
- Total/Request - {formatCost(result.cost_per_request)} +
+

Total/Request

+

{formatCost(result.cost_per_request)}

-
- Input Cost - {formatCost(result.input_cost_per_request)} +
+

Input Cost

+

{formatCost(result.input_cost_per_request)}

-
- Output Cost - {formatCost(result.output_cost_per_request)} +
+

Output Cost

+

{formatCost(result.output_cost_per_request)}

-
- Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(result.margin_cost_per_request)} - +

{periodCost !== null && (
-
- +
+

{periodLabel} Total ({formatRequests(periodRequests)} req) - - +

+

{formatCost(periodCost)} - +

-
- {periodLabel} Input - {formatCost(periodInputCost)} +
+

{periodLabel} Input

+

{formatCost(periodInputCost)}

-
- {periodLabel} Output - {formatCost(periodOutputCost)} +
+

{periodLabel} Output

+

{formatCost(periodOutputCost)}

-
- {periodLabel} Margin Fee - 0 ? "text-amber-600" : ""}`}> +
+

{periodLabel} Margin Fee

+

0 ? "text-amber-600" : ""}`}> {formatCost(periodMarginCost)} - +

)} @@ -124,7 +130,7 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && !isAnyLoading && !hasAnyError) { return (
- Select models above to see cost estimates +

Select models above to see cost estimates

); } @@ -133,8 +139,8 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && isAnyLoading && !hasAnyError) { return (
- } /> - Calculating costs... + +

Calculating costs...

); } @@ -143,10 +149,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe if (!hasAnyResult && hasAnyError) { return (
- +
- Cost Estimates - {isAnyLoading && } size="small" />} +

Cost Estimates

+ {isAnyLoading && }
{/* Error Messages */} {errorEntries.map((e) => ( @@ -174,102 +180,10 @@ const MultiCostResults: React.FC = ({ multiResult, timePe const hasMargin = multiResult.totals.margin_per_request > 0; const periodLabel = timePeriod === "day" ? "Daily" : "Monthly"; - const periodCostKey = timePeriod === "day" ? "daily_cost" : "monthly_cost"; - - const summaryColumns = [ - { - title: "Model", - dataIndex: "model", - key: "model", - render: ( - text: string, - record: { - id: string; - provider?: string | null; - error?: string | null; - loading?: boolean; - hasZeroCost?: boolean | null; - }, - ) => ( -
-
- {text} - {record.provider && ( - - {record.provider} - - )} - {record.loading && } size="small" />} -
- {record.error &&
⚠️ {record.error}
} - {record.hasZeroCost && !record.error && ( -
- ⚠️ No pricing data found for this model. Set base_model in config. -
- )} -
- ), - }, - { - title: "Per Request", - dataIndex: "cost_per_request", - key: "cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "Margin Fee", - dataIndex: "margin_cost_per_request", - key: "margin_cost_per_request", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - 0 ? "text-amber-600" : "text-gray-400"}`}> - {formatCost(value)} - - ), - }, - { - title: periodLabel, - dataIndex: periodCostKey, - key: "period_cost", - align: "right" as const, - render: (value: number | null, record: { error?: string | null }) => - record.error ? ( - - - ) : ( - {formatCost(value)} - ), - }, - { - title: "", - key: "expand", - width: 40, - render: (_: unknown, record: { id: string; error?: string | null }) => - record.error ? null : ( - - ), - }, - ]; // Include both valid results and errors in the table data const allEntriesWithModels = multiResult.entries.filter((e) => e.entry.model); const summaryData = allEntriesWithModels.map((e) => ({ - key: e.entry.id, id: e.entry.id, model: e.result?.model || e.entry.model, provider: e.result?.provider, @@ -284,78 +198,153 @@ const MultiCostResults: React.FC = ({ multiResult, timePe return (
- +
- Cost Estimates +

Cost Estimates

- {isAnyLoading && } size="small" />} + {isAnyLoading && }
{/* Combined Totals - Always show when there are results */} - - - - Total Per Request} - value={formatCost(multiResult.totals.cost_per_request)} - valueStyle={{ color: "#1890ff", fontSize: "18px", fontFamily: "monospace" }} - /> - - - Total {periodLabel}} - value={formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} - valueStyle={{ - color: timePeriod === "day" ? "#52c41a" : "#722ed1", - fontSize: "18px", - fontFamily: "monospace", - }} - /> - - + +
+
+ Total Per Request +
+ {formatCost(multiResult.totals.cost_per_request)} +
+
+
+ Total {periodLabel} +
+ {formatCost(timePeriod === "day" ? multiResult.totals.daily_cost : multiResult.totals.monthly_cost)} +
+
+
{hasMargin && ( - - +
+
Margin Fee/Request
-
+
{formatCost(multiResult.totals.margin_per_request)}
- - +
+
{periodLabel} Margin Fee
-
+
{formatCost(timePeriod === "day" ? multiResult.totals.daily_margin : multiResult.totals.monthly_margin)}
- - +
+
)} {/* Per-Model Table */} {summaryData.length > 0 && ( - { - const entry = validEntries.find((e) => e.entry.id === record.id); - if (!entry?.result) return null; +
+ + + Model + Per Request + Margin Fee + {periodLabel} + + Cost breakdown + + + + + {summaryData.map((record) => { + const isExpanded = expandedModels.has(record.id); + const periodCost = timePeriod === "day" ? record.daily_cost : record.monthly_cost; + const breakdownEntry = validEntries.find((e) => e.entry.id === record.id); return ( -
- -
+ + + +
+
+ {record.model} + {record.provider && ( + + {record.provider} + + )} + {record.loading && } +
+ {record.error && ( +
⚠️ {record.error}
+ )} + {record.hasZeroCost && !record.error && ( +
+ ⚠️ No pricing data found for this model. Set base_model in config. +
+ )} +
+
+ + {record.error ? ( + - + ) : ( + {formatCost(record.cost_per_request)} + )} + + + {record.error ? ( + - + ) : ( + 0 ? "text-amber-600" : "text-gray-400"}`} + > + {formatCost(record.margin_cost_per_request)} + + )} + + + {record.error ? ( + - + ) : ( + {formatCost(periodCost)} + )} + + + {!record.error && ( + + )} + +
+ {isExpanded && breakdownEntry?.result && ( + + +
+ +
+
+
+ )} +
); - }, - showExpandColumn: false, - }} - /> + })} +
+
)}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx index f9a0a40f07d..24280873cf0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_discount_table.test.tsx @@ -5,49 +5,21 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderDiscountTable from "./provider_discount_table"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); - -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, onKeyDown, placeholder, ...rest }: any) => ( - onValueChange?.(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - {...rest} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{(row.discount * 100).toFixed(1)}%

+ + + )} +
+ ); + }, width: "250px", }, { @@ -125,12 +138,15 @@ const ProviderDiscountTable: React.FC = ({ cell: (row) => { const { displayName } = getProviderLogoAndName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx index 170e61141b6..dd478571568 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/provider_margin_table.test.tsx @@ -6,43 +6,15 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import ProviderMarginTable from "./provider_margin_table"; import { Providers, providerLogoMap } from "@/components/provider_info_helpers"; -vi.mock("@heroicons/react/outline", () => ({ - TrashIcon: function TrashIcon() { - return null; - }, - PencilAltIcon: function PencilAltIcon() { - return null; - }, - CheckIcon: function CheckIcon() { - return null; - }, - XIcon: function XIcon() { - return null; - }, -})); +const ROW_ACTION_NAME = { + edit: /^Edit margin for /, + save: /^Save margin for /, + cancel: /^Cancel editing margin for /, + remove: /^Remove margin for /, +} as const; -vi.mock("@tremor/react", () => ({ - Table: ({ children }: any) => {children}
, - TableHead: ({ children }: any) => {children}, - TableRow: ({ children }: any) => {children}, - TableHeaderCell: ({ children }: any) => {children}, - TableBody: ({ children }: any) => {children}, - TableCell: ({ children }: any) => {children}, - Text: ({ children }: any) => {children}, - TextInput: ({ value, onValueChange, placeholder, autoFocus, className }: any) => ( - onValueChange?.(e.target.value)} - placeholder={placeholder} - autoFocus={autoFocus} - className={className} - /> - ), - Icon: ({ icon: IconComponent, onClick }: any) => { - const name = IconComponent?.displayName ?? IconComponent?.name ?? "icon"; - return + + + ) : ( + <> +

{formatMargin(row.margin)}

+ + + )} +
+ ); + }, width: "350px", }, { header: "Actions", cell: (row) => { - const displayName = row.provider === "global" ? "Global" : getProviderLogoAndName(row.provider).displayName; + const displayName = marginRowDisplayName(row.provider); return ( - onRemoveProvider(row.provider, displayName)} className="cursor-pointer hover:text-red-600" - /> + > + + ); }, width: "80px",