From 1258d842211b405097f0154687d886e677161c64 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 16:26:45 -0700 Subject: [PATCH 1/3] refactor(ui): route the sidebar by pathname and shrink the ?page= shim to a redirect table The sidebar and header were still keyed on legacy ?page= ids and mapped back and forth through MIGRATED_PAGES, legacyPageHref and legacyKeyForPathname. Leaves are now plain Next links to their path route, the active item and breadcrumb come from usePathname, and the setPage/defaultSelectedKey prop chain is gone. The id-to-route table moves next to the dashboard root page as its only consumer. That redirect now forwards the remaining query params instead of dropping them, so deep links such as the proxy's MCP env-var setup link (?page=mcp-servers&fill_env_vars=) no longer rely on the target page reading the pre-redirect URL during its first render. The proxy builds that link as /ui/mcp-servers?fill_env_vars= directly, and the Playground warnings link to the real routes instead of relative ?page= URLs. migratedHref is renamed uiHref, the /ui base-path helper it always was. --- .../proxy/_experimental/mcp_server/utils.py | 2 +- .../mcp_server/test_mcp_env_vars.py | 6 +- .../components/SidebarProvider.tsx | 11 +- .../src/app/(dashboard)/layout.tsx | 26 +- .../app/(dashboard)/legacyPageRoutes.test.ts | 47 ++++ .../src/app/(dashboard)/legacyPageRoutes.ts | 56 ++++ .../components/AllModelsTab.tsx | 6 +- .../src/app/(dashboard)/page.test.tsx | 30 ++- .../src/app/(dashboard)/page.tsx | 15 +- .../playground/components/chat_ui/ChatUI.tsx | 7 +- .../view_users/user_info_view.test.tsx | 1 - .../src/app/chat/layout.test.tsx | 8 +- ui/litellm-dashboard/src/app/chat/layout.tsx | 4 +- .../src/components/DashboardHeader.test.tsx | 27 +- .../src/components/DashboardHeader.tsx | 9 +- .../components/Navbar/ViewSwitcher.test.tsx | 2 +- .../src/components/Navbar/ViewSwitcher.tsx | 8 +- .../src/components/chat/ChatShell.test.tsx | 2 +- .../src/components/chat/ChatShell.tsx | 4 +- .../src/components/leftnav.test.tsx | 79 +++++- .../src/components/leftnav.tsx | 75 +++--- .../src/components/navbar.tsx | 4 +- .../src/components/networking.test.ts | 4 +- .../organization/organization_view.test.tsx | 3 +- ui/litellm-dashboard/src/utils/entityLinks.ts | 12 +- .../src/utils/migratedPages.test.ts | 239 ------------------ .../src/utils/migratedPages.ts | 83 ------ ui/litellm-dashboard/src/utils/tabRoutes.ts | 4 +- ui/litellm-dashboard/src/utils/uiHref.test.ts | 55 ++++ ui/litellm-dashboard/src/utils/uiHref.ts | 23 ++ 30 files changed, 383 insertions(+), 469 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts delete mode 100644 ui/litellm-dashboard/src/utils/migratedPages.test.ts delete mode 100644 ui/litellm-dashboard/src/utils/migratedPages.ts create mode 100644 ui/litellm-dashboard/src/utils/uiHref.test.ts create mode 100644 ui/litellm-dashboard/src/utils/uiHref.ts diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index 252756e0458..fb3eb06fd15 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -752,7 +752,7 @@ def interpolate_headers(headers: Mapping[str, str], variables: Mapping[str, str] def build_env_var_setup_url(server_id: str) -> str: """The frontend URL where a user can fill in their per-user env vars.""" base: Final = os.environ.get("PROXY_BASE_URL", "").rstrip("/") - path: Final = f"/ui/?page=mcp-servers&fill_env_vars={quote(server_id, safe='')}" + path: Final = f"/ui/mcp-servers?fill_env_vars={quote(server_id, safe='')}" return f"{base}{path}" if base else path diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py index a846ca24739..76cc235f7eb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py @@ -95,7 +95,7 @@ def test_interpolate_headers_returns_independent_copy(): def test_build_env_var_setup_url_includes_server_id(monkeypatch): monkeypatch.delenv("PROXY_BASE_URL", raising=False) url = _u("build_env_var_setup_url")("abc-123") - assert url.startswith("/ui/?page=mcp-servers") + assert url.startswith("/ui/mcp-servers?") assert "fill_env_vars=abc-123" in url @@ -123,7 +123,7 @@ def test_missing_user_env_vars_error_message_is_friendly(): server_id="abc-123", server_name="CorporateDB", missing=["CORP_USERNAME", "CORP_PASSWORD"], - setup_url="https://proxy.example.com/ui/?page=mcp-servers&fill_env_vars=abc-123", + setup_url="https://proxy.example.com/ui/mcp-servers?fill_env_vars=abc-123", ) err = exc_info.value text = str(err) @@ -1694,7 +1694,7 @@ async def test_missing_user_env_vars_error_renders_in_mcp_call_tool(): server_id="srv-99", server_name="CorporateDB", missing=["CORP_USERNAME"], - setup_url="/ui/?page=mcp-servers&fill_env_vars=srv-99", + setup_url="/ui/mcp-servers?fill_env_vars=srv-99", ) # We don't want to spin up the full MCP server framework — just # mimic the except-clause behavior the @server.call_tool handler uses. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index 4d407075d55..6aaf08dae79 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -6,18 +6,11 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useEffect, useState } from "react"; interface SidebarProviderProps { - setPage: (page: string) => void; - defaultSelectedKey: string; sidebarCollapsed: boolean; onToggleCollapsed?: () => void; } -const SidebarProvider = ({ - setPage, - defaultSelectedKey, - sidebarCollapsed, - onToggleCollapsed, -}: SidebarProviderProps) => { +const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProviderProps) => { const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); @@ -70,8 +63,6 @@ const SidebarProvider = ({ return ( { - const migratedRoute = MIGRATED_PAGES[newPage]; - router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage)); - }; - // Non-gateway (agent control plane) mode keeps the original full-width Navbar, // which carries the account menu; the redesigned sidebar + header shell is // scoped to the ai-gateway dashboard. Chat and the public model hub are @@ -136,14 +127,9 @@ function DashboardShell({ children }: { children: React.ReactNode }) { // so the page can't be dragged past the end of the nav. return (
- setSidebarCollapsed((v) => !v)} - /> + setSidebarCollapsed((v) => !v)} />
- + @@ -161,10 +147,10 @@ function LayoutContent({ children }: { children: React.ReactNode }) { const isInvitationFlow = Boolean(searchParams.get("invitation_id")); // Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own - // /onboarding route. Redirect once ui-config has loaded so migratedHref resolves the SERVER_ROOT_PATH base. + // /onboarding route. Redirect once ui-config has loaded so uiHref resolves the SERVER_ROOT_PATH base. useEffect(() => { if (!authLoading && isInvitationFlow) { - router.replace(`${migratedHref("onboarding")}?${searchParams.toString()}`); + router.replace(`${uiHref("onboarding")}?${searchParams.toString()}`); } }, [authLoading, isInvitationFlow, router, searchParams]); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.test.ts new file mode 100644 index 00000000000..6b02b79b04c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { menuGroups } from "@/components/leftnav"; +import { legacyPageRedirectHref } from "./legacyPageRoutes"; + +const redirect = (query: string) => legacyPageRedirectHref(new URLSearchParams(query)); + +describe("legacyPageRedirectHref", () => { + it("sends an old ?page= bookmark to the path route that replaced it", () => { + expect(redirect("page=logs")).toBe("/ui/logs"); + expect(redirect("page=models")).toBe("/ui/models-and-endpoints"); + expect(redirect("page=llm-playground")).toBe("/ui/playground"); + expect(redirect("page=new_usage")).toBe("/ui/usage"); + expect(redirect("page=usage")).toBe("/ui/old-usage"); + }); + + it("keeps the older aliases for renamed pages", () => { + expect(redirect("page=api_ref")).toBe("/ui/api-reference"); + expect(redirect("page=api-reference")).toBe("/ui/api-reference"); + expect(redirect("page=claude-code-plugins")).toBe("/ui/skills"); + }); + + it("forwards the remaining query params so the MCP env-var setup link still opens its form", () => { + expect(redirect("page=mcp-servers&fill_env_vars=srv-1")).toBe("/ui/mcp-servers?fill_env_vars=srv-1"); + expect(redirect("fill_env_vars=srv-1&page=mcp-servers")).toBe("/ui/mcp-servers?fill_env_vars=srv-1"); + }); + + it("keeps forwarded values encoded", () => { + expect(redirect("page=mcp-servers&fill_env_vars=a%26b%3Dc")).toBe("/ui/mcp-servers?fill_env_vars=a%26b%3Dc"); + }); + + it("returns null when there is no page param or the id is unknown", () => { + expect(redirect("")).toBeNull(); + expect(redirect("login=success")).toBeNull(); + expect(redirect("page=does-not-exist")).toBeNull(); + expect(redirect("page=constructor")).toBeNull(); + }); + + it("covers every sidebar page id with the route the sidebar itself links to", () => { + const leaves = menuGroups + .flatMap((group) => group.items.flatMap((item) => item.children ?? [item])) + .filter((item) => !item.external_url); + expect(leaves.length).toBeGreaterThan(30); + for (const leaf of leaves) { + expect(redirect(`page=${leaf.page}`), leaf.page).toBe(`/ui/${leaf.route ?? leaf.page}`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts new file mode 100644 index 00000000000..5c8a22fe795 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -0,0 +1,56 @@ +import { uiHref } from "@/utils/uiHref"; + +// Old ?page= bookmarks and the proxy's MCP env-var setup link still land on the UI root; +// this table sends them to the path route that replaced each page id. +const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( + Object.entries({ + "api-keys": "api-keys", + models: "models-and-endpoints", + api_ref: "api-reference", + "api-reference": "api-reference", + "llm-playground": "playground", + projects: "projects", + chat: "chat", + "access-groups": "access-groups", + budgets: "budgets", + workflows: "workflows", + "guardrails-monitor": "guardrails-monitor", + "mcp-servers": "mcp-servers", + "search-tools": "search-tools", + "tag-management": "tag-management", + "vector-stores": "vector-stores", + memory: "memory", + policies: "policies", + guardrails: "guardrails", + prompts: "prompts", + "tool-policies": "tool-policies", + skills: "skills", + "claude-code-plugins": "skills", + caching: "caching", + "cost-tracking": "cost-tracking", + "transform-request": "transform-request", + "ui-theme": "ui-theme", + logs: "logs", + "admin-panel": "admin-panel", + "logging-and-alerts": "logging-and-alerts", + "model-hub-table": "model-hub-table", + new_usage: "usage", + usage: "old-usage", + "cost-optimization": "cost-optimization", + agents: "agents", + "router-settings": "router-settings", + users: "users", + teams: "teams", + organizations: "organizations", + }), +); + +export function legacyPageRedirectHref(searchParams: URLSearchParams): string | null { + const page = searchParams.get("page"); + const route = page === null ? undefined : LEGACY_PAGE_ROUTES.get(page); + if (route === undefined) return null; + const rest = new URLSearchParams(searchParams); + rest.delete("page"); + const query = rest.toString(); + return query ? `${uiHref(route)}?${query}` : uiHref(route); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 3b4058a28fa..b4e300d9fc3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -7,7 +7,7 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { ModelData } from "@/components/model_dashboard/types"; import { toast } from "@/lib/toast"; -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking"; import { useQueryClient } from "@tanstack/react-query"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; @@ -294,7 +294,7 @@ const AllModelsTab = ({ {selectedTeamValue === PERSONAL_TEAM_VALUE ? ( To access these models, create a Virtual Key without selecting a team on the{" "} - + Virtual Keys page . @@ -302,7 +302,7 @@ const AllModelsTab = ({ ) : ( To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "} - + Virtual Keys page . diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index 5abb219f019..aaad0d072e5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -6,9 +6,9 @@ interface KeyRow { token: string; } -const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => { +const { mockReplace, mockUseKeys, mockUiHref, state } = vi.hoisted(() => { const state = { - login: "success" as string | null, + search: "login=success", userRole: "Internal User", keys: [] as KeyRow[], returnUrl: null as string | null, @@ -16,7 +16,7 @@ const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => { return { state, mockReplace: vi.fn(), - mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), + mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), mockUseKeys: vi.fn(() => ({ data: { keys: state.keys, total_count: state.keys.length }, isLoading: false, @@ -26,7 +26,7 @@ const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => { vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mockReplace }), - useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }), + useSearchParams: () => new URLSearchParams(state.search), })); vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ @@ -44,7 +44,7 @@ vi.mock("@/components/common_components/LoadingScreen", () => ({ default: () =>
, })); vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); -vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref })); +vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref })); vi.mock("@/utils/returnUrlUtils", () => ({ buildLoginUrlWithReturn: (u: string) => u, consumeReturnUrl: () => state.returnUrl, @@ -71,13 +71,13 @@ describe("dashboard landing", () => { afterEach(() => { Object.defineProperty(window, "location", { configurable: true, value: realLocation }); - state.login = "success"; + state.search = "login=success"; state.userRole = "Internal User"; state.keys = []; state.returnUrl = null; mockReplace.mockClear(); mockUseKeys.mockClear(); - mockMigratedHref.mockClear(); + mockUiHref.mockClear(); mockLocationReplace.mockClear(); }); @@ -89,7 +89,7 @@ describe("dashboard landing", () => { expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); - expect(mockMigratedHref).not.toHaveBeenCalledWith("connect"); + expect(mockUiHref).not.toHaveBeenCalledWith("connect"); }, ); @@ -105,6 +105,20 @@ describe("dashboard landing", () => { expect(mockUseKeys).not.toHaveBeenCalled(); }); + it("redirects an old ?page= bookmark to its path route without rendering the keys dashboard", () => { + state.search = "page=logs"; + render(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs"); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + }); + + it("carries the MCP env-var deep link's other params through the legacy redirect", () => { + state.search = "page=mcp-servers&fill_env_vars=srv-1"; + render(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/mcp-servers?fill_env_vars=srv-1"); + }); + it("still sends the user to an explicit stored return URL", () => { state.returnUrl = "/ui/models-and-endpoints"; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 9e82d33dc2b..3a38958dd66 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -12,7 +12,7 @@ import { normalizeUrlForCompare, storeReturnUrl, } from "@/utils/returnUrlUtils"; -import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages"; +import { legacyPageRedirectHref } from "@/app/(dashboard)/legacyPageRoutes"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef } from "react"; @@ -22,8 +22,6 @@ function CreateKeyPageContent() { const router = useRouter(); const searchParams = useSearchParams()!; - const explicitPage = searchParams.get("page"); - // Track if we've already attempted a return URL redirect to prevent race conditions const hasAttemptedReturnRedirectRef = useRef(false); @@ -41,13 +39,12 @@ function CreateKeyPageContent() { } }, [redirectToLogin]); - // Redirect legacy ?page= deep links (old bookmarks) to their path-based routes. - const isLegacyRedirect = explicitPage !== null && explicitPage in MIGRATED_PAGES; + const legacyRedirectHref = legacyPageRedirectHref(searchParams); useEffect(() => { - if (!authLoading && isLegacyRedirect) { - router.replace(migratedHref(MIGRATED_PAGES[explicitPage])); + if (!authLoading && legacyRedirectHref !== null) { + router.replace(legacyRedirectHref); } - }, [authLoading, isLegacyRedirect, explicitPage, router]); + }, [authLoading, legacyRedirectHref, router]); // Check for a stored return URL after successful authentication // This handles the case where user comes back from SSO and we need to redirect to the original URL @@ -86,7 +83,7 @@ function CreateKeyPageContent() { } }, [token]); - const isRedirecting = redirectToLogin || isLegacyRedirect; + const isRedirecting = redirectToLogin || legacyRedirectHref !== null; if (authLoading || isRedirecting) { return ; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index e6257907918..35378d3d4e7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -77,6 +77,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { uiHref } from "@/utils/uiHref"; import { AUDIO_ACCEPT, IMAGE_EDIT_ACCEPT, @@ -1650,7 +1651,7 @@ const ChatUI: React.FC = ({ Select vector store(s) to use for this LLM API call. You can set up your vector store{" "} - + here . @@ -1674,7 +1675,7 @@ const ChatUI: React.FC = ({ Select guardrail(s) to use for this LLM API call. You can set up your guardrails{" "} - + here . @@ -1700,7 +1701,7 @@ const ChatUI: React.FC = ({ Select policy/policies to apply to this LLM API call. Policies define which guardrails are applied based on conditions. You can set up your policies{" "} - + here . diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index c2b2be5b063..0d8505ffbc8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -48,7 +48,6 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search), })); -// entityLinks -> migratedPages imports serverRootPath from the same module, so the mock must export it too. vi.mock("@/components/networking", () => { return { serverRootPath: "/", diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx index 642fb688057..6c78bca0b5a 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import ChatLayout from "./layout"; -const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, state } = vi.hoisted(() => { +const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = vi.hoisted(() => { const state = { enableChatUI: false, isUISettingsLoading: false, @@ -10,7 +10,7 @@ const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, sta return { state, mockReplace: vi.fn(), - mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), + mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), mockUseAuthorized: vi.fn(() => ({ accessToken: "token-123", userRole: "Internal User", @@ -30,7 +30,7 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); -vi.mock("@/utils/migratedPages", () => ({ migratedHref: mockMigratedHref })); +vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref })); vi.mock("@/components/navbar", () => ({ default: () =>
})); vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -47,7 +47,7 @@ describe("ChatLayout", () => { state.enableChatUI = false; state.isUISettingsLoading = false; mockReplace.mockClear(); - mockMigratedHref.mockClear(); + mockUiHref.mockClear(); }); it("renders the chat shell when enable_chat_ui is on", () => { diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx index fe7c327d25b..2e0db2c6bdc 100644 --- a/ui/litellm-dashboard/src/app/chat/layout.tsx +++ b/ui/litellm-dashboard/src/app/chat/layout.tsx @@ -8,7 +8,7 @@ import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { ChatShellProvider } from "@/contexts/ChatShellContext"; import ChatShell from "@/components/chat/ChatShell"; -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; // ChatShellProvider uses useSearchParams(), which requires a Suspense boundary for static export. function ChatLayoutContent({ children }: { children: React.ReactNode }) { @@ -20,7 +20,7 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) { const blocked = !isUISettingsLoading && !chatEnabled; useEffect(() => { - if (blocked) router.replace(migratedHref("")); + if (blocked) router.replace(uiHref("")); }, [blocked, router]); if (isUISettingsLoading || blocked) return null; diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 6a06e1ba612..b4b9950cdd0 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -7,6 +7,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { const state = { plugins: [] as { name: string; display_name: string; url: string }[], enableChatUI: false, + pathname: "/ui/logs", }; return { state, @@ -17,8 +18,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode })); vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); -vi.mock("next/navigation", () => ({ usePathname: () => "/ui/" })); -vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` })); +vi.mock("next/navigation", () => ({ usePathname: () => state.pathname })); vi.mock("@/hooks/useWorker", () => ({ useWorker: () => ({ isControlPlane: false, selectedWorker: null }) })); vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => false })); vi.mock("@/components/Navbar/BlogDropdown/BlogDropdown", () => ({ BlogDropdown: () => null })); @@ -32,11 +32,26 @@ describe("DashboardHeader breadcrumb", () => { afterEach(() => { state.plugins = []; state.enableChatUI = false; + state.pathname = "/ui/logs"; + }); + + it("titles the breadcrumb from the current route, not from a sidebar page id", () => { + state.pathname = "/ui/models-and-endpoints"; + render(); + + expect(screen.getByText("Models + Endpoints")).toBeInTheDocument(); + }); + + it("titles the dashboard root as Virtual Keys", () => { + state.pathname = "/ui/"; + render(); + + expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); }); it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => { state.enableChatUI = true; - render(); + render(); expect(screen.getByText("Logs")).toBeInTheDocument(); expect(screen.queryByText("Observability")).not.toBeInTheDocument(); @@ -49,7 +64,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => { - render(); + render(); expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument(); expect(screen.getByText("Logs")).toBeInTheDocument(); @@ -57,7 +72,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("styles Docs with the shared product-link class instead of a muted toolbar button", () => { - render(); + render(); const docs = screen.getByRole("link", { name: "Docs" }); for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) { @@ -67,7 +82,7 @@ describe("DashboardHeader breadcrumb", () => { }); it("renders the tools divider centered rather than stretched to the top of the row", () => { - const { container } = render(); + const { container } = render(); const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]'); expect(separators).toHaveLength(1); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index fe824ce074d..57d734d05b3 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -20,15 +20,12 @@ import { useWorker } from "@/hooks/useWorker"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; - -interface DashboardHeaderProps { - page: string; -} +import { usePathname } from "next/navigation"; // Top bar for the dashboard shell. Sits only over the content column (the brand // lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. -export function DashboardHeader({ page }: DashboardHeaderProps) { - const { title } = getBreadcrumb(page); +export function DashboardHeader() { + const { title } = getBreadcrumb(usePathname()); const { isControlPlane, selectedWorker } = useWorker(); const showWorkerSwitch = isControlPlane && selectedWorker !== null; const hideCommunityLinks = useDisableShowPrompts(); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx index 449ef2eddc6..a32df932d05 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx @@ -28,7 +28,7 @@ vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMod vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings })); vi.mock("next/navigation", () => ({ usePathname: mockUsePathname })); // Deterministic hrefs so navigation assertions don't depend on server_root_path. -vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` })); +vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}` })); describe("ViewSwitcher", () => { let assignSpy: ReturnType; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index da08b3b9328..b3aba7155d1 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -9,7 +9,7 @@ import { import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; @@ -28,7 +28,7 @@ export default function ViewSwitcher() { const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui); - const chatHref = migratedHref(CHAT); + const chatHref = uiHref(CHAT); const normalizedPathname = (pathname ?? "").replace(/\/+$/, ""); const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`)); @@ -44,7 +44,7 @@ export default function ViewSwitcher() { // The chat route lives outside the dashboard SPA shell that reacts to `mode`, // so switching modes from there needs a real navigation, not just state. if (isChatRoute) { - window.location.assign(migratedHref("")); + window.location.assign(uiHref("")); } }; @@ -57,7 +57,7 @@ export default function ViewSwitcher() { {isChatRoute && }
), - onClick: () => window.location.assign(migratedHref(CHAT)), + onClick: () => window.location.assign(uiHref(CHAT)), } : { key: CHAT, diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx index e48a83020f0..bff6d30a5c8 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx @@ -18,7 +18,7 @@ vi.mock("next/navigation", () => ({ usePathname: mockUsePathname, })); // Deterministic hrefs so navigation/active-state assertions don't depend on server_root_path. -vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" })); +vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" })); vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell })); vi.mock("./ConversationList", () => ({ default: () =>
})); diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx index ac443a6bc34..7d144944d64 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatShell.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatShell.tsx @@ -5,12 +5,12 @@ import { usePathname, useRouter } from "next/navigation"; import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; import { useChatShell } from "@/contexts/ChatShellContext"; import ConversationList from "./ConversationList"; export function getChatRoutes() { - const base = migratedHref("chat"); + const base = uiHref("chat"); return { chats: base, integrations: `${base}/integrations`, diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index c3e1f924d09..61a820bb42b 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -17,6 +17,12 @@ vi.mock("../utils/roles", async (importOriginal) => { }; }); +const navState = vi.hoisted(() => ({ pathname: "/ui/api-keys" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, +})); + const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { const mockUseAuthorized = vi.fn(() => ({ userId: "test-user-id", @@ -98,8 +104,6 @@ const placementsOf = (page: string): string[] => describe("Sidebar (leftnav)", () => { const defaultProps = { - setPage: vi.fn(), - defaultSelectedKey: "api-keys", collapsed: false, }; @@ -107,6 +111,7 @@ describe("Sidebar (leftnav)", () => { mockUseAuthorized.mockReset(); mockUseOrganizations.mockReset(); mockUseThemeImpl = unbrandedTheme; + navState.pathname = "/ui/api-keys"; }); it("should link the logo to the UI home route rather than the proxy origin", () => { @@ -509,14 +514,54 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); - it("marks the selected page's nav item active", () => { - renderWithProviders(); + it("marks the nav item for the current route active", () => { + navState.pathname = "/ui/logs"; + renderWithProviders(); const logs = screen.getByText("Logs").closest("a"); expect(logs).toHaveAttribute("data-active", "true"); // A different item must not be active. expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active"); }); + it("marks Virtual Keys active at the dashboard root", () => { + navState.pathname = "/ui/"; + renderWithProviders(); + expect(screen.getByText("Virtual Keys").closest("a")).toHaveAttribute("data-active", "true"); + }); + + it("expands the parent group of the current nested route and marks the child active", () => { + navState.pathname = "/ui/search-tools"; + renderWithProviders(); + expect(screen.getByText("Search Tools").closest("a")).toHaveAttribute("data-active", "true"); + expect(screen.getByText("Tools").closest("button")).toHaveAttribute("aria-expanded", "true"); + }); + + it("links every leaf to its path route, including the ids that differ from their route", () => { + renderWithProviders(); + act(() => { + fireEvent.click(screen.getByText("Experimental")); + }); + + const hrefOf = (label: string) => screen.getByText(label).closest("a")?.getAttribute("href"); + expect(hrefOf("Virtual Keys")).toBe("/ui/api-keys"); + expect(hrefOf("Playground")).toBe("/ui/playground"); + expect(hrefOf("Models + Endpoints")).toBe("/ui/models-and-endpoints"); + expect(hrefOf("Usage")).toBe("/ui/usage"); + expect(hrefOf("API Reference")).toBe("/ui/api-reference"); + expect(hrefOf("Old Usage")).toBe("/ui/old-usage"); + }); + + it("never links a leaf to the legacy ?page= switch", () => { + const { container } = renderWithProviders(); + for (const group of ["Agentic", "Tools", "Experimental", "Settings"]) { + act(() => { + fireEvent.click(screen.getByText(group)); + }); + } + expect(container.querySelectorAll('a[href*="page="]')).toHaveLength(0); + expect(container.querySelectorAll('nav a[href^="/ui/"]').length).toBeGreaterThan(30); + }); + it("hides labels but keeps items reachable (icon + link) when collapsed to the rail", () => { const { container } = renderWithProviders(); expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true"); @@ -550,20 +595,30 @@ describe("Sidebar (leftnav)", () => { }); describe("getBreadcrumb", () => { - it("resolves a top-level page to its section + title", () => { - expect(getBreadcrumb("api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); - expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" }); + it("resolves a top-level route to its section + title", () => { + expect(getBreadcrumb("/ui/api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + expect(getBreadcrumb("/ui/logs")).toEqual({ section: "Observability", title: "Logs" }); }); - it("resolves a nested child page to its parent section", () => { - expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" }); + it("resolves routes whose segment differs from the sidebar page id", () => { + expect(getBreadcrumb("/ui/models-and-endpoints")).toEqual({ section: "AI Gateway", title: "Models + Endpoints" }); + expect(getBreadcrumb("/ui/usage")).toEqual({ section: "Observability", title: "Usage" }); + expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" }); + }); + + it("titles the dashboard root as Virtual Keys", () => { + expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + }); + + it("resolves a nested child route to its parent section", () => { + expect(getBreadcrumb("/ui/search-tools/")).toEqual({ section: "AI Gateway", title: "Search Tools" }); }); it("resolves router-settings under the Settings section", () => { - expect(getBreadcrumb("router-settings")).toEqual({ section: "Settings", title: "Router Settings" }); + expect(getBreadcrumb("/ui/router-settings")).toEqual({ section: "Settings", title: "Router Settings" }); }); - it("falls back to a prettified title with no section for unknown pages", () => { - expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); + it("falls back to a prettified title with no section for unknown routes", () => { + expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 51ba36348e1..255bbc04735 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -62,6 +62,7 @@ import { Workflow, } from "lucide-react"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; import { cn } from "@/lib/cva.config"; import { rolesWithCapability } from "../utils/capabilities"; @@ -76,15 +77,13 @@ import { import BetaBadge from "./BetaBadge"; import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu"; import SidebarUsageCard from "./SidebarUsageCard"; -import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; +import { routeSegmentForPathname, uiHref } from "@/utils/uiHref"; const ICON = { strokeWidth: 1.75 } as const; const LOGO_CLASS_NAME = "h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7"; interface SidebarProps { - setPage: (page: string) => void; - defaultSelectedKey: string; collapsed?: boolean; onToggleCollapsed?: () => void; enabledPagesInternalUsers?: string[] | null; @@ -98,6 +97,7 @@ interface SidebarProps { interface MenuItem { key: string; page: string; + route?: string; label: string | React.ReactNode; roles?: string[]; children?: MenuItem[]; @@ -122,6 +122,7 @@ const menuGroups: MenuGroup[] = [ { key: "llm-playground", page: "llm-playground", + route: "playground", label: "Playground", icon: , roles: rolesWithWriteAccess, @@ -129,6 +130,7 @@ const menuGroups: MenuGroup[] = [ { key: "models", page: "models", + route: "models-and-endpoints", label: "Models + Endpoints", icon: , roles: rolesAllowedToViewWriteScopedPages, @@ -197,6 +199,7 @@ const menuGroups: MenuGroup[] = [ { key: "new_usage", page: "new_usage", + route: "usage", icon: , roles: [...all_admin_roles, ...internalUserRoles], label: "Usage", @@ -258,7 +261,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "DEVELOPER TOOLS", items: [ - { key: "api_ref", page: "api_ref", label: "API Reference", icon: }, + { key: "api_ref", page: "api_ref", route: "api-reference", label: "API Reference", icon: }, { key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: }, { key: "learning-resources", @@ -304,6 +307,7 @@ const menuGroups: MenuGroup[] = [ { key: "4", page: "usage", + route: "old-usage", label: "Old Usage", icon: , roles: rolesWithCapability("viewGlobalSpend"), @@ -358,24 +362,31 @@ const menuGroups: MenuGroup[] = [ }, ]; -const findParentKey = (page: string): string | null => { +const HOME_ROUTE = "api-keys"; + +const routeOf = (item: MenuItem): string => item.route ?? item.page; + +// The dashboard root serves Virtual Keys, so an empty segment selects that entry. +const routeForPathname = (pathname: string): string => routeSegmentForPathname(pathname) || HOME_ROUTE; + +const findParentKey = (route: string): string | null => { for (const group of menuGroups) { for (const item of group.items) { - if (item.children?.some((c) => c.page === page || c.key === page)) return item.key; + if (item.children?.some((c) => routeOf(c) === route)) return item.key; } } return null; }; -const findMenuItemKey = (page: string): string => { +const findMenuItemKey = (route: string): string => { for (const group of menuGroups) { for (const item of group.items) { - if (item.page === page) return item.key; - const child = item.children?.find((c) => c.page === page); + if (routeOf(item) === route) return item.key; + const child = item.children?.find((c) => routeOf(c) === route); if (child) return child.key; } } - return "api-keys"; + return HOME_ROUTE; }; const SECTION_DISPLAY: Record = { @@ -395,22 +406,20 @@ const prettify = (key: string): string => const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key)); // Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. -export const getBreadcrumb = (page: string): { section: string | null; title: string } => { +export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => { + const route = routeForPathname(pathname); for (const group of menuGroups) { for (const item of group.items) { const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel; - if (item.page === page) - return { section, title: typeof item.label === "string" ? item.label : prettify(item.key) }; - const child = item.children?.find((c) => c.page === page); - if (child) return { section, title: typeof child.label === "string" ? child.label : prettify(child.key) }; + if (routeOf(item) === route) return { section, title: labelText(item) }; + const child = item.children?.find((c) => routeOf(c) === route); + if (child) return { section, title: labelText(child) }; } } - return { section: null, title: prettify(page) }; + return { section: null, title: prettify(route) }; }; const Sidebar_: React.FC = ({ - setPage, - defaultSelectedKey, collapsed = false, onToggleCollapsed, enabledPagesInternalUsers, @@ -430,20 +439,21 @@ const Sidebar_: React.FC = ({ const baseUrl = getProxyBaseUrl(); const version = healthData?.litellm_version; - const selectedKey = findMenuItemKey(defaultSelectedKey); + const currentRoute = routeForPathname(usePathname()); + const selectedKey = findMenuItemKey(currentRoute); const [openGroups, setOpenGroups] = useState>(() => { - const parent = findParentKey(defaultSelectedKey); + const parent = findParentKey(currentRoute); return new Set(parent ? [parent] : []); }); // Keep the active page's parent group expanded as the user navigates, using the // "adjust state during render" pattern rather than an effect (avoids a // setState-in-effect render cascade). - const [prevSelectedKey, setPrevSelectedKey] = useState(defaultSelectedKey); - if (defaultSelectedKey !== prevSelectedKey) { - setPrevSelectedKey(defaultSelectedKey); - const parent = findParentKey(defaultSelectedKey); + const [prevRoute, setPrevRoute] = useState(currentRoute); + if (currentRoute !== prevRoute) { + setPrevRoute(currentRoute); + const parent = findParentKey(currentRoute); if (parent && !openGroups.has(parent)) { setOpenGroups((prev) => new Set(prev).add(parent)); } @@ -512,13 +522,6 @@ const Sidebar_: React.FC = ({ }); }; - const handleLeafClick = (e: React.MouseEvent, item: MenuItem) => { - if (item.external_url) return; - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; - e.preventDefault(); - setPage(item.page); - }; - const renderLeaf = (item: MenuItem, isChild: boolean) => { const active = selectedKey === item.key; const size = isChild ? "sub" : "default"; @@ -542,19 +545,17 @@ const Sidebar_: React.FC = ({ ); } - const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page); return ( - handleLeafClick(e, item)} + href={uiHref(routeOf(item))} title={collapsed ? labelText(item) : undefined} data-active={active || undefined} className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} > {item.icon} {label} - + ); }; @@ -603,7 +604,7 @@ const Sidebar_: React.FC = ({
- + LiteLLM = ({ )}
- +
LiteLLM Brand diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 9df9e9a9209..578e355b85d 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { clearTokenCookies } from "@/utils/cookieUtils"; import * as Networking from "./networking"; -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; vi.mock("@/utils/cookieUtils", () => ({ clearTokenCookies: vi.fn(), @@ -392,7 +392,7 @@ describe("UI config and public endpoints", () => { await Networking.getUiConfig(); expect(Networking.serverRootPath).toBe("/litellm"); - expect(migratedHref("api-reference")).toBe("/litellm/ui/api-reference"); + expect(uiHref("api-reference")).toBe("/litellm/ui/api-reference"); }); }); diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx index 700d19eb13c..799cd1adffe 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.test.tsx @@ -12,8 +12,7 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search), })); -// Mock networking calls used by the component's mutation handlers. entityLinks -> migratedPages -// imports serverRootPath from the same module, so the mock must export it too. +// Mock networking calls used by the component's mutation handlers. vi.mock("../networking", () => { return { __esModule: true, diff --git a/ui/litellm-dashboard/src/utils/entityLinks.ts b/ui/litellm-dashboard/src/utils/entityLinks.ts index ad257ec7969..b8c70bdda48 100644 --- a/ui/litellm-dashboard/src/utils/entityLinks.ts +++ b/ui/litellm-dashboard/src/utils/entityLinks.ts @@ -1,4 +1,4 @@ -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; const MODEL_GRANT_SENTINELS: ReadonlySet = new Set([ "all-proxy-models", @@ -7,22 +7,22 @@ const MODEL_GRANT_SENTINELS: ReadonlySet = new Set([ ]); export function teamDetailHref(teamId: string): string { - return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`; + return `${uiHref("teams")}?team=${encodeURIComponent(teamId)}`; } export function keyDetailHref(keyToken: string): string { - return `${migratedHref("api-keys")}?key=${encodeURIComponent(keyToken)}`; + return `${uiHref("api-keys")}?key=${encodeURIComponent(keyToken)}`; } export function userDetailHref(userId: string): string { - return `${migratedHref("users")}?user=${encodeURIComponent(userId)}`; + return `${uiHref("users")}?user=${encodeURIComponent(userId)}`; } export function orgDetailHref(orgId: string): string { - return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`; + return `${uiHref("organizations")}?org=${encodeURIComponent(orgId)}`; } export function modelGroupHref(modelGroup: string): string | undefined { if (MODEL_GRANT_SENTINELS.has(modelGroup)) return undefined; - return `${migratedHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`; + return `${uiHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`; } diff --git a/ui/litellm-dashboard/src/utils/migratedPages.test.ts b/ui/litellm-dashboard/src/utils/migratedPages.test.ts deleted file mode 100644 index 5812c1eec40..00000000000 --- a/ui/litellm-dashboard/src/utils/migratedPages.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -describe("migratedHref / legacyPageHref", () => { - beforeEach(() => { - vi.resetModules(); - vi.stubEnv("NODE_ENV", "test"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("builds a /ui-rooted path when serverRootPath is /", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { migratedHref, legacyPageHref } = await import("./migratedPages"); - - expect(migratedHref("api-reference")).toBe("/ui/api-reference"); - expect(legacyPageHref("models")).toBe("/ui/?page=models"); - }); - - it("prefixes a non-root serverRootPath without duplicating slashes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" })); - const { migratedHref, legacyPageHref } = await import("./migratedPages"); - - expect(migratedHref("api-reference")).toBe("/team-x/ui/api-reference"); - expect(legacyPageHref("models")).toBe("/team-x/ui/?page=models"); - }); - - it("tolerates a leading slash in the route segment", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { migratedHref } = await import("./migratedPages"); - - expect(migratedHref("/api-reference")).toBe("/ui/api-reference"); - }); - - it("maps both the api_ref id and the hyphenated alias to the api-reference route", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.api_ref).toBe("api-reference"); - expect(MIGRATED_PAGES["api-reference"]).toBe("api-reference"); - }); - - it("maps the api-keys landing id to its route and builds its redirect href", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages"); - - expect(MIGRATED_PAGES["api-keys"]).toBe("api-keys"); - expect(migratedHref(MIGRATED_PAGES["api-keys"])).toBe("/ui/api-keys"); - }); - - it("maps the llm-playground sidebar id to the playground route", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES["llm-playground"]).toBe("playground"); - }); - - it("maps the models sidebar id to the models-and-endpoints route and builds its redirect href", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.models).toBe("models-and-endpoints"); - expect(migratedHref(MIGRATED_PAGES.models)).toBe("/ui/models-and-endpoints"); - }); - - it("maps the projects and access-groups sidebar ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.projects).toBe("projects"); - expect(MIGRATED_PAGES["access-groups"]).toBe("access-groups"); - }); - - it("maps the budgets, workflows, and guardrails-monitor sidebar ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.budgets).toBe("budgets"); - expect(MIGRATED_PAGES.workflows).toBe("workflows"); - expect(MIGRATED_PAGES["guardrails-monitor"]).toBe("guardrails-monitor"); - }); - - it("maps the mcp-servers, search-tools, tag-management, vector-stores, and memory ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES["mcp-servers"]).toBe("mcp-servers"); - expect(MIGRATED_PAGES["search-tools"]).toBe("search-tools"); - expect(MIGRATED_PAGES["tag-management"]).toBe("tag-management"); - expect(MIGRATED_PAGES["vector-stores"]).toBe("vector-stores"); - expect(MIGRATED_PAGES.memory).toBe("memory"); - }); - - it("maps the policies, guardrails, prompts, tool-policies, and skills ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.policies).toBe("policies"); - expect(MIGRATED_PAGES.guardrails).toBe("guardrails"); - expect(MIGRATED_PAGES.prompts).toBe("prompts"); - expect(MIGRATED_PAGES["tool-policies"]).toBe("tool-policies"); - expect(MIGRATED_PAGES.skills).toBe("skills"); - // Old bookmarks used ?page=claude-code-plugins for the same panel. - expect(MIGRATED_PAGES["claude-code-plugins"]).toBe("skills"); - }); - - it("maps the caching, cost-tracking, transform-request, ui-theme, and logs ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.caching).toBe("caching"); - expect(MIGRATED_PAGES["cost-tracking"]).toBe("cost-tracking"); - expect(MIGRATED_PAGES["transform-request"]).toBe("transform-request"); - expect(MIGRATED_PAGES["ui-theme"]).toBe("ui-theme"); - expect(MIGRATED_PAGES.logs).toBe("logs"); - }); - - it("maps the admin-panel, logging-and-alerts, model-hub-table, and new_usage ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES["admin-panel"]).toBe("admin-panel"); - expect(MIGRATED_PAGES["logging-and-alerts"]).toBe("logging-and-alerts"); - expect(MIGRATED_PAGES["model-hub-table"]).toBe("model-hub-table"); - // new_usage routes to /usage; the legacy ?page=usage report routes to /old-usage (asserted below). - expect(MIGRATED_PAGES.new_usage).toBe("usage"); - }); - - it("maps the legacy usage report id to the old-usage route and builds its redirect href", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.usage).toBe("old-usage"); - expect(migratedHref(MIGRATED_PAGES.usage)).toBe("/ui/old-usage"); - }); - - it("maps the agents and router-settings ids to their routes", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.agents).toBe("agents"); - expect(MIGRATED_PAGES["router-settings"]).toBe("router-settings"); - }); - - it("maps the users id to its route", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.users).toBe("users"); - }); - - it("maps the teams id to its route", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.teams).toBe("teams"); - }); - - it("maps the organizations id to its route", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { MIGRATED_PAGES } = await import("./migratedPages"); - - expect(MIGRATED_PAGES.organizations).toBe("organizations"); - }); -}); - -describe("dev server (NODE_ENV=development)", () => { - beforeEach(() => { - vi.resetModules(); - vi.stubEnv("NODE_ENV", "development"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("builds root-relative hrefs because next dev serves the app at /, not /ui", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { migratedHref, legacyPageHref } = await import("./migratedPages"); - - expect(migratedHref("api-reference")).toBe("/api-reference"); - expect(legacyPageHref("models")).toBe("/?page=models"); - }); - - it("ignores serverRootPath, which only applies to proxy-mounted deployments", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" })); - const { migratedHref } = await import("./migratedPages"); - - expect(migratedHref("api-reference")).toBe("/api-reference"); - }); - - it("maps a bare migrated path back to its legacy sidebar key", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { legacyKeyForPathname } = await import("./migratedPages"); - - expect(legacyKeyForPathname("/api-reference/")).toBe("api_ref"); - expect(legacyKeyForPathname("/")).toBeNull(); - }); -}); - -describe("legacyKeyForPathname", () => { - beforeEach(() => { - vi.resetModules(); - vi.stubEnv("NODE_ENV", "test"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("maps a migrated path back to its legacy sidebar key (including trailing slash)", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { legacyKeyForPathname } = await import("./migratedPages"); - - // Resolves to the sidebar key api_ref, not the hyphenated alias, so highlighting works. - expect(legacyKeyForPathname("/ui/api-reference")).toBe("api_ref"); - expect(legacyKeyForPathname("/ui/api-reference/")).toBe("api_ref"); - // Same for skills: the claude-code-plugins alias maps to the same segment, - // and first-match-wins iteration must keep returning the sidebar key. - expect(legacyKeyForPathname("/ui/skills")).toBe("skills"); - }); - - it("returns null for a not-yet-migrated path", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { legacyKeyForPathname } = await import("./migratedPages"); - - expect(legacyKeyForPathname("/ui/")).toBeNull(); - expect(legacyKeyForPathname("/ui/some-legacy-page")).toBeNull(); - }); - - it("strips a non-root serverRootPath prefix before matching", async () => { - vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" })); - const { legacyKeyForPathname } = await import("./migratedPages"); - - expect(legacyKeyForPathname("/team-x/ui/api-reference")).toBe("api_ref"); - expect(legacyKeyForPathname("/ui/api-reference")).toBeNull(); - }); -}); diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts deleted file mode 100644 index 73ab71ce4ac..00000000000 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { serverRootPath } from "@/components/networking"; - -/** - * Single source of truth for pages cut over from the legacy `?page=` switch in - * app/page.tsx to path-based routes under app/(dashboard)/. - * - * Key = legacy page id emitted by the sidebar. Value = route segment under (dashboard)/. - * Add an entry to route the sidebar and deep links to the new path and redirect the - * legacy `?page=` URL; remove it to roll back. - */ -export const MIGRATED_PAGES: Record = { - "api-keys": "api-keys", - models: "models-and-endpoints", - api_ref: "api-reference", - // Legacy alias: older bookmarks used the hyphenated ?page=api-reference form. - "api-reference": "api-reference", - "llm-playground": "playground", - projects: "projects", - chat: "chat", - "access-groups": "access-groups", - budgets: "budgets", - workflows: "workflows", - "guardrails-monitor": "guardrails-monitor", - "mcp-servers": "mcp-servers", - "search-tools": "search-tools", - "tag-management": "tag-management", - "vector-stores": "vector-stores", - memory: "memory", - policies: "policies", - guardrails: "guardrails", - prompts: "prompts", - "tool-policies": "tool-policies", - skills: "skills", - // Legacy alias: the old switch matched ?page=claude-code-plugins for the same panel. - "claude-code-plugins": "skills", - caching: "caching", - "cost-tracking": "cost-tracking", - "transform-request": "transform-request", - "ui-theme": "ui-theme", - logs: "logs", - "admin-panel": "admin-panel", - "logging-and-alerts": "logging-and-alerts", - "model-hub-table": "model-hub-table", - // The modern usage dashboard; the legacy ?page=usage report routes to /old-usage. - new_usage: "usage", - usage: "old-usage", - "cost-optimization": "cost-optimization", - agents: "agents", - "router-settings": "router-settings", - users: "users", - teams: "teams", - organizations: "organizations", -}; - -function uiBase(): string { - // next dev serves the app at the root; only the proxy mounts the static export under /ui - // (and optionally under server_root_path). Inlined at build time, so production is unaffected. - if (process.env.NODE_ENV === "development") { - return ""; - } - const root = serverRootPath && serverRootPath !== "/" ? `/${serverRootPath.replace(/^\/+|\/+$/g, "")}` : ""; - return `${root}/ui`; -} - -/** Absolute (same-origin) href for a migrated route segment, e.g. "api-reference" -> "/ui/api-reference". */ -export function migratedHref(routeSegment: string): string { - return `${uiBase()}/${routeSegment.replace(/^\/+/, "")}`; -} - -/** Href for a not-yet-migrated page, served by the legacy `?page=` switch at the UI root. */ -export function legacyPageHref(pageKey: string): string { - return `${uiBase()}/?page=${pageKey}`; -} - -/** Reverse-maps a path-routed location back to its legacy page id, e.g. "/ui/api-reference" -> "api_ref". */ -export function legacyKeyForPathname(pathname: string): string | null { - const base = uiBase(); - const rel = (pathname.startsWith(base) ? pathname.slice(base.length) : pathname).replace(/^\/+|\/+$/g, ""); - for (const [key, segment] of Object.entries(MIGRATED_PAGES)) { - if (rel === segment) return key; - } - return null; -} diff --git a/ui/litellm-dashboard/src/utils/tabRoutes.ts b/ui/litellm-dashboard/src/utils/tabRoutes.ts index f27b1f5d49f..4af2b983cba 100644 --- a/ui/litellm-dashboard/src/utils/tabRoutes.ts +++ b/ui/litellm-dashboard/src/utils/tabRoutes.ts @@ -1,4 +1,4 @@ -import { migratedHref } from "@/utils/migratedPages"; +import { uiHref } from "@/utils/uiHref"; export interface TabRoutes { baseSegment: string; @@ -9,7 +9,7 @@ export interface TabRoutes { export function createTabRoutes(baseSegment: string, slugs: readonly Slug[]): TabRoutes { const tabHref = (slug: string): string => { - const base = migratedHref(baseSegment); + const base = uiHref(baseSegment); return slug ? `${base}/${slug}/` : `${base}/`; }; diff --git a/ui/litellm-dashboard/src/utils/uiHref.test.ts b/ui/litellm-dashboard/src/utils/uiHref.test.ts new file mode 100644 index 00000000000..dae2594929c --- /dev/null +++ b/ui/litellm-dashboard/src/utils/uiHref.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setServerRootPath } from "@/lib/serverRootPath"; +import { routeSegmentForPathname, uiHref } from "./uiHref"; + +afterEach(() => { + setServerRootPath("/"); + vi.unstubAllEnvs(); +}); + +describe("uiHref", () => { + it("builds a /ui-rooted path when serverRootPath is /", () => { + expect(uiHref("api-reference")).toBe("/ui/api-reference"); + }); + + it("prefixes a non-root serverRootPath without duplicating slashes", () => { + setServerRootPath("/team-x/"); + expect(uiHref("api-reference")).toBe("/team-x/ui/api-reference"); + }); + + it("tolerates a leading slash in the route segment", () => { + expect(uiHref("/api-reference")).toBe("/ui/api-reference"); + }); + + it("stays root-relative under next dev, which serves the app at /", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(uiHref("logs")).toBe("/logs"); + }); +}); + +describe("routeSegmentForPathname", () => { + it("strips the /ui base and any trailing slash", () => { + expect(routeSegmentForPathname("/ui/api-reference")).toBe("api-reference"); + expect(routeSegmentForPathname("/ui/api-reference/")).toBe("api-reference"); + }); + + it("returns an empty segment for the dashboard root", () => { + expect(routeSegmentForPathname("/ui/")).toBe(""); + expect(routeSegmentForPathname("/ui")).toBe(""); + }); + + it("keeps only the first segment of a nested path", () => { + expect(routeSegmentForPathname("/ui/models-and-endpoints/anything")).toBe("models-and-endpoints"); + }); + + it("strips a non-root serverRootPath too", () => { + setServerRootPath("/team-x/"); + expect(routeSegmentForPathname("/team-x/ui/guardrails")).toBe("guardrails"); + }); + + it("reads the segment straight after / under next dev", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(routeSegmentForPathname("/logs")).toBe("logs"); + expect(routeSegmentForPathname("/")).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/uiHref.ts b/ui/litellm-dashboard/src/utils/uiHref.ts new file mode 100644 index 00000000000..83e65b4d5a3 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/uiHref.ts @@ -0,0 +1,23 @@ +import { serverRootPath } from "@/lib/serverRootPath"; + +function uiBase(): string { + // next dev serves the app at the root; only the proxy mounts the static export under /ui + // (and optionally under server_root_path). Inlined at build time, so production is unaffected. + if (process.env.NODE_ENV === "development") { + return ""; + } + const root = serverRootPath && serverRootPath !== "/" ? `/${serverRootPath.replace(/^\/+|\/+$/g, "")}` : ""; + return `${root}/ui`; +} + +/** Absolute (same-origin) href for a dashboard route segment, e.g. "api-reference" -> "/ui/api-reference". */ +export function uiHref(routeSegment: string): string { + return `${uiBase()}/${routeSegment.replace(/^\/+/, "")}`; +} + +/** First route segment under the UI base, e.g. "/ui/api-reference/" -> "api-reference" and "/ui/" -> "". */ +export function routeSegmentForPathname(pathname: string): string { + const base = uiBase(); + const relative = pathname.startsWith(base) ? pathname.slice(base.length) : pathname; + return relative.replace(/^\/+/, "").split("/")[0]; +} From 2dfa14648a76eb2b249808aaf0e617cadb8d7b21 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 16:50:20 -0700 Subject: [PATCH 2/3] refactor(ui): drop comments that restate the redirect table and home route --- ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts | 2 -- ui/litellm-dashboard/src/components/leftnav.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 5c8a22fe795..cf943b331b9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -1,7 +1,5 @@ import { uiHref } from "@/utils/uiHref"; -// Old ?page= bookmarks and the proxy's MCP env-var setup link still land on the UI root; -// this table sends them to the path route that replaced each page id. const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( Object.entries({ "api-keys": "api-keys", diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 255bbc04735..9d772f45153 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -366,7 +366,6 @@ const HOME_ROUTE = "api-keys"; const routeOf = (item: MenuItem): string => item.route ?? item.page; -// The dashboard root serves Virtual Keys, so an empty segment selects that entry. const routeForPathname = (pathname: string): string => routeSegmentForPathname(pathname) || HOME_ROUTE; const findParentKey = (route: string): string | null => { From 0c45e28dbd520bd57fbddbe0c859034d2325d991 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 17:07:27 -0700 Subject: [PATCH 3/3] test(ui): query sidebar links by role so the testing-library budgets stay under their ceilings --- ui/litellm-dashboard/eslint-budgets.json | 2 +- .../src/components/leftnav.test.tsx | 34 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index b3c77e287fc..e98cea9261e 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -6,6 +6,6 @@ "local/no-large-inline-object-arg": { "max": 554, "target": 300 }, "local/no-long-condition-chain": { "max": 265, "target": 120 }, "testing-library/no-container": { "max": 133, "target": 50 }, - "testing-library/no-node-access": { "max": 716, "target": 500 }, + "testing-library/no-node-access": { "max": 707, "target": 500 }, "testing-library/prefer-screen-queries": { "max": 18, "target": 18 } } diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 61a820bb42b..6eb0218c41d 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -517,23 +517,21 @@ describe("Sidebar (leftnav)", () => { it("marks the nav item for the current route active", () => { navState.pathname = "/ui/logs"; renderWithProviders(); - const logs = screen.getByText("Logs").closest("a"); - expect(logs).toHaveAttribute("data-active", "true"); - // A different item must not be active. - expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active"); + expect(screen.getByRole("link", { name: "Logs" })).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active"); }); it("marks Virtual Keys active at the dashboard root", () => { navState.pathname = "/ui/"; renderWithProviders(); - expect(screen.getByText("Virtual Keys").closest("a")).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true"); }); it("expands the parent group of the current nested route and marks the child active", () => { navState.pathname = "/ui/search-tools"; renderWithProviders(); - expect(screen.getByText("Search Tools").closest("a")).toHaveAttribute("data-active", "true"); - expect(screen.getByText("Tools").closest("button")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("link", { name: "Search Tools" })).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("button", { name: "Tools" })).toHaveAttribute("aria-expanded", "true"); }); it("links every leaf to its path route, including the ids that differ from their route", () => { @@ -542,24 +540,26 @@ describe("Sidebar (leftnav)", () => { fireEvent.click(screen.getByText("Experimental")); }); - const hrefOf = (label: string) => screen.getByText(label).closest("a")?.getAttribute("href"); - expect(hrefOf("Virtual Keys")).toBe("/ui/api-keys"); - expect(hrefOf("Playground")).toBe("/ui/playground"); - expect(hrefOf("Models + Endpoints")).toBe("/ui/models-and-endpoints"); - expect(hrefOf("Usage")).toBe("/ui/usage"); - expect(hrefOf("API Reference")).toBe("/ui/api-reference"); - expect(hrefOf("Old Usage")).toBe("/ui/old-usage"); + const expectHref = (label: string, href: string) => + expect(screen.getByRole("link", { name: label })).toHaveAttribute("href", href); + expectHref("Virtual Keys", "/ui/api-keys"); + expectHref("Playground", "/ui/playground"); + expectHref("Models + Endpoints", "/ui/models-and-endpoints"); + expectHref("Usage", "/ui/usage"); + expectHref("API Reference", "/ui/api-reference"); + expectHref("Old Usage", "/ui/old-usage"); }); it("never links a leaf to the legacy ?page= switch", () => { - const { container } = renderWithProviders(); + renderWithProviders(); for (const group of ["Agentic", "Tools", "Experimental", "Settings"]) { act(() => { fireEvent.click(screen.getByText(group)); }); } - expect(container.querySelectorAll('a[href*="page="]')).toHaveLength(0); - expect(container.querySelectorAll('nav a[href^="/ui/"]').length).toBeGreaterThan(30); + const hrefs = screen.getAllByRole("link").map((link) => link.getAttribute("href") ?? ""); + expect(hrefs.filter((href) => href.includes("page="))).toHaveLength(0); + expect(hrefs.filter((href) => href.startsWith("/ui/")).length).toBeGreaterThan(30); }); it("hides labels but keeps items reachable (icon + link) when collapsed to the rail", () => {