From f6fc6d299a7daa6fd405c9ddd0f4fabdc4b90432 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 22 Jul 2026 17:49:14 -0700 Subject: [PATCH 01/23] feat(ui): add standalone /connect route for MCP OAuth The MCP connect surface only existed as the Integrations tab inside the enable_chat_ui-gated /chat shell, so a keyless SSO user was bounced to the dashboard and could never reach it unless an admin enabled Chat UI first. Add a sibling /connect route with its own thin, auth-only layout that renders the same MCPAppsPanel without the chat-ui gate or chat shell. The user OAuth flow already returns to whatever URL started it, so no backend changes are needed. The chat playground and its gate are left unchanged. --- .../src/app/connect/layout.test.tsx | 65 +++++++++++++++++++ .../src/app/connect/layout.tsx | 20 ++++++ .../src/app/connect/page.test.tsx | 55 ++++++++++++++++ ui/litellm-dashboard/src/app/connect/page.tsx | 36 ++++++++++ 4 files changed, 176 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/connect/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/page.test.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/page.tsx diff --git a/ui/litellm-dashboard/src/app/connect/layout.test.tsx b/ui/litellm-dashboard/src/app/connect/layout.test.tsx new file mode 100644 index 000000000000..795a79d77e3c --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/layout.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ConnectLayout from "./layout"; + +const { mockUseAuthorized, state } = vi.hoisted(() => { + const state = { + accessToken: "token-123" as string | null, + isAuthorized: true, + isLoading: false, + }; + return { + state, + mockUseAuthorized: vi.fn(() => ({ + accessToken: state.accessToken, + isAuthorized: state.isAuthorized, + isLoading: state.isLoading, + })), + }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); +vi.mock("@/components/navbar", () => ({ default: () =>
})); +vi.mock("@/contexts/ThemeContext", () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe("ConnectLayout", () => { + afterEach(() => { + state.accessToken = "token-123"; + state.isAuthorized = true; + state.isLoading = false; + }); + + it("renders the connect surface for an authorized user without any chat-ui flag", () => { + render( + +
+ , + ); + expect(screen.getByTestId("navbar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("renders nothing when the user is not authorized", () => { + state.isAuthorized = false; + render( + +
+ , + ); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + }); + + it("renders nothing while authorization is still loading", () => { + state.isLoading = true; + render( + +
+ , + ); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/connect/layout.tsx b/ui/litellm-dashboard/src/app/connect/layout.tsx new file mode 100644 index 000000000000..63b1c484094b --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/layout.tsx @@ -0,0 +1,20 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; + +export default function ConnectLayout({ children }: { children: React.ReactNode }) { + const { accessToken, isAuthorized, isLoading } = useAuthorized(); + + if (isLoading || !isAuthorized) return null; + + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/connect/page.test.tsx b/ui/litellm-dashboard/src/app/connect/page.test.tsx new file mode 100644 index 000000000000..07b0e7a305a3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/page.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ConnectPage from "./page"; + +interface PanelProps { + accessToken: string; + selectedServers: string[]; + onChange: (servers: string[]) => void; +} + +const { mockReplace, mockPanel, state } = vi.hoisted(() => { + const state = { + oauthReturn: null as string | null, + }; + return { + state, + mockReplace: vi.fn(), + mockPanel: vi.fn((_props: PanelProps) =>
), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => ({ get: (key: string) => (key === "mcpOauthReturn" ? state.oauthReturn : null) }), +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "token-123" }), +})); +vi.mock("@/components/chat/MCPAppsPanel", () => ({ default: mockPanel })); + +describe("ConnectPage", () => { + afterEach(() => { + state.oauthReturn = null; + mockReplace.mockClear(); + mockPanel.mockClear(); + }); + + it("renders the MCP connect panel with the user's access token", () => { + render(); + expect(screen.getByTestId("mcp-apps-panel")).toBeInTheDocument(); + expect(mockPanel.mock.calls[0][0]).toMatchObject({ accessToken: "token-123", selectedServers: [] }); + }); + + it("strips the mcpOauthReturn param from the URL after an OAuth return", () => { + state.oauthReturn = "apps"; + window.history.replaceState({}, "", "/connect?mcpOauthReturn=apps"); + render(); + expect(mockReplace).toHaveBeenCalledWith("/connect"); + }); + + it("does not rewrite the URL when there is no OAuth return param", () => { + render(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/connect/page.tsx b/ui/litellm-dashboard/src/app/connect/page.tsx new file mode 100644 index 000000000000..84770915e46c --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import MCPAppsPanel from "@/components/chat/MCPAppsPanel"; + +function ConnectPageContent() { + const { accessToken } = useAuthorized(); + const [selectedServers, setSelectedServers] = useState([]); + const router = useRouter(); + const searchParams = useSearchParams(); + const oauthReturn = searchParams.get("mcpOauthReturn"); + + useEffect(() => { + if (oauthReturn) { + const url = new URL(window.location.href); + url.searchParams.delete("mcpOauthReturn"); + router.replace(url.pathname + url.search); + } + }, [oauthReturn, router]); + + return ( +
+ +
+ ); +} + +export default function ConnectPage() { + return ( + + + + ); +} From 9e7b1c0b3617da0d26706d794609dd8aec0e4ffa Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 22 Jul 2026 18:15:37 -0700 Subject: [PATCH 02/23] feat(ui): land keyless users on the connect page after login A standalone /ui/connect route is only reachable if something points a user at it. Post-login the dashboard always rendered the API-keys view, so a keyless SSO user saw an empty dashboard and no path to connect. Redirect to /ui/connect from the dashboard landing when the URL carries ?login=success, the user is not an admin, and their key list is empty. Gating on the post-login marker keeps the dashboard reachable afterwards, and an explicit stored return URL still wins. useKeys takes an optional enabled flag so the lookup only runs on that landing. --- .../src/app/(dashboard)/hooks/keys/useKeys.ts | 3 +- .../src/app/(dashboard)/page.test.tsx | 111 ++++++++++++++++++ .../src/app/(dashboard)/page.tsx | 24 +++- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 198058803eb5..0df809bc5829 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -101,13 +101,14 @@ export const useKeys = ( page: number, pageSize: number, options: KeyListCallOptions = {}, + enabled: boolean = true, ): UseQueryResult => { const { accessToken } = useAuthorized(); return useQuery({ queryKey: keyKeys.list({ page, limit: pageSize, ...options }), queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && enabled, staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx new file mode 100644 index 000000000000..6fb643330898 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import CreateKeyPage from "./page"; + +interface KeyRow { + token: string; +} + +const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => { + const state = { + login: "success" as string | null, + userRole: "Internal User", + keys: [] as KeyRow[], + keysLoading: false, + returnUrl: null as string | null, + }; + return { + state, + mockReplace: vi.fn(), + mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), + mockUseKeys: vi.fn((_page: number, _size: number, _opts: unknown, _enabled: boolean) => ({ + data: state.keysLoading ? undefined : { keys: state.keys, total_count: state.keys.length }, + isLoading: state.keysLoading, + })), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }), +})); +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ + authLoading: false, + token: "tok", + userRole: state.userRole, + userID: "user-1", + }), +})); +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys })); +vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ + default: () =>
, +})); +vi.mock("@/components/common_components/LoadingScreen", () => ({ + default: () =>
, +})); +vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); +vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref })); +vi.mock("@/utils/returnUrlUtils", () => ({ + buildLoginUrlWithReturn: (u: string) => u, + consumeReturnUrl: () => state.returnUrl, + getLoginUrl: () => "/login", + isValidReturnUrl: () => true, + normalizeUrlForCompare: (u: string) => u, + storeReturnUrl: () => undefined, +})); + +describe("dashboard landing keyless redirect", () => { + afterEach(() => { + state.login = "success"; + state.userRole = "Internal User"; + state.keys = []; + state.keysLoading = false; + state.returnUrl = null; + mockReplace.mockClear(); + mockUseKeys.mockClear(); + mockMigratedHref.mockClear(); + }); + + it("sends a keyless non-admin to the connect page after login", () => { + render(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect"); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + }); + + it("leaves an admin with no keys on the dashboard", () => { + state.userRole = "Admin"; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + + it("leaves a user who already has a key on the dashboard", () => { + state.keys = [{ token: "sk-abc" }]; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + + it("does not redirect outside the post-login landing, and skips the key lookup entirely", () => { + state.login = null; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(mockUseKeys.mock.calls[0][3]).toBe(false); + }); + + it("holds the loading screen while the key lookup is in flight", () => { + state.keysLoading = true; + render(); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("yields to an explicit return URL instead of the connect redirect", () => { + state.returnUrl = "/ui/models-and-endpoints"; + render(); + expect(mockReplace).not.toHaveBeenCalledWith("/mocked-ui/connect"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 02b2ccf5357a..883aff8b36b8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -3,6 +3,8 @@ import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { isAdminRole } from "@/utils/roles"; import { useAuth } from "@/contexts/AuthContext"; import { buildLoginUrlWithReturn, @@ -17,7 +19,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef } from "react"; function CreateKeyPageContent() { - const { authLoading, token } = useAuth(); + const { authLoading, token, userRole, userID } = useAuth(); const router = useRouter(); const searchParams = useSearchParams()!; @@ -26,6 +28,7 @@ function CreateKeyPageContent() { // Track if we've already attempted a return URL redirect to prevent race conditions const hasAttemptedReturnRedirectRef = useRef(false); + const didReturnRedirectRef = useRef(false); const redirectToLogin = authLoading === false && token === null; @@ -75,6 +78,7 @@ function CreateKeyPageContent() { // Only redirect if the return URL is different from the current URL // This prevents infinite redirect loops if (normalizedReturnUrl !== normalizedCurrentUrl) { + didReturnRedirectRef.current = true; window.location.replace(safeUrl.href); } } @@ -83,10 +87,26 @@ function CreateKeyPageContent() { useEffect(() => { if (!token) { hasAttemptedReturnRedirectRef.current = false; + didReturnRedirectRef.current = false; } }, [token]); - if (authLoading || redirectToLogin || isLegacyRedirect) { + const isPostLoginLanding = searchParams.get("login") === "success"; + const isSignedIn = !authLoading && Boolean(token); + const shouldCheckForKeys = isPostLoginLanding && isSignedIn && !isAdminRole(userRole); + const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); + const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; + const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; + + useEffect(() => { + if (isKeylessLanding && !didReturnRedirectRef.current) { + router.replace(migratedHref("connect")); + } + }, [isKeylessLanding, router]); + + const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingKeylessLanding; + + if (authLoading || isRedirecting) { return ; } From 9dda5d882c206e6003cf8c0bf54c7155f1736c50 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 22:23:45 -0700 Subject: [PATCH 03/23] test(ui): characterise the memory page's drawer and header before migrating Adds role/text-based coverage for MemoryDetailDrawer (which had none) and extends MemoryView's test past the mocked table to the header, the create modal trigger and the detail drawer round trip. Both are green against the current antd components, so they act as an unedited regression net for the shadcn migration that follows. --- .../_components/MemoryDetailDrawer.test.tsx | 88 +++++++++++++++++++ .../memory/_components/MemoryView.test.tsx | 47 +++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx new file mode 100644 index 000000000000..df65d5b534f9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { MemoryRow } from "@/components/networking"; + +import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; + +const makeMemory = (overrides: Partial = {}): MemoryRow => ({ + memory_id: "mem-1", + key: "user:profile", + value: "The user prefers concise answers.", + metadata: null, + user_id: "user-42", + team_id: "team-7", + created_at: "2024-05-01T12:00:00Z", + updated_at: "2024-05-02T12:00:00Z", + created_by: "alice", + updated_by: "bob", + ...overrides, +}); + +describe("MemoryDetailDrawer", () => { + it("renders nothing until a row is selected", () => { + render(); + + expect(screen.queryByText("Memory ID")).not.toBeInTheDocument(); + expect(screen.queryByText("Value")).not.toBeInTheDocument(); + }); + + it("shows the selected row's key, identifiers and value", () => { + render(); + + expect(screen.getByText("user:profile")).toBeInTheDocument(); + expect(screen.getByText("Memory ID")).toBeInTheDocument(); + expect(screen.getByText("mem-1")).toBeInTheDocument(); + expect(screen.getByText("User ID")).toBeInTheDocument(); + expect(screen.getByText("user-42")).toBeInTheDocument(); + expect(screen.getByText("Team ID")).toBeInTheDocument(); + expect(screen.getByText("team-7")).toBeInTheDocument(); + expect(screen.getByText("Value")).toBeInTheDocument(); + expect(screen.getByText("The user prefers concise answers.")).toBeInTheDocument(); + }); + + it("falls back to a dash for a memory with no owning user or team", () => { + render(); + + expect(screen.getAllByText("-")).toHaveLength(2); + expect(screen.queryByText("user-42")).not.toBeInTheDocument(); + }); + + it("omits the metadata block when the row carries no metadata", () => { + render(); + + expect(screen.queryByText("Metadata")).not.toBeInTheDocument(); + }); + + it("pretty-prints metadata as JSON when present", () => { + render(); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText('{ "tags": [ "example" ] }')).toBeInTheDocument(); + }); + + it("attributes the created and updated timestamps to their actors", () => { + render(); + + expect(screen.getByText(/^Created .* by alice$/)).toBeInTheDocument(); + expect(screen.getByText(/^Updated .* by bob$/)).toBeInTheDocument(); + }); + + it("renders an em dash for a timestamp the backend did not send", () => { + render(); + + expect(screen.getByText("Created —")).toBeInTheDocument(); + }); + + it("closes through the close control", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx index f415c99225a9..9ccef5357b90 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import React from "react"; import { describe, expect, it, vi } from "vitest"; @@ -12,6 +13,7 @@ interface CapturedTableProps { rowCount: number; data: MemoryRow[]; hasActiveSearch: boolean; + onViewClick: (row: MemoryRow) => void; } const captured = vi.hoisted(() => ({ current: null as CapturedTableProps | null })); @@ -42,4 +44,47 @@ describe("MemoryView", () => { expect(captured.current?.rowCount).toBe(0); expect(captured.current?.hasActiveSearch).toBe(false); }); + + it("heads the page with the Memory title and the /v1/memory scope note", () => { + renderView(null); + + expect(screen.getByRole("heading", { name: "Memory" })).toBeInTheDocument(); + expect(screen.getByText("/v1/memory")).toBeInTheDocument(); + expect(screen.getByText(/Scoped to memories visible to your user \/ team \(admins see all\)/)).toBeInTheDocument(); + }); + + it("opens the create modal from the New memory button", async () => { + const user = userEvent.setup(); + renderView(null); + + expect(screen.queryByText("Create memory")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /new memory/i })); + + expect(await screen.findByText("Create memory")).toBeInTheDocument(); + }); + + it("opens the detail drawer for the row the table hands back, and closes it again", async () => { + const user = userEvent.setup(); + renderView(null); + + expect(screen.queryByText("Memory ID")).not.toBeInTheDocument(); + + const row: MemoryRow = { + memory_id: "mem-drawer", + key: "user:profile", + value: "remembered", + metadata: null, + user_id: null, + team_id: null, + }; + act(() => captured.current?.onViewClick(row)); + + expect(await screen.findByText("Memory ID")).toBeInTheDocument(); + expect(screen.getByText("mem-drawer")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /close/i })); + + expect(screen.queryByText("mem-drawer")).not.toBeInTheDocument(); + }); }); From d2f872d04fd67435452beffe26ca3b6f5c23550b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 22:52:15 -0700 Subject: [PATCH 04/23] refactor(ui): migrate memory page to shadcn Replaces the antd Drawer with ui/sheet and the antd Button, Typography and Space usage with ui/button plus token utilities, and swaps the @ant-design PlusOutlined icon for lucide's Plus. Toasts now go through the shared MessageManager so the route no longer imports antd directly. The route's tests were written against the antd components in the previous commit and are unchanged here, so they pass on both implementations. MemoryEditModal is left alone because it is built on antd Form; the table already sits on the shared DataTable. --- ui/litellm-dashboard/eslint-suppressions.json | 18 --- .../memory/_components/MemoryDetailDrawer.tsx | 133 +++++++----------- .../memory/_components/MemoryView.tsx | 49 +++---- 3 files changed, 78 insertions(+), 122 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e31cdcae5965..01258f55ba62 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1128,29 +1128,11 @@ "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/memory/_components/MemoryView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx": { - "no-restricted-imports": { - "count": 3 - }, - "react-hooks/preserve-manual-memoization": { - "count": 4 - } - }, "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 970e088ec00d..63e6644d7e0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -1,17 +1,19 @@ "use client"; -import { Drawer, Space, Typography } from "antd"; import React from "react"; import { MemoryRow } from "@/components/networking"; - -const { Text, Paragraph } = Typography; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; interface MemoryDetailDrawerProps { row: MemoryRow | null; onClose: () => void; } +const CODE_CLASS = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground"; +const BLOCK_CLASS = "mt-1 rounded-md bg-muted p-3 font-mono whitespace-pre-wrap text-foreground"; +const LABEL_CLASS = "text-sm font-semibold text-foreground"; + function formatTimestamp(ts?: string): string { if (!ts) return "—"; try { @@ -24,90 +26,61 @@ function formatTimestamp(ts?: string): string { export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { return ( - - {row.key} - - ) : ( - "Memory" - ) - } - width={720} - destroyOnClose + onOpenChange={(open) => { + if (!open) onClose(); + }} > - {row && ( - - -
- - Memory ID - - - {row.memory_id} - + + + {row ? {row.key} : "Memory"} + + {row && ( +
+
+
+ Memory ID + {row.memory_id} +
+
+ User ID + + {row.user_id ?? "-"} + +
+
+ Team ID + + {row.team_id ?? "-"} + +
- - User ID - - {row.user_id ?? "-"} + Value +

{row.value}

-
- - Team ID - - {row.team_id ?? "-"} + {row.metadata !== undefined && row.metadata !== null && ( +
+ Metadata +

{JSON.stringify(row.metadata, null, 2)}

+
+ )} +
+ + Created {formatTimestamp(row.created_at)} + {row.created_by ? ` by ${row.created_by}` : ""} + + + + Updated {formatTimestamp(row.updated_at)} + {row.updated_by ? ` by ${row.updated_by}` : ""} +
- -
- Value - - {row.value} -
- {row.metadata !== undefined && row.metadata !== null && ( -
- Metadata - - {JSON.stringify(row.metadata, null, 2)} - -
- )} - ·} wrap size="small" style={{ color: "rgba(0,0,0,0.45)" }}> - - Created {formatTimestamp(row.created_at)} - {row.created_by ? ` by ${row.created_by}` : ""} - - - Updated {formatTimestamp(row.updated_at)} - {row.updated_by ? ` by ${row.updated_by}` : ""} - - - - )} - + )} + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx index fcb15978f477..a91110cf7803 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryView.tsx @@ -3,20 +3,19 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; -import { PlusOutlined } from "@ant-design/icons"; -import { Button, Space, Typography, message } from "antd"; +import { Plus } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import MessageManager from "@/components/molecules/message_manager"; +import { Button } from "@/components/ui/button"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { MemoryDetailDrawer } from "./MemoryDetailDrawer"; import { MemoryEditModal } from "./MemoryEditModal"; import { MemoryTable } from "./MemoryTable"; -const { Text, Paragraph, Title } = Typography; - interface MemoryViewProps { accessToken: string | null; userID: string | null; @@ -62,7 +61,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { // All three write endpoints share the same success/error plumbing: // - on success: invalidate the list query so every cached page // refetches from scratch (pagination + filter-aware). - // - on error: surface the message via antd `message.error`. + // - on error: surface the message via `MessageManager.error`. const invalidateList = useCallback( () => queryClient.invalidateQueries({ queryKey: [MEMORY_LIST_KEY] }), @@ -75,11 +74,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return createMemory(accessToken, args); }, onSuccess: (row) => { - message.success(`Created ${row.key}`); + MessageManager.success(`Created ${row.key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Save failed: ${err.message}`); + MessageManager.error(`Save failed: ${err.message}`); }, }); @@ -90,11 +89,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return updateMemory(accessToken, key, payload); }, onSuccess: (row) => { - message.success(`Updated ${row.key}`); + MessageManager.success(`Updated ${row.key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Save failed: ${err.message}`); + MessageManager.error(`Save failed: ${err.message}`); }, }); @@ -104,11 +103,11 @@ export const MemoryView: React.FC = ({ accessToken }) => { return deleteMemory(accessToken, key).then(() => key); }, onSuccess: (key) => { - message.success(`Deleted ${key}`); + MessageManager.success(`Deleted ${key}`); invalidateList(); }, onError: (err: Error) => { - message.error(`Delete failed: ${err.message}`); + MessageManager.error(`Delete failed: ${err.message}`); }, }); @@ -150,7 +149,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { try { metadataPayload = JSON.parse(metadataText); } catch { - message.error("Metadata must be valid JSON (or leave empty)."); + MessageManager.error("Metadata must be valid JSON (or leave empty)."); return false; } } @@ -177,19 +176,21 @@ export const MemoryView: React.FC = ({ accessToken }) => { }; return ( -
- -
+
+
+
- - Memory - - - Inspect what your agents have stored under /v1/memory. Scoped to memories visible to - your user / team (admins see all). - +

Memory

+

+ Inspect what your agents have stored under{" "} + + /v1/memory + + . Scoped to memories visible to your user / team (admins see all). +

-
@@ -209,7 +210,7 @@ export const MemoryView: React.FC = ({ accessToken }) => { onEditClick={handleEdit} onDeleteClick={handleDelete} /> - +
{/* Detail drawer */} setDetailRow(null)} /> From a928db1fab12ff190afc91266c0be8c58b6f11aa Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 22 Jul 2026 23:40:19 -0700 Subject: [PATCH 05/23] fix(ui): keep the memory detail sheet inside the viewport on narrow screens The migrated sheet asked for a flat 720px width while its only max-width came from the primitive's sm:-scoped rule, so below the sm breakpoint no cap applied at all: on a 375px viewport the sheet rendered 720px wide with its left edge at -305px, and because it is position:fixed there was no scroll to reach the hidden content. The primitive's own w-3/4 default did not have this problem; the fixed pixel width is what removed the guard. Caps the width to the viewport at every breakpoint and only asks for 720px from sm up. Verified in a browser at 375px, 700px and 1280px: the sheet is now 375, 700 and 720 wide respectively, always at left 0. --- .../app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx index 63e6644d7e0e..5c2fac2d5a97 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx @@ -32,7 +32,7 @@ export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) { if (!open) onClose(); }} > - + {row ? {row.key} : "Memory"} From 8929e09f4927a65672a161337f365e8546adfaf4 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 23 Jul 2026 09:56:01 -0700 Subject: [PATCH 06/23] fix(ui): gate the keyless connect redirect on internal-user roles isAdminRole compares against a list that mixes raw and formatted role strings: it holds raw org_admin but not the "Org Admin" that formatUserRole produces, and AuthContext stores the formatted form. A keyless org admin therefore read as a non-admin and was redirected to the connect page. Gate positively on internalUserRoles instead, which carries both representations, so the redirect targets the persona it is meant for and any role that is not unambiguously an internal user is left on the dashboard. The shared admin list is left alone: completing it would change org-admin access across every isAdminRole caller, which is a roles-policy decision of its own. --- ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx | 7 ++++--- ui/litellm-dashboard/src/app/(dashboard)/page.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index 6fb643330898..affcca401f25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -67,14 +67,15 @@ describe("dashboard landing keyless redirect", () => { mockMigratedHref.mockClear(); }); - it("sends a keyless non-admin to the connect page after login", () => { + it.each(["Internal User", "Internal Viewer"])("sends a keyless %s to the connect page after login", (role) => { + state.userRole = role; render(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect"); expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); }); - it("leaves an admin with no keys on the dashboard", () => { - state.userRole = "Admin"; + it.each(["Admin", "Admin Viewer", "Org Admin"])("leaves a keyless %s on the dashboard", (role) => { + state.userRole = role; render(); expect(mockReplace).not.toHaveBeenCalled(); expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 883aff8b36b8..682ba0bbcb2d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -4,7 +4,7 @@ import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { isAdminRole } from "@/utils/roles"; +import { internalUserRoles } from "@/utils/roles"; import { useAuth } from "@/contexts/AuthContext"; import { buildLoginUrlWithReturn, @@ -93,7 +93,7 @@ function CreateKeyPageContent() { const isPostLoginLanding = searchParams.get("login") === "success"; const isSignedIn = !authLoading && Boolean(token); - const shouldCheckForKeys = isPostLoginLanding && isSignedIn && !isAdminRole(userRole); + const shouldCheckForKeys = isPostLoginLanding && isSignedIn && internalUserRoles.includes(userRole); const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; From 531854db4eb8ad04e50330ef5c6bdc5389aea9d1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 23 Jul 2026 10:53:59 -0700 Subject: [PATCH 07/23] fix(ui): hold the landing until the role hydrates before deciding the redirect AuthContext sets token and clears authLoading in one effect, then a second token-keyed effect populates userRole, so there is a render where the user is signed in but userRole is still the initial empty string. The positive internalUserRoles check reads that interim role as non-internal, which let the api-keys dashboard paint for a frame before the role arrived and the keyless redirect ran. Treat "signed in on the post-login landing with an unhydrated role" as a resolving state that holds the loading screen, so the dashboard never flashes. Every login=success token carries a required user_role claim, so the role always hydrates within a tick and this cannot hang; it is scoped to the landing, so ordinary dashboard visits are unaffected. --- .../src/app/(dashboard)/page.test.tsx | 15 +++++++++++++++ ui/litellm-dashboard/src/app/(dashboard)/page.tsx | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index affcca401f25..89975f231aa4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -96,6 +96,21 @@ describe("dashboard landing keyless redirect", () => { expect(mockUseKeys.mock.calls[0][3]).toBe(false); }); + it("holds the loading screen on the landing until the role hydrates, instead of flashing the dashboard", () => { + state.userRole = ""; + render(); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("does not hold the dashboard for an unhydrated role outside the post-login landing", () => { + state.login = null; + state.userRole = ""; + render(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + it("holds the loading screen while the key lookup is in flight", () => { state.keysLoading = true; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 682ba0bbcb2d..c43ba12985da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -93,10 +93,12 @@ function CreateKeyPageContent() { const isPostLoginLanding = searchParams.get("login") === "success"; const isSignedIn = !authLoading && Boolean(token); + const isAwaitingRole = isPostLoginLanding && isSignedIn && userRole === ""; const shouldCheckForKeys = isPostLoginLanding && isSignedIn && internalUserRoles.includes(userRole); const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; + const isResolvingLanding = isAwaitingRole || isResolvingKeylessLanding; useEffect(() => { if (isKeylessLanding && !didReturnRedirectRef.current) { @@ -104,7 +106,7 @@ function CreateKeyPageContent() { } }, [isKeylessLanding, router]); - const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingKeylessLanding; + const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingLanding; if (authLoading || isRedirecting) { return ; From 2b77e8c4dba4567676c46bc19717877908ce91c1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 23 Jul 2026 15:38:42 -0700 Subject: [PATCH 08/23] fix(ui): keep cache leakage time range picker inline at narrow widths The card header used flex-wrap, so the date picker was the element that gave way when the row ran out of room; at higher browser zoom it dropped onto its own line under the description. Pin the picker with shrink-0 and let the title/description block shrink instead (min-w-0), so the copy wraps to a second line and the picker stays on the right. Below md the header stacks, since a 300px input plus its nowrap label leaves nothing usable beside it. --- .../cost-optimization/_components/CacheLeakageCard.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index bd0ecea9483d..4f1cfc495696 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -102,8 +102,8 @@ const CacheLeakageCard: React.FC = ({ activity }) => { -
-
+
+
Cache leakage by {dimension === "model" ? "model" : "virtual key"}

{subject} sending large volumes of uncached input with a low cache hit rate are likely missing prompt @@ -111,7 +111,9 @@ const CacheLeakageCard: React.FC = ({ activity }) => { {dimension === "model" ? " Limited to Anthropic (Claude) models, which support prompt caching." : ""}

- +
+ +
Date: Thu, 23 Jul 2026 14:55:12 -0700 Subject: [PATCH 09/23] feat(ui): show in the log drawer and session sidebar when an auto-router served a request The dashboard already receives the requested model name as model_group on every spend-log row, but LogEntry dropped the field, so nothing distinguished an auto-routed request from a direct one. Surface it precisely rather than by comparing requested against resolved: model_group differs from model for plain aliases and wildcard deployments too, so a bare mismatch tags almost every row and identifies nothing. The indication is driven instead by which deployments are auto-routers, resolved from every page of /v2/model/info and shared through context. The request drawer header names the router in a badge next to the provider; the session sidebar swaps the entry's leading icon. Rows that no auto-router served render exactly as before. --- ui/litellm-dashboard/eslint-suppressions.json | 2 +- .../hooks/models/useModels.test.ts | 162 ++++++++++++++++++ .../app/(dashboard)/hooks/models/useModels.ts | 60 +++++++ .../shared/table_cells/AutoRouterTag.test.tsx | 62 +++++++ .../shared/table_cells/AutoRouterTag.tsx | 54 ++++++ .../components/shared/table_cells/index.ts | 7 + .../LogDetailsDrawer/DrawerHeader.tsx | 5 + .../LogDetailsDrawer.test.tsx | 46 +++++ .../LogDetailsDrawer/LogDetailsDrawer.tsx | 21 ++- .../components/view_logs/RequestLogsPanel.tsx | 5 +- .../src/components/view_logs/columns.tsx | 1 + 11 files changed, 413 insertions(+), 12 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 4fa5528aab80..790965c119ea 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4318,7 +4318,7 @@ }, "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "no-nested-ternary": { - "count": 3 + "count": 2 }, "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 0bcc37d13897..f83ebd2622a6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -3,13 +3,17 @@ import { renderHook, waitFor } from "@testing-library/react"; import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + isAutoRouterDeployment, + selectAutoRouterModelGroups, useAllProxyModels, + useAutoRouterModelGroups, useInfiniteModelInfo, useModelHub, useModelsInfo, useSelectedTeamModels, useUserModels, type AllProxyModelsResponse, + type AutoRouterCandidateDeployment, type PaginatedModelInfoResponse, type ProxyModel, } from "./useModels"; @@ -918,3 +922,161 @@ describe("useInfiniteModelInfo", () => { expect(modelInfoCall).not.toHaveBeenCalled(); }); }); + +describe("isAutoRouterDeployment", () => { + const cases: [string, string | null | undefined, boolean][] = [ + ["base semantic auto-router", "auto_router/my_router", true], + ["complexity router", "auto_router/complexity_router", true], + ["adaptive router", "auto_router/adaptive_router", true], + ["quality router", "auto_router/quality_router", true], + ["plain provider alias", "anthropic/claude-haiku-4-5", false], + ["wildcard deployment", "openai/*", false], + ["name merely containing the prefix", "openai/auto_router/nope", false], + ["missing model", undefined, false], + ["null model", null, false], + ]; + + it.each(cases)("returns %s -> %s", (_label, litellmParamsModel, expected) => { + expect(isAutoRouterDeployment({ model_name: "some-group", litellm_params: { model: litellmParamsModel } })).toBe( + expected, + ); + }); + + it("returns false when litellm_params is absent", () => { + expect(isAutoRouterDeployment({ model_name: "some-group" })).toBe(false); + }); +}); + +describe("selectAutoRouterModelGroups", () => { + it("keeps only the public model_name of auto-router deployments", () => { + const deployments: AutoRouterCandidateDeployment[] = [ + { model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } }, + { model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }, + { model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } }, + { model_name: "cheap-router", litellm_params: { model: "auto_router/adaptive_router" } }, + ]; + + expect(selectAutoRouterModelGroups(deployments)).toEqual(new Set(["smart-router", "cheap-router"])); + }); + + it("drops auto-router deployments that have no public model_name", () => { + expect( + selectAutoRouterModelGroups([{ model_name: "", litellm_params: { model: "auto_router/complexity_router" } }]), + ).toEqual(new Set()); + }); + + it("returns an empty set for an empty model list", () => { + expect(selectAutoRouterModelGroups([])).toEqual(new Set()); + }); +}); + +describe("useAutoRouterModelGroups", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("resolves the set of auto-router model groups from the deployment list", async () => { + (modelInfoCall as any).mockResolvedValue({ + data: [ + { model_name: "smart-router", litellm_params: { model: "auto_router/complexity_router" } }, + { model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }, + ], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 1000, + }); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.has("smart-router")).toBe(true); + expect(result.current.has("claude-haiku")).toBe(false); + }); + + it("requests a single large page when the proxy reports only one page of deployments", async () => { + (modelInfoCall as any).mockResolvedValue({ + data: [], + total_count: 0, + current_page: 1, + total_pages: 1, + size: 1000, + }); + + renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(modelInfoCall).toHaveBeenCalled()); + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 1, 1000); + expect(modelInfoCall).toHaveBeenCalledTimes(1); + }); + + it("follows total_pages so an auto-router past the first page is still found", async () => { + (modelInfoCall as any).mockImplementation((_t: string, _u: string, _r: string, page: number) => { + if (page === 1) { + return Promise.resolve({ + data: [{ model_name: "claude-haiku", litellm_params: { model: "anthropic/claude-haiku-4-5" } }], + total_count: 3, + current_page: 1, + total_pages: 3, + size: 1000, + }); + } + if (page === 2) { + return Promise.resolve({ + data: [{ model_name: "claude-sonnet", litellm_params: { model: "anthropic/claude-sonnet-4-5" } }], + total_count: 3, + current_page: 2, + total_pages: 3, + size: 1000, + }); + } + return Promise.resolve({ + data: [{ model_name: "late-router", litellm_params: { model: "auto_router/complexity_router" } }], + total_count: 3, + current_page: 3, + total_pages: 3, + size: 1000, + }); + }); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(result.current.size).toBe(1)); + expect(result.current.has("late-router")).toBe(true); + expect(modelInfoCall).toHaveBeenCalledTimes(3); + expect(modelInfoCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin", 3, 1000); + }); + + it("returns an empty set before the model list resolves", () => { + (modelInfoCall as any).mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + expect(result.current.size).toBe(0); + }); + + it("returns an empty set when the model list request fails", async () => { + (modelInfoCall as any).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useAutoRouterModelGroups(), { wrapper }); + + await waitFor(() => expect(modelInfoCall).toHaveBeenCalled()); + expect(result.current.size).toBe(0); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index 113d1616e626..ad5e3c91ec31 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -24,6 +24,7 @@ export interface PaginatedModelInfoResponse { const modelKeys = createQueryKeys("models"); const modelHubKeys = createQueryKeys("modelHub"); +const autoRouterKeys = createQueryKeys("autoRouterModelGroups"); const allProxyModelsKeys = createQueryKeys("allProxyModels"); const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels"); const infiniteModelKeys = createQueryKeys("infiniteModels"); @@ -59,6 +60,65 @@ export const useModelsInfo = ( }); }; +const AUTO_ROUTER_MODEL_PREFIX = "auto_router/"; +const AUTO_ROUTER_LOOKUP_PAGE_SIZE = 1000; +const NO_AUTO_ROUTERS: ReadonlySet = new Set(); + +export interface AutoRouterCandidateDeployment { + model_name?: string | null; + litellm_params?: { model?: string | null } | null; +} + +export const isAutoRouterDeployment = (deployment: AutoRouterCandidateDeployment): boolean => + Boolean(deployment?.litellm_params?.model?.startsWith(AUTO_ROUTER_MODEL_PREFIX)); + +export const selectAutoRouterModelGroups = (deployments: AutoRouterCandidateDeployment[]): ReadonlySet => + new Set( + deployments + .filter(isAutoRouterDeployment) + .map((deployment) => deployment.model_name) + .filter((modelName): modelName is string => Boolean(modelName)), + ); + +const fetchAllModelDeployments = async ( + accessToken: string, + userId: string, + userRole: string, +): Promise => { + const firstPage: PaginatedModelInfoResponse = await modelInfoCall( + accessToken, + userId, + userRole, + 1, + AUTO_ROUTER_LOOKUP_PAGE_SIZE, + ); + const totalPages = firstPage?.total_pages ?? 1; + const remainingPages = await Promise.all( + Array.from({ length: Math.max(0, totalPages - 1) }, (_unused, index) => + modelInfoCall(accessToken, userId, userRole, index + 2, AUTO_ROUTER_LOOKUP_PAGE_SIZE), + ), + ); + return [firstPage, ...remainingPages].flatMap( + (page: PaginatedModelInfoResponse) => page?.data ?? [], + ) as AutoRouterCandidateDeployment[]; +}; + +export const useAutoRouterModelGroups = (): ReadonlySet => { + const { accessToken, userId, userRole } = useAuthorized(); + const { data } = useQuery>({ + queryKey: autoRouterKeys.list({ + filters: { + ...(userId && { userId }), + ...(userRole && { userRole }), + }, + }), + queryFn: async () => await fetchAllModelDeployments(accessToken!, userId!, userRole!), + enabled: Boolean(accessToken && userId && userRole), + select: selectAutoRouterModelGroups, + }); + return data ?? NO_AUTO_ROUTERS; +}; + export const useModelHub = () => { const { accessToken } = useAuthorized(); return useQuery({ diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.test.tsx new file mode 100644 index 000000000000..6c00f16be424 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { AutoRouterModelGroupsProvider, AutoRouterTag } from "./AutoRouterTag"; + +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useAutoRouterModelGroups: vi.fn(), +})); + +import { useAutoRouterModelGroups } from "@/app/(dashboard)/hooks/models/useModels"; + +const mockUseAutoRouterModelGroups = vi.mocked(useAutoRouterModelGroups); + +const renderInProvider = (ui: React.ReactNode) => + render({ui}); + +describe("AutoRouterTag", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("names the router that served the request", () => { + mockUseAutoRouterModelGroups.mockReturnValue(new Set(["smart-router"])); + + renderInProvider(); + + const tag = screen.getByTitle('Routed by auto-router "smart-router"'); + expect(tag).toHaveTextContent("smart-router"); + expect(tag.querySelector("svg")).not.toBeNull(); + }); + + it("does not tag a plain alias whose model group differs from the resolved model", () => { + mockUseAutoRouterModelGroups.mockReturnValue(new Set(["smart-router"])); + + renderInProvider(); + + expect(screen.queryByText("claude-haiku")).not.toBeInTheDocument(); + }); + + it("renders nothing while the model list is unavailable, so a routed row is never mislabelled", () => { + mockUseAutoRouterModelGroups.mockReturnValue(new Set()); + + const { container } = renderInProvider(); + + expect(container).toBeEmptyDOMElement(); + }); + + it.each([[undefined], [null], [""]])("renders nothing when the row carries no model group (%s)", (modelGroup) => { + mockUseAutoRouterModelGroups.mockReturnValue(new Set(["smart-router"])); + + const { container } = renderInProvider(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing outside a provider instead of requiring a QueryClient", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(mockUseAutoRouterModelGroups).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.tsx new file mode 100644 index 000000000000..3cc833e11e43 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/AutoRouterTag.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import { Waypoints } from "lucide-react"; + +import { useAutoRouterModelGroups } from "@/app/(dashboard)/hooks/models/useModels"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; + +const NO_AUTO_ROUTERS: ReadonlySet = new Set(); + +const AutoRouterModelGroupsContext = createContext>(NO_AUTO_ROUTERS); + +export function AutoRouterModelGroupsProvider({ children }: { children: ReactNode }) { + const autoRouterModelGroups = useAutoRouterModelGroups(); + + return ( + + {children} + + ); +} + +export function useIsAutoRoutedModelGroup(modelGroup?: string | null): boolean { + const autoRouterModelGroups = useContext(AutoRouterModelGroupsContext); + + return Boolean(modelGroup) && autoRouterModelGroups.has(modelGroup as string); +} + +export function AutoRouterIcon({ size = 12, className }: { size?: number; className?: string }) { + return ; +} + +export interface AutoRouterTagProps { + modelGroup?: string | null; + className?: string; +} + +export function AutoRouterTag({ modelGroup, className }: AutoRouterTagProps) { + const isAutoRouted = useIsAutoRoutedModelGroup(modelGroup); + + if (!isAutoRouted) return null; + + return ( + + + {modelGroup} + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts index 9fdd04d169cf..99b54f9aad00 100644 --- a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts +++ b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts @@ -1,3 +1,10 @@ +export { + AutoRouterTag, + AutoRouterIcon, + AutoRouterModelGroupsProvider, + useIsAutoRoutedModelGroup, + type AutoRouterTagProps, +} from "./AutoRouterTag"; export { CellTooltip } from "./cell_tooltip"; export { DateCell, formatCellDate, formatFullTimestamp, type DatePrecision } from "./date_cell"; export { IdCell, type IdCellVariant } from "./id_cell"; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index 8c0b679a846e..0bcb9050d7de 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -2,6 +2,7 @@ import { Button, Space, Tag, Tooltip, Typography } from "antd"; import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; import moment from "moment"; import { LogEntry } from "../columns"; +import { AutoRouterTag } from "@/components/shared/table_cells"; import { getProviderLogoAndName } from "../../provider_info_helpers"; import { DRAWER_HEADER_PADDING, @@ -57,6 +58,7 @@ export function DrawerHeader({ {/* Row 0: Model + Provider with Logo */} @@ -80,10 +82,12 @@ export function DrawerHeader({ */ function ModelProviderSection({ model, + modelGroup, providerLogo, providerName, }: { model: string; + modelGroup?: string; providerLogo?: string; providerName?: string; }) { @@ -109,6 +113,7 @@ function ModelProviderSection({ {providerName} )} + ); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.test.tsx index 5a49cccec701..b913f7c8d21d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { sessionSpendLogsCall } from "../../networking"; import { LogEntry } from "../columns"; +import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; vi.mock("../../networking", () => ({ sessionSpendLogsCall: vi.fn(), @@ -22,6 +23,10 @@ vi.mock("./DrawerHeader", () => ({ DrawerHeader: () => null, })); +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useAutoRouterModelGroups: vi.fn(() => new Set(["smart-router"])), +})); + const makeLog = (overrides: Partial): LogEntry => ({ request_id: "req", api_key: "", @@ -118,3 +123,44 @@ describe("LogDetailsDrawer session sidebar sorting", () => { await waitFor(() => expect(sidebarEventNames()).toEqual(["tool-early", "llm-late", "llm-early", "tool-late"])); }); }); + +describe("LogDetailsDrawer session sidebar auto-router icon", () => { + const routedSessionLogs = [ + makeLog({ request_id: "routed", model: "claude-opus-4-8", model_group: "smart-router" }), + makeLog({ request_id: "direct", model: "claude-haiku-4-5", model_group: "claude-haiku" }), + ]; + + const renderRoutedSession = () => { + vi.mocked(sessionSpendLogsCall).mockResolvedValue({ data: routedSessionLogs, total: 2, total_pages: 1 }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {}} logEntry={null} sessionId="session-1" accessToken="token" /> + + , + ); + }; + + const rowFor = (label: string): HTMLElement => { + const row = Array.from(document.body.querySelectorAll("button")).find((button) => + button.textContent?.includes(label), + ); + if (!row) throw new Error(`no sidebar row for ${label}`); + return row; + }; + + it("marks the auto-routed entry with the router icon and leaves a direct call on the default icon", async () => { + renderRoutedSession(); + + await waitFor(() => expect(screen.queryByText("claude-opus-4-8")).not.toBeNull()); + + const routedRow = rowFor("claude-opus-4-8"); + const directRow = rowFor("claude-haiku-4-5"); + + expect(routedRow.querySelector(".lucide-waypoints")).not.toBeNull(); + expect(routedRow.querySelector(".lucide-sparkles")).toBeNull(); + expect(directRow.querySelector(".lucide-sparkles")).not.toBeNull(); + expect(directRow.querySelector(".lucide-waypoints")).toBeNull(); + }); +}); 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 cc087a611b0b..cdf8e0b1c602 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -3,6 +3,7 @@ import { Button, Drawer, Segmented } from "antd"; import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons"; import { Bot, Sparkles, Wrench } from "lucide-react"; import { LogEntry } from "../columns"; +import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants"; import { getEventDisplayName } from "../utils"; import { DrawerHeader } from "./DrawerHeader"; @@ -46,9 +47,17 @@ interface TraceEventRowProps { onClick: () => void; } +const TRACE_EVENT_ICON_CLASS = "text-slate-500 shrink-0"; + +function TraceEventIcon({ callType, isAutoRouted }: { callType: string; isAutoRouted: boolean }) { + if (MCP_CALL_TYPES.includes(callType)) return ; + if (AGENT_CALL_TYPES.includes(callType)) return ; + if (isAutoRouted) return ; + return ; +} + function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) { - const isMcp = MCP_CALL_TYPES.includes(row.call_type); - const isAgent = AGENT_CALL_TYPES.includes(row.call_type); + const isAutoRouted = useIsAutoRoutedModelGroup(row.model_group); const durationValue = row.request_duration_ms != null ? (row.request_duration_ms / 1000).toFixed(3) @@ -65,13 +74,7 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) { onClick={onClick} >
- {isMcp ? ( - - ) : isAgent ? ( - - ) : ( - - )} + {getEventDisplayName(row.call_type, row.model)} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 05044dda791f..100b3422435f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -5,6 +5,7 @@ import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } fr import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; import { internalUserRoles } from "../../utils/roles"; import type { KeyResponse } from "../key_team_helpers/key_list"; import { keyInfoV1Call } from "../networking"; @@ -202,7 +203,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, } return ( - <> +

Request Logs

@@ -259,6 +260,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, onSelectLog={setSelectedLog} startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")} /> - +
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index a4b8015892a2..d4f784bf1655 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -16,6 +16,7 @@ export type LogEntry = { team_id: string; model: string; model_id: string; + model_group?: string; api_base?: string; call_type: string; spend: number; From 212421207ecc59d35da919166f0bf372b90d49c2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:25:05 -0700 Subject: [PATCH 10/23] test(ui): characterise budgets, skills and ui-theme panels before migration Adds a role/text-based characterisation test for UIThemeSettings, which had none, and extends the skills panel test to cover the delete confirmation. Both are green against the current antd/Tremor components so they can prove the shadcn migration keeps behaviour identical without being edited. --- .../ClaudeCodePluginsPanel.test.tsx | 82 ++++++++++- .../ui-theme/UIThemeSettings.test.tsx | 128 ++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx index 52f3dc21b7ad..67bab398bd24 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getClaudeCodePluginsList } from "@/components/networking"; +import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; +import type { Plugin } from "@/components/claude_code_plugins/types"; import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; @@ -12,8 +14,27 @@ vi.mock("@/components/networking", () => ({ vi.mock("./PluginTable", () => ({ __esModule: true, - default: ({ isLoading }: { isLoading: boolean }) => ( -
{isLoading ? "table-loading" : "table-loaded"}
+ default: ({ + isLoading, + pluginsList, + onDeleteClick, + }: { + isLoading: boolean; + pluginsList: Plugin[]; + onDeleteClick: (pluginName: string, displayName: string) => void; + }) => ( +
+ {isLoading ? "table-loading" : "table-loaded"} + {pluginsList.map((plugin) => ( + + ))} +
), })); @@ -21,6 +42,14 @@ vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); +const mockDeleteClaudeCodePlugin = vi.mocked(deleteClaudeCodePlugin); + +const skill: Plugin = { + id: "plugin-1", + name: "my-skill", + source: { source: "github", repo: "acme/my-skill" }, + enabled: true, +}; describe("ClaudeCodePluginsPanel loading state", () => { beforeEach(() => { @@ -48,3 +77,48 @@ describe("ClaudeCodePluginsPanel loading state", () => { expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); }); }); + +describe("ClaudeCodePluginsPanel delete confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClaudeCodePluginsList.mockResolvedValue({ plugins: [skill], count: 1 }); + }); + + it("should ask for confirmation before deleting and name the skill", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + + expect(await screen.findByText(/are you sure you want to delete skill/i)).toBeInTheDocument(); + expect(screen.getByText("my-skill")).toBeInTheDocument(); + expect(screen.getByText("This action cannot be undone.")).toBeInTheDocument(); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); + + it("should delete the skill and refresh the list once confirmed", async () => { + const user = userEvent.setup(); + mockDeleteClaudeCodePlugin.mockResolvedValue({}); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(mockDeleteClaudeCodePlugin).toHaveBeenCalledWith("sk-test", "my-skill")); + await waitFor(() => expect(mockGetClaudeCodePluginsList).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + }); + + it("should not delete the skill when the confirmation is cancelled", async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByTestId("row-delete-plugin-1")); + await screen.findByText(/are you sure you want to delete skill/i); + await user.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => expect(screen.queryByText(/are you sure you want to delete skill/i)).not.toBeInTheDocument()); + expect(mockDeleteClaudeCodePlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx new file mode 100644 index 000000000000..20f8960ae3af --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.test.tsx @@ -0,0 +1,128 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import NotificationsManager from "@/components/molecules/notifications_manager"; + +import UIThemeSettings from "./UIThemeSettings"; + +const setLogoUrl = vi.fn(); +const setFaviconUrl = vi.fn(); + +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "", + getGlobalLitellmHeaderName: () => "Authorization", +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + __esModule: true, + default: { success: vi.fn(), fromBackend: vi.fn() }, +})); + +const LOGO_PLACEHOLDER = "https://example.com/logo.png"; +const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico"; + +const okResponse = (values: Record = {}) => + Promise.resolve({ ok: true, json: () => Promise.resolve({ values }) } as Response); + +const fetchMock = vi.fn(); + +const patchCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === "PATCH"); + +const bodyOf = (call: Parameters) => JSON.parse(String(call[1]?.body)); + +describe("UIThemeSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockImplementation(() => okResponse()); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should render nothing without an access token", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should load the saved logo and favicon urls into the inputs", async () => { + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("https://cdn.example.com/fav.ico"); + expect(setLogoUrl).toHaveBeenCalledWith("https://cdn.example.com/logo.svg"); + expect(setFaviconUrl).toHaveBeenCalledWith("https://cdn.example.com/fav.ico"); + }); + + it("should save the entered urls and report success", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + await user.type(screen.getByPlaceholderText(LOGO_PLACEHOLDER), "https://a.test/logo.png"); + await user.type(screen.getByPlaceholderText(FAVICON_PLACEHOLDER), "https://a.test/fav.ico"); + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ + logo_url: "https://a.test/logo.png", + favicon_url: "https://a.test/fav.ico", + }); + await waitFor(() => + expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings updated successfully!"), + ); + }); + + it("should surface a backend failure when saving fails", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + fetchMock.mockImplementation(() => Promise.resolve({ ok: false } as Response)); + + await user.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => + expect(NotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update theme settings"), + ); + expect(NotificationsManager.success).not.toHaveBeenCalled(); + }); + + it("should clear both inputs and persist nulls when resetting to default", async () => { + const user = userEvent.setup(); + fetchMock.mockImplementation(() => + okResponse({ logo_url: "https://cdn.example.com/logo.svg", favicon_url: "https://cdn.example.com/fav.ico" }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo.svg"); + }); + + await user.click(screen.getByRole("button", { name: "Reset to Default" })); + + await waitFor(() => expect(patchCalls()).toHaveLength(1)); + expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null }); + expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue(""); + expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue(""); + expect(setLogoUrl).toHaveBeenLastCalledWith(null); + expect(setFaviconUrl).toHaveBeenLastCalledWith(null); + await waitFor(() => expect(NotificationsManager.success).toHaveBeenCalledWith("Theme settings reset to default!")); + }); +}); From f231d46375e9e56553ef50ca96dccba4af94beac Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:26:34 -0700 Subject: [PATCH 11/23] test(ui): decouple access-groups, vector-stores and organizations tests from antd markup Prepares the shadcn migration of these three routes by removing every assertion that depends on the current component library, so the same tests can gate the migration without being edited. FiltersButton and its OrganizationFilters consumer both asserted on the ".ant-badge" wrapper class; they now assert the active-filter indicator element itself, and FiltersButton additionally asserts that it is absent when there are no active filters. TestVectorStoreTab drove the antd Select with fireEvent.mouseDown and picked options by node; it now clicks through the combobox role and the option text, which works against any listbox implementation. The vector-stores index test relied on Tremor mounting every TabPanel at once, so it read the Manage tab's table without ever opening that tab. It now clicks the tab first, which is what a user does and what any tabs implementation supports. VectorStoreTester had no test at all, so this adds a characterisation suite covering the empty state, the blank-query guard, the search call and its rendered result, result expansion, Enter versus Shift+Enter, the failure path and clearing history. All of these pass against the current antd and Tremor components --- .../OrganizationFilters.test.tsx | 7 +- .../_components/TestVectorStoreTab.test.tsx | 28 ++-- .../_components/VectorStoreTester.test.tsx | 156 ++++++++++++++++++ .../vector-stores/_components/index.test.tsx | 9 + .../Filters/FiltersButton.test.tsx | 14 +- 5 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx index 37eeaf4c2af9..c1eda1be670a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx @@ -106,7 +106,7 @@ describe("OrganizationFilters", () => { org_alias: "test org", }; - render( + const { container } = render( { />, ); - const filtersButton = screen.getByRole("button", { name: /^filters$/i }); - const badgeWrapper = filtersButton.closest(".ant-badge"); - expect(badgeWrapper).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^filters$/i })).toBeInTheDocument(); + expect(container.querySelector("sup")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.test.tsx index c1322dced609..1d7bce34e041 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.test.tsx @@ -1,4 +1,5 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; import TestVectorStoreTab from "./TestVectorStoreTab"; import { VectorStore } from "@/components/vector_store_management/types"; @@ -60,31 +61,24 @@ describe("TestVectorStoreTab", () => { expect(screen.getByTestId("tester-access-token")).toHaveTextContent("test-token"); }); - it("should update VectorStoreTester when selecting different vector store", () => { + it("should update VectorStoreTester when selecting different vector store", async () => { + const user = userEvent.setup(); render(); - // Find the select component - const selectElement = screen.getByRole("combobox"); + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Test Store 2")); - // Change selection - fireEvent.mouseDown(selectElement); - - // Wait for options to appear and click the second one - const option2 = screen.getByText("Test Store 2"); - fireEvent.click(option2); - - // Verify the tester component updated expect(screen.getByTestId("tester-vector-store-id")).toHaveTextContent("vs_456"); }); - it("should display vector store names in select options", () => { + it("should display vector store names in select options", async () => { + const user = userEvent.setup(); render(); - const selectElement = screen.getByRole("combobox"); - fireEvent.mouseDown(selectElement); + await user.click(screen.getByRole("combobox")); - // Use getAllByText since the selected value also shows the name - expect(screen.getAllByText("Test Store 1").length).toBeGreaterThan(0); + // The selected store's name may also render in the trigger, so only require at least one match. + expect((await screen.findAllByText("Test Store 1")).length).toBeGreaterThan(0); expect(screen.getByText("Test Store 2")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.test.tsx new file mode 100644 index 000000000000..cbabcc6dca5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { vectorStoreSearchCall } from "@/components/networking"; + +import { VectorStoreTester } from "./VectorStoreTester"; + +vi.mock("@/components/networking", () => ({ + vectorStoreSearchCall: vi.fn(), +})); + +const mockWarning = vi.fn(); +vi.mock("@/components/molecules/message_manager", () => ({ + __esModule: true, + default: { warning: (...args: unknown[]) => mockWarning(...args) }, +})); + +const mockFromBackend = vi.fn(); +const mockSuccess = vi.fn(); +vi.mock("@/components/molecules/notifications_manager", () => ({ + __esModule: true, + default: { + fromBackend: (...args: unknown[]) => mockFromBackend(...args), + success: (...args: unknown[]) => mockSuccess(...args), + }, +})); + +const mockSearch = vi.mocked(vectorStoreSearchCall); + +const searchResponse = { + object: "vector_store.search_results.page", + search_query: "hello", + data: [ + { + score: 0.91234, + content: [{ text: "the quick brown fox", type: "text" }], + file_id: "file-1", + filename: "notes.txt", + attributes: { source: "manual" }, + }, + ], +}; + +const EMPTY_STATE = "Test your vector store by entering a search query below"; + +const renderTester = () => render(); + +const queryInput = () => screen.getByPlaceholderText(/enter your search query/i); +const searchButton = () => screen.getByRole("button", { name: /search/i }); + +describe("VectorStoreTester", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSearch.mockResolvedValue(searchResponse); + }); + + it("shows the empty state before any search has run", () => { + renderTester(); + expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /clear history/i })).not.toBeInTheDocument(); + }); + + it("does not search until a non-blank query is entered", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.click(searchButton()); + expect(mockSearch).not.toHaveBeenCalled(); + + await user.type(queryInput(), "hello"); + await user.click(searchButton()); + + await waitFor(() => expect(mockSearch).toHaveBeenCalledWith("sk-test", "vs_123", "hello")); + }); + + it("renders the returned result and clears the query input", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.type(queryInput(), "hello"); + await user.click(searchButton()); + + expect(await screen.findByText("Result 1")).toBeInTheDocument(); + expect(screen.getByText("1 results")).toBeInTheDocument(); + expect(screen.getByText("Score: 0.9123")).toBeInTheDocument(); + expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument(); + await waitFor(() => expect(queryInput()).toHaveValue("")); + }); + + it("expands a result to reveal its content and metadata", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.type(queryInput(), "hello"); + await user.click(searchButton()); + + expect(await screen.findByText("Result 1")).toBeInTheDocument(); + expect(screen.queryByText("the quick brown fox")).not.toBeInTheDocument(); + + await user.click(screen.getByText("Result 1")); + + expect(screen.getByText("the quick brown fox")).toBeInTheDocument(); + expect(screen.getByText("File ID:").parentElement).toHaveTextContent("file-1"); + expect(screen.getByText("Filename:").parentElement).toHaveTextContent("notes.txt"); + }); + + it("warns instead of searching when the query is only whitespace", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.type(queryInput(), " "); + await user.type(queryInput(), "{Enter}"); + + expect(mockWarning).toHaveBeenCalledWith("Please enter a search query"); + expect(mockSearch).not.toHaveBeenCalled(); + }); + + it("submits on Enter but not on Shift+Enter", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.type(queryInput(), "hello"); + await user.type(queryInput(), "{Shift>}{Enter}{/Shift}"); + expect(mockSearch).not.toHaveBeenCalled(); + + await user.type(queryInput(), "{Enter}"); + await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1)); + }); + + it("reports a failed search and keeps the history empty", async () => { + const user = userEvent.setup(); + mockSearch.mockRejectedValue(new Error("boom")); + renderTester(); + + await user.type(queryInput(), "hello"); + await user.click(searchButton()); + + await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store")); + expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument(); + }); + + it("clears the search history", async () => { + const user = userEvent.setup(); + renderTester(); + + await user.type(queryInput(), "hello"); + await user.click(searchButton()); + expect(await screen.findByText("Result 1")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /clear history/i })); + + expect(screen.queryByText("Result 1")).not.toBeInTheDocument(); + expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx index 2931372f384d..521c1f879ee5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { vectorStoreListCall } from "@/components/networking"; @@ -25,18 +26,25 @@ vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null } const mockVectorStoreListCall = vi.mocked(vectorStoreListCall); +const openManageTab = async (user: ReturnType) => { + await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" })); +}; + describe("VectorStoreManagement loading state", () => { beforeEach(() => { vi.clearAllMocks(); }); it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { + const user = userEvent.setup(); render(); + await openManageTab(user); expect(await screen.findByText("table-loaded")).toBeInTheDocument(); expect(mockVectorStoreListCall).not.toHaveBeenCalled(); }); it("should show the loading state until the vector store fetch settles", async () => { + const user = userEvent.setup(); let resolveFetch: (value: { data: never[] }) => void = () => {}; mockVectorStoreListCall.mockReturnValue( new Promise((resolve) => { @@ -44,6 +52,7 @@ describe("VectorStoreManagement loading state", () => { }), ); render(); + await openManageTab(user); expect(screen.getByText("table-loading")).toBeInTheDocument(); resolveFetch({ data: [] }); diff --git a/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx index ccb2c5d9e53b..145fdb1f5761 100644 --- a/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/Filters/FiltersButton.test.tsx @@ -21,12 +21,18 @@ describe("FiltersButton", () => { expect(onClick).toHaveBeenCalledTimes(1); }); - it("should show badge when hasActiveFilters is true", () => { + it("should show the active-filter indicator when hasActiveFilters is true", () => { const onClick = vi.fn(); const { container } = render(); - const button = screen.getByRole("button", { name: /filters/i }); - const badgeWrapper = button.closest(".ant-badge"); - expect(badgeWrapper).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument(); + expect(container.querySelector("sup")).toBeInTheDocument(); + }); + + it("should not show the active-filter indicator when hasActiveFilters is false", () => { + const onClick = vi.fn(); + const { container } = render(); + expect(screen.getByRole("button", { name: /filters/i })).toBeInTheDocument(); + expect(container.querySelector("sup")).not.toBeInTheDocument(); }); it("should render custom label when provided", () => { From 39f0b56502e5ae572e08971c58723e2ff17455d6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:40:25 -0700 Subject: [PATCH 12/23] refactor(ui): migrate budgets, skills and ui-theme to shadcn Replaces antd and Tremor with the installed shadcn primitives on the three route-exclusive panels: Tremor tabs, buttons and text on budgets; the antd delete Modal and Tremor button on skills; the Tremor card, inputs and buttons on ui-theme. Markup only, no behaviour change. The characterisation tests added in the previous commit are untouched and stay green, and the ui-theme inputs now carry real label associations. Shared components stay on antd; they are reached by other routes and are migrated separately. The form-bearing files on these routes are left alone. --- ui/litellm-dashboard/eslint-suppressions.json | 9 -- .../budgets/_components/budget_panel.tsx | 141 +++++++++--------- .../_components/ClaudeCodePluginsPanel.tsx | 46 ++++-- .../(dashboard)/ui-theme/UIThemeSettings.tsx | 60 +++++--- 4 files changed, 141 insertions(+), 115 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05bab..c09b832a69b4 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -144,9 +144,6 @@ "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { @@ -1840,9 +1837,6 @@ } }, "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { - "no-restricted-imports": { - "count": 2 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1888,9 +1882,6 @@ } }, "src/app/(dashboard)/ui-theme/UIThemeSettings.tsx": { - "no-restricted-imports": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 6d5c0c7be086..2cf2a4c06ecd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -3,9 +3,10 @@ * */ -import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react"; import React, { useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; @@ -73,76 +74,82 @@ const BudgetPanel: React.FC = ({ accessToken }) => { return (
{canModify && ( - )} - - - Budgets - Examples - - - -
- - {selectedBudget && ( - - )} - Create a budget to assign to customers. - + + + Budgets + + + Examples + + + +
+ + {selectedBudget && ( + - -
- - -
- How to use budget id - - - Assign Budget to Customer - Test it (Curl) - Test it (OpenAI SDK) - - - - {CREATE_END_USER_CURL_COMMAND} - - - {CHAT_COMPLETIONS_CURL_COMMAND} - - - {OPENAI_SDK_PYTHON_CODE} - - - -
-
- - + )} +

Create a budget to assign to customers.

+ + +
+ + +
+

How to use budget id

+ + + + Assign Budget to Customer + + + Test it (Curl) + + + Test it (OpenAI SDK) + + + + {CREATE_END_USER_CURL_COMMAND} + + + {CHAT_COMPLETIONS_CURL_COMMAND} + + + {OPENAI_SDK_PYTHON_CODE} + + +
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 5a638f9ae794..47fc8f41307e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -1,6 +1,14 @@ import React, { useState, useEffect } from "react"; -import { Button } from "@tremor/react"; -import { Modal } from "antd"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; import PluginTable from "./PluginTable"; @@ -115,20 +123,28 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT /> {pluginToDelete && ( - setPluginToDelete(null)} - confirmLoading={isDeleting} - okText="Delete" - okButtonProps={{ danger: true }} + { + if (!open) setPluginToDelete(null); + }} > -

- Are you sure you want to delete skill: {pluginToDelete.displayName}? -

-

This action cannot be undone.

-
+ + + Delete Skill + + Are you sure you want to delete skill: {pluginToDelete.displayName}? + +

This action cannot be undone.

+
+ + Cancel + + +
+ )}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx index 15a3de0f78ba..035d87c4e9b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/ui-theme/UIThemeSettings.tsx @@ -1,5 +1,9 @@ import React, { useState, useEffect } from "react"; -import { Card, Title, Text, TextInput, Button } from "@tremor/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useTheme } from "@/contexts/ThemeContext"; import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -113,50 +117,58 @@ const UIThemeSettings: React.FC = ({ userID, userRole, acc return (
- UI Theme Customization - Customize your LiteLLM admin dashboard with a custom logo and favicon. +

UI Theme Customization

+

+ Customize your LiteLLM admin dashboard with a custom logo and favicon. +

- -
+ +
- Custom Logo URL - + Custom Logo URL + + { - setLogoUrlInput(v); - setLogoUrl(v || null); + onChange={(event) => { + setLogoUrlInput(event.target.value); + setLogoUrl(event.target.value || null); }} - className="w-full" /> - +

Enter a URL for your custom logo or leave empty for default - +

- Custom Favicon URL - + Custom Favicon URL + + { - setFaviconUrlInput(v); - setFaviconUrl(v || null); + onChange={(event) => { + setFaviconUrlInput(event.target.value); + setFaviconUrl(event.target.value || null); }} - className="w-full" /> - +

Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default - +

- -
-
+
); From f2d531737adce76ddb84e3c4adeef02cc3f43ec8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:29:09 -0700 Subject: [PATCH 13/23] test(ui): pin mcp-servers, tag-management and tool-policies behaviour before the shadcn migration Rewrite the two markup-coupled assertions off antd class selectors and onto role/text queries, and add characterisation tests for the nine route-owned components that had none. Both rewritten tests and all nine new ones are green against the current antd and Tremor components, so the migration that follows can be judged by tests it never touched. --- .../_components/MCPNetworkSettings.test.tsx | 92 ++++++++ .../_components/OpenAPIQuickPicker.test.tsx | 84 ++++++++ .../TruePassthroughWarning.test.tsx | 23 ++ .../_components/mcp_discovery.test.tsx | 118 +++++++++++ .../mcp_server_cost_config.test.tsx | 87 ++++++++ .../mcp_server_cost_display.test.tsx | 48 +++++ .../_components/mcp_server_view.test.tsx | 152 ++++++++++++++ .../_components/mcp_servers.test.tsx | 36 +--- .../src/components/ToolDetail.test.tsx | 197 ++++++++++++++++++ .../ToolPolicies/PolicySelect.test.tsx | 4 +- .../ToolPolicies/ToolPoliciesPanel.test.tsx | 24 ++- .../ToolPoliciesTableColumns.test.tsx | 126 +++++++++++ 12 files changed, 952 insertions(+), 39 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolDetail.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx new file mode 100644 index 000000000000..358968df0398 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPNetworkSettings from "./MCPNetworkSettings"; +import { + getGeneralSettingsCall, + updateConfigFieldSetting, + deleteConfigFieldSetting, + fetchMCPClientIp, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + getGeneralSettingsCall: vi.fn(), + updateConfigFieldSetting: vi.fn(), + deleteConfigFieldSetting: vi.fn(), + fetchMCPClientIp: vi.fn(), +})); + +const renderSettings = () => render(); + +describe("MCPNetworkSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGeneralSettingsCall).mockResolvedValue([]); + vi.mocked(fetchMCPClientIp).mockResolvedValue(null); + vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined); + vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined); + }); + + it("renders the stored private ranges once settings load", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] }, + ]); + + renderSettings(); + + expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument(); + expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument(); + }); + + it("ignores unrelated config fields", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "some_other_setting", field_value: ["should-not-show"] }, + ]); + + renderSettings(); + + await screen.findByText("Private IP Ranges"); + expect(screen.queryByText("should-not-show")).not.toBeInTheDocument(); + }); + + it("suggests the caller's /24 range from the detected client IP", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + + expect(await screen.findByText("203.0.113.45")).toBeInTheDocument(); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("adds the suggested range to the list when clicked, and stops suggesting it", async () => { + vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45"); + + renderSettings(); + await userEvent.click(await screen.findByText("203.0.113.0/24")); + + await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument()); + expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument(); + }); + + it("saves the configured ranges", async () => { + vi.mocked(getGeneralSettingsCall).mockResolvedValue([ + { field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] }, + ]); + + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => + expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]), + ); + expect(deleteConfigFieldSetting).not.toHaveBeenCalled(); + }); + + it("clears the setting instead of saving an empty list", async () => { + renderSettings(); + await userEvent.click(await screen.findByRole("button", { name: /Save/ })); + + await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges")); + expect(updateConfigFieldSetting).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx new file mode 100644 index 000000000000..f6091f06376c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker"; +import { fetchOpenAPIRegistry } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + fetchOpenAPIRegistry: vi.fn(), +})); + +const stripe: OpenAPIRegistryEntry = { + name: "stripe", + title: "Stripe", + description: "Payments API", + icon_url: "https://cdn.example.com/stripe.svg", + spec_url: "https://example.com/stripe.json", +}; + +const github: OpenAPIRegistryEntry = { + name: "github", + title: "GitHub", + description: "Code hosting API", + icon_url: "https://cdn.example.com/github.svg", + spec_url: "https://example.com/github.json", +}; + +describe("OpenAPIQuickPicker", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders one selectable entry per registry API", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + + render(); + + expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument(); + expect(screen.getByText("Popular APIs")).toBeInTheDocument(); + }); + + it("passes the whole registry entry to onSelect when one is clicked", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] }); + const onSelect = vi.fn(); + + render(); + await userEvent.click(await screen.findByRole("button", { name: /Stripe/ })); + + expect(onSelect).toHaveBeenCalledWith(stripe); + }); + + it("renders nothing when the registry is empty", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] }); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("renders nothing when the registry fetch fails", async () => { + vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom")); + + const { container } = render(); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); + }); + + it("does not fetch without an access token", () => { + render(); + + expect(fetchOpenAPIRegistry).not.toHaveBeenCalled(); + }); + + it("falls back to a letter avatar when the icon fails to load", async () => { + vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] }); + + render(); + + fireEvent.error(await screen.findByAltText("Stripe")); + + await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument()); + expect(screen.getByText("S")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx new file mode 100644 index 000000000000..18a32a384c56 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import TruePassthroughWarning from "./TruePassthroughWarning"; +import { AUTH_TYPE } from "@/components/mcp_tools/types"; + +describe("TruePassthroughWarning", () => { + it("warns when auth type is true_passthrough", () => { + render(); + + expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument(); + expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument(); + }); + + it("renders nothing for any other auth type", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when no auth type is set", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx new file mode 100644 index 000000000000..4e2456ab7a40 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_discovery.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import MCPDiscovery from "./mcp_discovery"; +import { fetchDiscoverableMCPServers } from "@/components/networking"; +import type { DiscoverableMCPServer } from "@/components/mcp_tools/types"; + +vi.mock("@/components/networking", () => ({ + fetchDiscoverableMCPServers: vi.fn(), +})); + +const githubServer = { + name: "github", + title: "GitHub", + description: "Code hosting", + category: "Developer Tools", + icon_url: "", +} as DiscoverableMCPServer; + +const slackServer = { + name: "slack", + title: "Slack", + description: "Team chat", + category: "Communication", + icon_url: "", +} as DiscoverableMCPServer; + +const defaultProps = { + isVisible: true, + onClose: vi.fn(), + onSelectServer: vi.fn(), + onCustomServer: vi.fn(), + accessToken: "tok", +}; + +describe("MCPDiscovery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ + servers: [githubServer, slackServer], + categories: ["Developer Tools", "Communication"], + }); + }); + + // Each category name renders twice: once as a filter pill (a button) and once + // as the heading of its group. Only the heading is not a button. + const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON"); + + it("lists every discoverable server grouped under its category", async () => { + render(); + + expect(await screen.findByText("GitHub")).toBeInTheDocument(); + expect(screen.getByText("Slack")).toBeInTheDocument(); + expect(groupHeading("Developer Tools")).toHaveLength(1); + expect(groupHeading("Communication")).toHaveLength(1); + expect(screen.getByText("Add MCP Server")).toBeInTheDocument(); + }); + + it("filters the list down to the chosen category", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.click(screen.getByRole("button", { name: "Communication" })); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("filters the list by the search term", async () => { + render(); + await screen.findByText("GitHub"); + + await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat"); + + await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument()); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("hands the picked server back to the caller", async () => { + const onSelectServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByText("GitHub")); + + expect(onSelectServer).toHaveBeenCalledWith(githubServer); + }); + + it("offers a custom-server escape hatch", async () => { + const onCustomServer = vi.fn(); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" })); + + expect(onCustomServer).toHaveBeenCalled(); + }); + + it("surfaces a fetch failure", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down")); + + render(); + + expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument(); + }); + + it("offers the custom-server link when nothing matches", async () => { + vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] }); + + render(); + + expect(await screen.findByText(/No servers found/)).toBeInTheDocument(); + }); + + it("does not fetch while hidden", () => { + render(); + + expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx new file mode 100644 index 000000000000..a4547e4923f4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import MCPServerCostConfig from "./mcp_server_cost_config"; + +const tools = [ + { name: "search", description: "Search the index" }, + { name: "fetch", description: "Fetch a document" }, +]; + +describe("MCPServerCostConfig", () => { + it("renders the default cost field with the current value", () => { + render(); + + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200"); + }); + + it("reports the edited default cost as a number", async () => { + const onChange = vi.fn(); + render(); + + await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5"); + + expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 }); + }); + + it("disables the default cost field when disabled", () => { + render(); + + expect(screen.getByPlaceholderText("0.0000")).toBeDisabled(); + }); + + it("hides the per-tool section when the server exposes no tools", () => { + render(); + + expect(screen.queryByText("Available Tools")).not.toBeInTheDocument(); + }); + + it("offers a per-tool override for every tool once tools are loaded", async () => { + render(); + + await userEvent.click(screen.getByText("Available Tools")); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("Search the index")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2); + }); + + it("merges a per-tool override into the existing cost map", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.click(screen.getByText("Available Tools")); + await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3"); + + expect(onChange).toHaveBeenLastCalledWith({ + default_cost_per_query: 0.01, + tool_name_to_cost_per_query: { fetch: 0.2, search: 3 }, + }); + }); + + it("summarises the configured costs", () => { + render( + , + ); + + expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument(); + expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument(); + }); + + it("shows no summary when nothing is configured", () => { + render(); + + expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx new file mode 100644 index 000000000000..466341405c89 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import MCPServerCostDisplay from "./mcp_server_cost_display"; + +describe("MCPServerCostDisplay", () => { + it("explains that calls are free when no cost config exists", () => { + render(); + + expect( + screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."), + ).toBeInTheDocument(); + }); + + it("treats a config with only a null default cost as unconfigured", () => { + render(); + + expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument(); + }); + + it("shows a zero default cost rather than falling back to the empty state", () => { + render(); + + expect(screen.getByText("Default Cost per Query")).toBeInTheDocument(); + expect(screen.getByText("$0.0000")).toBeInTheDocument(); + }); + + it("renders the default cost to four decimal places and summarises it", () => { + render(); + + expect(screen.getByText("$0.0125")).toBeInTheDocument(); + expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument(); + }); + + it("lists each tool-specific cost and counts them in the summary", () => { + render( + , + ); + + expect(screen.getByText("search")).toBeInTheDocument(); + expect(screen.getByText("$0.5000 per query")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.getByText("$0.2500 per query")).toBeInTheDocument(); + expect(screen.queryByText("skipped")).not.toBeInTheDocument(); + expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx new file mode 100644 index 000000000000..02d168bf7f45 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.test.tsx @@ -0,0 +1,152 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPServerView } from "./mcp_server_view"; +import type { MCPServer } from "@/components/mcp_tools/types"; + +vi.mock(".", () => ({ + MCPToolsViewer: () =>
tools viewer
, +})); + +vi.mock("./mcp_server_edit", () => ({ + default: () =>
edit form
, + EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state", +})); + +const baseServer = { + server_id: "srv-1", + server_name: "demo server", + alias: "demo_alias", + description: "A demo MCP server", + transport: "http", + url: "https://example.com/mcp", + auth_type: "api_key", +} as MCPServer; + +const renderView = (overrides: Partial = {}, props: Record = {}) => + render( + , + ); + +describe("MCPServerView", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // Name, alias and description each label the header and a Settings row, so + // only the server id is unique to the header. + it("shows the server identity in the header", () => { + renderView(); + + expect(screen.getByText("srv-1")).toBeInTheDocument(); + expect(screen.getAllByText("demo server").length).toBeGreaterThan(0); + expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0); + expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0); + }); + + it("falls back to a placeholder name when the server has neither name nor alias", () => { + renderView({ server_name: undefined, alias: undefined }); + + expect(screen.getByText("Unnamed Server")).toBeInTheDocument(); + }); + + // "Transport" and "Authentication" label both an Overview card and a Settings + // row, so only Overview-exclusive labels identify the Overview panel. + it("summarises the connection on the Overview tab", () => { + renderView(); + + expect(screen.getByText("Host URL")).toBeInTheDocument(); + expect(screen.getByText("Cost Configuration")).toBeInTheDocument(); + expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0); + expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0); + }); + + it("offers a Settings tab to proxy admins only", () => { + renderView(); + expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); + }); + + it("hides the Settings tab from non-admins", () => { + renderView({}, { isProxyAdmin: false }); + expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument(); + }); + + it("opens the tools viewer on the MCP Tools tab", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" })); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("shows the read-only settings summary before editing", async () => { + renderView({ allow_all_keys: true, available_on_public_internet: false }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument(); + expect(screen.getByText("Allow All Keys")).toBeInTheDocument(); + expect(screen.getByText("Enabled")).toBeInTheDocument(); + expect(screen.getByText("Internal only")).toBeInTheDocument(); + expect(screen.queryByText("edit form")).not.toBeInTheDocument(); + }); + + it("swaps in the edit form when Edit Settings is pressed", async () => { + renderView(); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + }); + + it("opens straight into the edit form when isEditing is set", async () => { + renderView({}, { isEditing: true }); + + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("edit form")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + }); + + it("opens on the tab named by initialTabIndex", async () => { + renderView({}, { initialTabIndex: 1 }); + + expect(await screen.findByText("tools viewer")).toBeInTheDocument(); + }); + + it("returns to the server list when Back is pressed", async () => { + const onBack = vi.fn(); + renderView({}, { onBack }); + + await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("lists the allowed tools, or says all tools are enabled", async () => { + renderView({ allowed_tools: ["search", "fetch"] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("search")).toBeInTheDocument(); + expect(screen.getByText("fetch")).toBeInTheDocument(); + expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument(); + }); + + it("says all tools are enabled when no allowlist is stored", async () => { + renderView({ allowed_tools: [] }); + await userEvent.click(screen.getByRole("tab", { name: "Settings" })); + + expect(await screen.findByText("All tools enabled")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx index d61bc23c757f..f9f3d20ca155 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"; +import { render, waitFor, screen, act, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import MCPServers from "./mcp_servers"; @@ -307,36 +308,15 @@ describe("MCPServers", () => { expect(screen.getByText("Team B Server")).toBeInTheDocument(); expect(screen.getByText("Team A Server 2")).toBeInTheDocument(); - // Find the team select dropdown by looking for the "Team" label + // Find the team select by its "Team" label, then the combobox it labels const teamLabel = screen.getByText("Team"); - const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select"); - expect(teamSelectContainer).toBeTruthy(); + const teamSelect = within(teamLabel.parentElement!).getByRole("combobox"); - // Open the dropdown by clicking on the selector - const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector"); - expect(selectSelector).toBeTruthy(); + await userEvent.click(teamSelect); - act(() => { - fireEvent.mouseDown(selectSelector!); - }); - - // Wait for dropdown to open - await waitFor( - () => { - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - expect(dropdownOptions.length).toBeGreaterThan(0); - }, - { timeout: 5000 }, - ); - - // Find and click on "Team A" option - const dropdownOptions = document.querySelectorAll(".ant-select-item-option"); - const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A")); - expect(teamAOption).toBeTruthy(); - - act(() => { - fireEvent.click(teamAOption!); - }); + // Pick the "Team A" option once the listbox opens + const teamAOption = await screen.findByText("Team A"); + await userEvent.click(teamAOption); // Wait for filtering to complete await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/ToolDetail.test.tsx b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx new file mode 100644 index 000000000000..db5047145c45 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolDetail.test.tsx @@ -0,0 +1,197 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ToolDetail } from "./ToolDetail"; +import { + deleteToolPolicyOverride, + fetchToolDetail, + fetchToolPolicyOptions, + getToolUsageLogs, + keyListCall, + teamListCall, + updateToolPolicy, + type ToolDetailResponse, + type ToolPolicyOption, + type ToolPolicyOverrideRow, + type ToolRow, + type ToolUsageLogsResponse, +} from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + deleteToolPolicyOverride: vi.fn(), + fetchToolDetail: vi.fn(), + fetchToolPolicyOptions: vi.fn(), + getToolUsageLogs: vi.fn(), + keyListCall: vi.fn(), + teamListCall: vi.fn(), + updateToolPolicy: vi.fn(), +})); + +vi.mock("@/components/common_components/team_dropdown", () => ({ + default: ({ onChange }: { onChange: (id: string) => void }) => ( + + ), +})); + +vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({ + LogViewer: ({ totalLogs }: { totalLogs: number }) =>
log viewer ({totalLogs})
, +})); + +const detail = { + tool: { + tool_name: "search_docs", + input_policy: "untrusted", + output_policy: "trusted", + origin: "mcp", + call_count: 42, + user_agent: "litellm-python/1.0", + created_at: "2026-03-04T10:00:00Z", + }, + overrides: [], +} as unknown as ToolDetailResponse; + +const renderDetail = (onBack = vi.fn()) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render( + + + , + ); +}; + +describe("ToolDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchToolDetail).mockResolvedValue(detail); + vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] }); + vi.mocked(teamListCall).mockResolvedValue({ data: [] }); + vi.mocked(keyListCall).mockResolvedValue({ keys: [] }); + vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse); + vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow); + vi.mocked(deleteToolPolicyOverride).mockResolvedValue( + undefined as unknown as { deleted: boolean; tool_name: string }, + ); + }); + + it("shows the tool identity once loaded", async () => { + renderDetail(); + + expect(await screen.findByText("search_docs")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + expect(screen.getByText("42 calls")).toBeInTheDocument(); + expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument(); + }); + + it("renders both policy panels with the tool's current policies", async () => { + renderDetail(); + + expect(await screen.findByText("Input Policy")).toBeInTheDocument(); + expect(screen.getByText("Output Policy")).toBeInTheDocument(); + expect(screen.getByText("untrusted")).toBeInTheDocument(); + expect(screen.getByText("trusted")).toBeInTheDocument(); + }); + + it("uses the policy option descriptions when the backend supplies them", async () => { + vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ + input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption], + output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption], + }); + + renderDetail(); + + expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument(); + expect(screen.getByText("Outputs may be chained")).toBeInTheDocument(); + }); + + it("returns to the list when Back is pressed", async () => { + const onBack = vi.fn(); + renderDetail(onBack); + + await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ })); + + expect(onBack).toHaveBeenCalled(); + }); + + it("reports a failed detail load and still offers a way back", async () => { + vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope")); + + renderDetail(); + + expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument(); + }); + + it("hides the overrides panel when the tool has none", async () => { + renderDetail(); + + await screen.findByText("Input Policy"); + expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument(); + }); + + it("lists existing overrides and removes the chosen one", async () => { + vi.mocked(fetchToolDetail).mockResolvedValue({ + ...detail, + overrides: [ + { + override_id: "o1", + team_id: "team-alpha", + key_hash: null, + key_alias: null, + } as unknown as ToolPolicyOverrideRow, + ], + }); + + renderDetail(); + + expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Remove" })); + + await waitFor(() => + expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", { + team_id: "team-alpha", + key_hash: undefined, + }), + ); + }); + + it("keeps the block button disabled until a team is chosen, then blocks that team", async () => { + renderDetail(); + + const blockButton = await screen.findByRole("button", { name: /Block for team/ }); + expect(blockButton).toBeDisabled(); + + await userEvent.click(screen.getByRole("button", { name: "pick team" })); + await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled()); + await userEvent.click(screen.getByRole("button", { name: /Block for team/ })); + + await waitFor(() => + expect(updateToolPolicy).toHaveBeenCalledWith( + "tok", + "search_docs", + { input_policy: "blocked" }, + { team_id: "team-1", key_hash: undefined, key_alias: undefined }, + ), + ); + }); + + it("switches the block scope to a key", async () => { + renderDetail(); + + await screen.findByText("Block for team or key"); + await userEvent.click(screen.getByRole("radio", { name: "Key" })); + + expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument(); + }); + + it("passes the usage-log total through to the log viewer", async () => { + vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse); + + renderDetail(); + + expect(await screen.findByText("log viewer (7)")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx index e65f79edd992..f2890e773474 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/PolicySelect.test.tsx @@ -30,12 +30,12 @@ describe("PolicySelect", () => { it("should be disabled when saving is true", () => { renderWithProviders(); expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); - expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled"); + expect(screen.getByRole("combobox")).toBeDisabled(); }); it("should not be disabled when saving is false", () => { renderWithProviders(); - expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled"); + expect(screen.getByRole("combobox")).toBeEnabled(); }); }); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx index 721c215cfb1f..0a0b1c09fbbe 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => { const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement => within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1]; -/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */ -const policyValue = (toolId: string, kind: "input" | "output"): string => - policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? ""; +/** + * Exact selected-value text, read off the policy cell and stripped of anything + * that is not a letter (the control draws a status dot and a chevron around the + * label). Never assert with toHaveTextContent here: it substring-matches, so + * "untrusted" satisfies "trusted". + */ +const policyValue = (toolId: string, kind: "input" | "output"): string => { + const cell = policySelect(toolId, kind).closest("td"); + return (cell?.textContent ?? "").replace(/[^a-z]/gi, ""); +}; const isSaving = (toolId: string, kind: "input" | "output"): boolean => - policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false; + policySelect(toolId, kind).hasAttribute("disabled"); const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => { await user.click(trigger); + // The label also renders in the trigger once selected, so take the last match: + // the popup is portalled after the table in document order. const option = await waitFor(() => { - const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find( - (element) => element.textContent === label, - ); - if (match === undefined) throw new Error(`option ${label} not open`); - return match as HTMLElement; + const matches = screen.getAllByText(label); + return matches[matches.length - 1]; }); await user.click(option); }; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx new file mode 100644 index 000000000000..bb4cd8a463a9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; +import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns"; +import type { ToolRow } from "@/components/networking"; + +const row: ToolRow = { + tool_name: "search_docs", + input_policy: "untrusted", + output_policy: "trusted", + call_count: 1234, + team_id: "team-alpha", + key_hash: "abc123def456", + key_alias: "prod-key", + user_agent: "litellm-python/1.0", + created_at: "2026-03-04T10:00:00Z", +} as ToolRow; + +const defaultDeps = { + onSelectTool: vi.fn(), + savingInput: new Set(), + savingOutput: new Set(), + onInputPolicyChange: vi.fn(), + onOutputPolicyChange: vi.fn(), +}; + +// Renders the column definitions through a real TanStack table so each `cell` +// renderer runs exactly as the DataTable runs it. +function TableHarness({ columns, data }: { columns: ColumnDef[]; data: ToolRow[] }) { + const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }); + return ( + + + {table.getRowModel().rows.map((r) => ( + + {r.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ); +} + +const renderTable = (deps = {}, data: ToolRow[] = [row]) => + render(); + +describe("getToolPoliciesTableColumns", () => { + it("defines the expected columns in order", () => { + const columns = getToolPoliciesTableColumns(defaultDeps); + + expect(columns.map((c) => c.id)).toEqual([ + "created_at", + "tool_name", + "input_policy", + "output_policy", + "call_count", + "team_id", + "key_hash", + "key_alias", + "user_agent", + ]); + }); + + it("renders the row's identifying fields", () => { + renderTable(); + + expect(screen.getByText("search_docs")).toBeInTheDocument(); + expect(screen.getByText("team-alpha")).toBeInTheDocument(); + expect(screen.getByText("prod-key")).toBeInTheDocument(); + expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument(); + }); + + it("formats the call count with thousands separators", () => { + renderTable(); + + expect(screen.getByText("1,234")).toBeInTheDocument(); + }); + + it("renders a zero call count rather than a blank cell", () => { + renderTable({}, [{ ...row, call_count: undefined } as ToolRow]); + + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("falls back to a dash for a missing key alias and user agent", () => { + renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]); + + expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2); + }); + + it("notifies the caller when the tool name is clicked", async () => { + const onSelectTool = vi.fn(); + renderTable({ onSelectTool }); + + await userEvent.click(screen.getByText("search_docs")); + + expect(onSelectTool).toHaveBeenCalledWith("search_docs"); + }); + + it("renders a policy control for each direction, showing the row's current policies", () => { + renderTable(); + + expect(screen.getByText("untrusted")).toBeInTheDocument(); + expect(screen.getByText("trusted")).toBeInTheDocument(); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + }); + + it("disables only the input policy control while that direction is saving", () => { + renderTable({ savingInput: new Set(["search_docs"]) }); + + const [input, output] = screen.getAllByRole("combobox"); + expect(input).toBeDisabled(); + expect(output).toBeEnabled(); + }); + + it("disables only the output policy control while that direction is saving", () => { + renderTable({ savingOutput: new Set(["search_docs"]) }); + + const [input, output] = screen.getAllByRole("combobox"); + expect(input).toBeEnabled(); + expect(output).toBeDisabled(); + }); +}); From a1bacb660f0f340a01238046a76b72788a181ce4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 23 Jul 2026 22:54:00 -0700 Subject: [PATCH 14/23] refactor(ui): migrate access-groups, vector-stores, organizations to shadcn Moves the nine files these three routes exclusively own off antd and Tremor onto the shadcn primitives in src/components/ui. Scope came from the migration analyzer's import closure, so nothing reached by a second route is touched and every file carrying an antd Form is left alone until #34195 lands. access-groups gets the page header, search box and the whole detail view; vector-stores gets the tab shell, the store picker and the tester panel; organizations gets the organization detail view and the three filter controls. Two changes are behavioural rather than cosmetic. The vector-stores tab strip moves from Tremor, which mounts every panel at once, to Base UI, which mounts only the active panel; that is the correct behaviour and the reworked test now opens the tab it asserts on. The antd Select on the Test Vector Store tab becomes a combobox rather than a plain select so its showSearch type-ahead survives. organization_view keeps one antd import, the ColumnsType used to build the extra columns it hands to the shared MemberTable; that is dictated by the shared component's API and goes away when MemberTable migrates. eslint-suppressions.json ratchets down accordingly: eight files lose their no-restricted-imports entry and organization_view drops from three to one. Every test passes unedited across the migration, and the visual gate reports the three migrated routes changed with the other 32 pixel-identical --- ui/litellm-dashboard/eslint-suppressions.json | 38 +- .../_components/AccessGroupsDetailsPage.tsx | 378 +++++++---------- .../_components/AccessGroupsPage.tsx | 71 ++-- .../_components/TestVectorStoreTab.tsx | 83 ++-- .../_components/VectorStoreTester.tsx | 106 +++-- .../vector-stores/_components/index.tsx | 113 +++-- .../common_components/Filters/FilterInput.tsx | 17 +- .../Filters/FiltersButton.tsx | 13 +- .../Filters/ResetFiltersButton.tsx | 5 +- .../organization/organization_view.tsx | 397 +++++++++--------- 10 files changed, 557 insertions(+), 664 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05bab..571598b18530 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,11 +4,6 @@ "count": 1 } }, - "src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": { "no-restricted-imports": { "count": 2 @@ -24,11 +19,6 @@ "count": 1 } }, - "src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { "no-restricted-imports": { "count": 2 @@ -2054,11 +2044,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { "no-nested-ternary": { "count": 2 @@ -2070,18 +2055,10 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { "local/filename-pascal-case": { "count": 1 }, - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -3017,23 +2994,10 @@ } }, "src/components/common_components/Filters/FilterInput.tsx": { - "no-restricted-imports": { - "count": 1 - }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/common_components/Filters/FiltersButton.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/common_components/Filters/ResetFiltersButton.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/common_components/IconActionButton/BaseActionButton.tsx": { "no-restricted-imports": { "count": 1 @@ -3595,7 +3559,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 + "count": 1 } }, "src/components/page_utils.test.ts": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 72a89093bdb0..9476a8d98af4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -1,68 +1,63 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; -import { - Button, - Card, - Col, - Descriptions, - Empty, - Flex, - Layout, - List, - Row, - Spin, - Tabs, - Tag, - theme, - Typography, -} from "antd"; import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react"; import { useState } from "react"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import CopyButton from "@/components/shared/CopyButton"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; -const { Title, Text } = Typography; -const { Content } = Layout; - interface AccessGroupDetailProps { accessGroupId: string; onBack: () => void; } +const MAX_PREVIEW = 5; + +function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) { + if (ids.length === 0) { + return

{emptyMessage}

; + } + return ( +
+ {ids.map((id) => ( + + + {id} + + + ))} +
+ ); +} + export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) { const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId); - const { token } = theme.useToken(); const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [showAllKeys, setShowAllKeys] = useState(false); const [showAllTeams, setShowAllTeams] = useState(false); - const MAX_PREVIEW = 5; - if (isLoading) { return ( - - - - - +
+
+ +
+
); } if (!accessGroup) { return ( - - +

Access group not found

+
); } @@ -75,224 +70,159 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW); const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW); - const handleEdit = () => { - setIsEditModalVisible(true); - }; - - const tabItems = [ - { - key: "models", - label: ( - - - Models - {modelIds?.length} - - ), - children: - modelIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - { - key: "mcp", - label: ( - - - MCP Servers - {mcpServerIds?.length} - - ), - children: - mcpServerIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - { - key: "agents", - label: ( - - - Agents - {agentIds?.length} - - ), - children: - agentIds?.length > 0 ? ( - ( - - - {id} - - - )} - /> - ) : ( - - ), - }, - ]; - return ( - - {/* Header */} -
-
-
- - {accessGroup.access_group_name} - - - ID: {accessGroup.access_group_id} - +

{accessGroup.access_group_name}

+
+ ID: {accessGroup.access_group_id} + +
-
- {/* Group Details */} - - - - {accessGroup.description || "—"} - + + + Group Details + + +
+
Description
+
{accessGroup.description || "—"}
+
Created
+
{new Date(accessGroup.created_at).toLocaleString()} {accessGroup.created_by && ( - -  {"by"}  + <> + by - + )} - - +
+
Last Updated
+
{new Date(accessGroup.updated_at).toLocaleString()} {accessGroup.updated_by && ( - -  {"by"}  + <> + by - + )} - - - - +
+
+
+
- {/* Attached Keys & Teams */} - - - - - Attached Keys - {keyIds?.length} - - } - extra={ - keyIds?.length > MAX_PREVIEW ? ( - - ) : null - } - > - {keyIds?.length > 0 ? ( - + + )} + + + {keyIds.length > 0 ? ( +
{displayedKeys.map((id) => ( - - - {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id} - - + + {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id} + ))} - +
) : ( - +

No keys attached

)} -
- - - - - Attached Teams - {teamIds?.length} - - } - extra={ - teamIds?.length > MAX_PREVIEW ? ( - - ) : null - } - > - {teamIds?.length > 0 ? ( - + + )} + + + {teamIds.length > 0 ? ( +
{displayedTeams.map((id) => ( - - - {id} - - + + {id} + ))} - +
) : ( - +

No teams attached

)} -
- -
+ +
+
- {/* Resources Tabs */} - + + + + + + Models + {modelIds.length} + + + + MCP Servers + {mcpServerIds.length} + + + + Agents + {agentIds.length} + + + + + + + + + + + + + - {/* Edit Modal */} setIsEditModalVisible(false)} /> - +
); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index 0de6596f57c6..f37acb3d85aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -1,10 +1,11 @@ import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup"; -import { PlusOutlined } from "@ant-design/icons"; -import { Button, Flex, Input, Layout, Space, theme, Typography } from "antd"; -import { SearchIcon } from "lucide-react"; +import { Plus, SearchIcon, X } from "lucide-react"; import { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button } from "@/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal"; import { AccessGroupsTable } from "./AccessGroupsTable"; @@ -12,9 +13,6 @@ import { AccessGroup } from "./types"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; -const { Title, Text } = Typography; -const { Content } = Layout; - function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { return { id: r.access_group_id, @@ -33,7 +31,6 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup { } export function AccessGroupsPage() { - const { token } = theme.useToken(); const { userRole } = useAuthorized(); // Admin Viewer follows the read-parity rule: see access groups, no writes. const canModify = isProxyAdminRole(userRole ?? ""); @@ -62,31 +59,41 @@ export function AccessGroupsPage() { } return ( - - - - - Access Groups - - Manage resource permissions for your organization - - {canModify && ( - - )} - - - - } - placeholder="Search groups by name, ID, or description..." - style={{ maxWidth: 400 }} - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear +
+
+ setIsCreateModalVisible(true)}> + + Create Access Group + + ) : undefined + } /> - +
+ +
+ + + + + setSearchText(e.target.value)} + /> + {searchText && ( + + setSearchText("")}> + + + + )} + +
- +
); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx index 3156da0b413f..fec2dc62e9cc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx @@ -1,24 +1,32 @@ import React, { useState } from "react"; -import { Card, Select, Typography } from "antd"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; import { VectorStoreTester } from "./VectorStoreTester"; import { VectorStore } from "@/components/vector_store_management/types"; -const { Text, Title } = Typography; - interface TestVectorStoreTabProps { accessToken: string | null; vectorStores: VectorStore[]; } +const storeLabel = (store: VectorStore) => store.vector_store_name || store.vector_store_id; + const TestVectorStoreTab: React.FC = ({ accessToken, vectorStores }) => { - const [selectedVectorStoreId, setSelectedVectorStoreId] = useState( - vectorStores.length > 0 ? vectorStores[0].vector_store_id : undefined, - ); + const [selectedVectorStore, setSelectedVectorStore] = useState(vectorStores[0] ?? null); if (!accessToken) { return ( - Access token is required to test vector stores. + +

Access token is required to test vector stores.

+
); } @@ -26,9 +34,11 @@ const TestVectorStoreTab: React.FC = ({ accessToken, ve if (vectorStores.length === 0) { return ( -
- No vector stores available. Create one first to test it. -
+ +
+

No vector stores available. Create one first to test it.

+
+
); } @@ -36,36 +46,41 @@ const TestVectorStoreTab: React.FC = ({ accessToken, ve return (
-
+
- Select Vector Store - Choose a vector store to test search queries against +
Select Vector Store
+

Choose a vector store to test search queries against

- -
+ + + No matching vector stores + + {(store: VectorStore) => ( + +
+ {storeLabel(store)} + {store.vector_store_name && ( + {store.vector_store_id} + )} +
+
+ )} +
+
+ +
- {selectedVectorStoreId && } + {selectedVectorStore && ( + + )}
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx index fa4587b526c2..015d58e86493 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx @@ -1,12 +1,13 @@ import React, { useState } from "react"; -import { Button, Input, Card, Typography, Spin, Divider } from "antd"; import MessageManager from "@/components/molecules/message_manager"; -import { SendOutlined, DatabaseOutlined, LoadingOutlined, DownOutlined, RightOutlined } from "@ant-design/icons"; +import { ChevronDown, ChevronRight, Database, Send } from "lucide-react"; import { vectorStoreSearchCall } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; - -const { TextArea } = Input; -const { Text, Title } = Typography; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface VectorStoreContent { text: string; @@ -98,18 +99,16 @@ export const VectorStoreTester: React.FC = ({ vectorStor }; return ( - -
+ +
{/* Header */} -
+
- - - Test Vector Store - + +

Test Vector Store

{searchHistory.length > 0 && ( - )} @@ -118,9 +117,9 @@ export const VectorStoreTester: React.FC = ({ vectorStor {/* Results Area */}
{searchHistory.length === 0 ? ( -
- - Test your vector store by entering a search query below +
+ +

Test your vector store by entering a search query below

) : (
@@ -128,10 +127,10 @@ export const VectorStoreTester: React.FC = ({ vectorStor
{/* User Query */}
-
-
+
+
Query - {formatTimestamp(entry.timestamp)} + {formatTimestamp(entry.timestamp)}
{entry.query}
@@ -139,12 +138,12 @@ export const VectorStoreTester: React.FC = ({ vectorStor {/* Vector Store Response */}
-
-
- +
+
+ Vector Store Results {entry.response && ( - + {entry.response.data?.length || 0} results )} @@ -156,40 +155,42 @@ export const VectorStoreTester: React.FC = ({ vectorStor const isExpanded = expandedResults[`${index}-${resultIndex}`] || false; return ( -
+
{/* Clickable Header */}
toggleResultExpansion(index, resultIndex)} >
{isExpanded ? ( - + ) : ( - + )} - Result {resultIndex + 1} + Result {resultIndex + 1} {/* Show preview of content when collapsed */} {!isExpanded && result.content && result.content[0] && ( - + - {result.content[0].text.substring(0, 100)}... )}
- + Score: {result.score.toFixed(4)}
{/* Expandable Content */} {isExpanded && ( -
+
{/* Content */} {result.content && result.content.map((content, contentIndex) => (
-
Content ({content.type})
-
+
+ Content ({content.type}) +
+
{content.text}
@@ -197,23 +198,23 @@ export const VectorStoreTester: React.FC = ({ vectorStor {/* Metadata */} {(result.file_id || result.filename || result.attributes) && ( -
-
Metadata
+
+
Metadata
{result.file_id && ( -
+
File ID: {result.file_id}
)} {result.filename && ( -
+
Filename: {result.filename}
)} {result.attributes && Object.keys(result.attributes).length > 0 && ( -
- Attributes: -
+                                            
+ Attributes: +
                                                 {JSON.stringify(result.attributes, null, 2)}
                                               
@@ -228,45 +229,40 @@ export const VectorStoreTester: React.FC = ({ vectorStor })}
) : ( -
No results found
+
No results found
)}
- {index < searchHistory.length - 1 && } + {index < searchHistory.length - 1 && }
))}
)} {isLoading && ( -
- } /> +
+
)}
{/* Input Area */} -
+
-