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/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/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..cf943b331b9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -0,0 +1,54 @@ +import { uiHref } from "@/utils/uiHref"; + +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..6eb0218c41d 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,12 +514,52 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); - it("marks the selected page's nav item active", () => { - 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 the nav item for the current route active", () => { + navState.pathname = "/ui/logs"; + renderWithProviders(); + 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.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.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", () => { + renderWithProviders(); + act(() => { + fireEvent.click(screen.getByText("Experimental")); + }); + + 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", () => { + renderWithProviders(); + for (const group of ["Agentic", "Tools", "Experimental", "Settings"]) { + act(() => { + fireEvent.click(screen.getByText(group)); + }); + } + 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", () => { @@ -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..9d772f45153 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,30 @@ const menuGroups: MenuGroup[] = [ }, ]; -const findParentKey = (page: string): string | null => { +const HOME_ROUTE = "api-keys"; + +const routeOf = (item: MenuItem): string => item.route ?? item.page; + +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 +405,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 +438,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 +521,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 +544,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 +603,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]; +}