From 1afc41cb295e78a25a5d67389ac5a831e290ee8d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 8 Jun 2026 13:05:12 -0700 Subject: [PATCH] fix(ui): unify migrated-route URLs and migrate the API Reference page (#29953) * fix(ui): unify migrated-route URLs and cut the API Reference page over to path routing Route all migrated-page navigation through one /ui-prefixed, serverRootPath-aware builder in migratedPages.ts (migratedHref/legacyPageHref/legacyKeyForPathname), replacing the three divergent base-URL constructions that lived in the dashboard layout's withBase, leftnav, and the page.tsx redirect. The previous migratedHref read NEXT_PUBLIC_BASE_URL, which no build sets, so it produced URLs without the /ui prefix the app is served under; every other internal link hardcodes /ui and this now matches that convention. Remove the sidebar's own pushState navigation so the parent (legacy root page or dashboard layout) is the single owner of navigation, fixing the double-navigate that fired when moving between a path route and a legacy ?page= route. Cut API Reference over to its path route: add api_ref -> api-reference to MIGRATED_PAGES and delete its arm from the legacy switch. Visiting /ui/?page=api_ref redirects to /ui/api-reference, the sidebar links to and highlights it, and navigating away returns to the legacy switch. * fix(ui): address review on migrated-page routing Keep the legacy hyphenated ?page=api-reference form working by mapping it to the api-reference route alongside api_ref; the old switch matched both, so a bookmark using the hyphen would otherwise fall through to the Usage default. Add legacyKeyForPathname coverage: a migrated path (with and without trailing slash) resolves to the api_ref sidebar key rather than the alias, a non-migrated path returns null, and a non-root serverRootPath prefix is stripped before matching. * fix(ui): populate serverRootPath from getUiConfig so migrated nav links keep the root path getUiConfig updated proxyBaseUrl but never called updateServerRootPath, so the module-level serverRootPath stayed at its "/" default. Under a custom server_root_path the unified migratedHref/legacyPageHref builders then dropped the prefix and the sidebar produced /ui/api-reference (404) instead of //ui/api-reference. Adds the missing updateServerRootPath call plus a regression test asserting getUiConfig sets serverRootPath and that migratedHref carries the prefix --- .../src/app/(dashboard)/layout.tsx | 36 ++-------- ui/litellm-dashboard/src/app/page.tsx | 19 +++--- .../src/components/leftnav.tsx | 26 ++----- .../src/components/networking.test.ts | 15 ++++ .../src/components/networking.tsx | 1 + .../src/utils/migratedPages.test.ts | 68 +++++++++++++------ .../src/utils/migratedPages.ts | 43 ++++++++---- 7 files changed, 112 insertions(+), 96 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 7dc2b2ad61..5f5c240d02 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -5,51 +5,29 @@ import Navbar from "@/components/navbar"; import { ThemeProvider } from "@/contexts/ThemeContext"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; -import { MIGRATED_PAGES } from "@/utils/migratedPages"; - -/** ---- BASE URL HELPERS ---- */ -function normalizeBasePrefix(raw: string | undefined | null): string { - const trimmed = (raw ?? "").trim(); - if (!trimmed) return ""; - const core = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); - return core ? `/${core}/` : "/"; -} -const BASE_PREFIX = normalizeBasePrefix(process.env.NEXT_PUBLIC_BASE_URL); -function withBase(path: string): string { - const body = path.startsWith("/") ? path.slice(1) : path; - const combined = `${BASE_PREFIX}${body}`; - return combined.startsWith("/") ? combined : `/${combined}`; -} -/** -------------------------------- */ +import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages"; function LayoutContent({ children }: { children: React.ReactNode }) { const router = useRouter(); const searchParams = useSearchParams(); + const pathname = usePathname(); const { accessToken } = useAuthorized(); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); const [page, setPage] = useState(() => { - return searchParams.get("page") || "api-keys"; + return legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys"; }); const handleSetPage = (newPage: string) => { - // If the page has been migrated to path routing, navigate there const migratedRoute = MIGRATED_PAGES[newPage]; - if (migratedRoute) { - router.push(withBase(migratedRoute)); - setPage(newPage); - return; - } - - // Otherwise, navigate back to the legacy root page with query params - router.push(withBase(`?page=${newPage}`)); + router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage)); setPage(newPage); }; useEffect(() => { - setPage(searchParams.get("page") || "api-keys"); - }, [searchParams]); + setPage(legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys"); + }, [pathname, searchParams]); const toggleSidebar = () => setSidebarCollapsed((v) => !v); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index f329a00d60..12dd39a1c2 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -1,6 +1,5 @@ "use client"; -import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import OldModelDashboard from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView"; import PlaygroundPage from "@/app/(dashboard)/playground/page"; @@ -54,7 +53,7 @@ import { storeReturnUrl, } from "@/utils/returnUrlUtils"; import { isAdminRole } from "@/utils/roles"; -import { MIGRATED_PAGES } from "@/utils/migratedPages"; +import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useRef, useState } from "react"; @@ -156,15 +155,16 @@ function CreateKeyPageContent() { return searchParams.get("page") || "api-keys"; }); - // Custom setPage function that updates URL const updatePage = (newPage: string) => { - // Update URL without full page reload + const migratedRoute = MIGRATED_PAGES[newPage]; + if (migratedRoute) { + router.push(migratedHref(migratedRoute)); + setPage(newPage); + return; + } const newSearchParams = new URLSearchParams(searchParams); newSearchParams.set("page", newPage); - - // Use Next.js router to update URL window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(newPage); }; @@ -199,8 +199,7 @@ function CreateKeyPageContent() { const isLegacyRedirect = page in MIGRATED_PAGES; useEffect(() => { if (!authLoading && isLegacyRedirect) { - const base = (proxyBaseUrl || "") + "/ui"; - router.replace(`${base}/${MIGRATED_PAGES[page]}`); + router.replace(migratedHref(MIGRATED_PAGES[page])); } }, [authLoading, isLegacyRedirect, page, router]); @@ -441,8 +440,6 @@ function CreateKeyPageContent() { /> ) : page == "admin-panel" ? ( - ) : page == "api_ref" || page == "api-reference" ? ( - ) : page == "logging-and-alerts" ? ( ) : page == "budgets" ? ( diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index ff0dac703d..f4946b81d6 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -43,7 +43,7 @@ import { import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; import UsageIndicator from "./UsageIndicator"; -import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages"; +import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; const { Sider } = Layout; // Define the props type @@ -418,18 +418,9 @@ const Sidebar: React.FC = ({ // Check if user is a team admin for any team const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); - // Navigate to page helper - const navigateToPage = (page: string) => { - // For migrated pages, just call setPage — the parent layout handles routing - if (MIGRATED_PAGES[page]) { - setPage(page); - return; - } - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(page); - }; + // The parent (legacy root page or dashboard layout) owns navigation for both + // migrated and legacy pages; the sidebar only reports the selected page. + const navigateToPage = (page: string) => setPage(page); // Wrap label in so every nav item supports right-click → "Open in new tab" // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. @@ -447,15 +438,8 @@ const Sidebar: React.FC = ({ ); } - // For migrated pages, generate a path-based href for right-click "Open in new tab" const migratedRoute = MIGRATED_PAGES[page]; - const href = migratedRoute - ? migratedHref(migratedRoute) - : (() => { - const params = new URLSearchParams(window.location.search); - params.set("page", page); - return `?${params.toString()}`; - })(); + const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page); return ( ({ clearTokenCookies: vi.fn(), @@ -349,6 +350,20 @@ describe("UI config and public endpoints", () => { ); expect(configCall).toBeDefined(); }); + + it("updates serverRootPath so path-based nav links carry the root path", async () => { + const uiConfig = { + server_root_path: "/litellm", + proxy_base_url: "https://example.com", + }; + + setupMockFetch([{ url: "/litellm/.well-known/litellm-ui-config", data: uiConfig }]); + + await Networking.getUiConfig(); + + expect(Networking.serverRootPath).toBe("/litellm"); + expect(migratedHref("api-reference")).toBe("/litellm/ui/api-reference"); + }); }); describe("individualModelHealthCheckCall", () => { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d303734dff..54a7a19362 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -404,6 +404,7 @@ export const getUiConfig = async () => { * Update the proxy base url and server root path */ console.log("jsonData in getUiConfig:", jsonData); + updateServerRootPath(jsonData.server_root_path); updateProxyBaseUrl(jsonData.server_root_path, jsonData.proxy_base_url); return jsonData; }; diff --git a/ui/litellm-dashboard/src/utils/migratedPages.test.ts b/ui/litellm-dashboard/src/utils/migratedPages.test.ts index 2f9ed11b3a..e9aceea814 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.test.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.test.ts @@ -1,43 +1,69 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; -describe("migratedHref", () => { +describe("migratedHref / legacyPageHref", () => { beforeEach(() => { vi.resetModules(); - vi.unstubAllEnvs(); }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("returns an absolute path rooted at / when serverRootPath is /", async () => { + it("builds a /ui-rooted path when serverRootPath is /", async () => { vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); - const { migratedHref } = await import("./migratedPages"); + const { migratedHref, legacyPageHref } = await import("./migratedPages"); - expect(migratedHref("api-reference")).toBe("/api-reference"); - expect(migratedHref("virtual-keys")).toBe("/virtual-keys"); + 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 } = await import("./migratedPages"); + const { migratedHref, legacyPageHref } = await import("./migratedPages"); - expect(migratedHref("api-reference")).toBe("/team-x/api-reference"); + expect(migratedHref("api-reference")).toBe("/team-x/ui/api-reference"); + expect(legacyPageHref("models")).toBe("/team-x/ui/?page=models"); }); - it("honors NEXT_PUBLIC_BASE_URL as a base path segment", async () => { - vi.stubEnv("NEXT_PUBLIC_BASE_URL", "ui"); + 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"); + expect(migratedHref("/api-reference")).toBe("/ui/api-reference"); }); - it("combines NEXT_PUBLIC_BASE_URL with a non-root serverRootPath", async () => { - vi.stubEnv("NEXT_PUBLIC_BASE_URL", "ui"); - vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x" })); - const { migratedHref } = await import("./migratedPages"); + 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(migratedHref("api-reference")).toBe("/team-x/ui/api-reference"); + expect(MIGRATED_PAGES.api_ref).toBe("api-reference"); + expect(MIGRATED_PAGES["api-reference"]).toBe("api-reference"); + }); +}); + +describe("legacyKeyForPathname", () => { + beforeEach(() => { + vi.resetModules(); + }); + + 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"); + }); + + 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 index 5421f00bf0..2c27e4fee6 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -6,20 +6,35 @@ import { serverRootPath } from "@/components/networking"; * * 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. Empty until a page is migrated. + * legacy `?page=` URL; remove it to roll back. */ -export const MIGRATED_PAGES: Record = {}; +export const MIGRATED_PAGES: Record = { + api_ref: "api-reference", + // Legacy alias: older bookmarks used the hyphenated ?page=api-reference form. + "api-reference": "api-reference", +}; -export function migratedHref(routeSegment: string): string { - const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; - const trimmed = raw.replace(/^\/+|\/+$/g, ""); - let base = trimmed ? `/${trimmed}/` : "/"; - - if (serverRootPath && serverRootPath !== "/") { - const cleanRoot = serverRootPath.replace(/\/+$/, ""); - const cleanBase = base.replace(/^\/+/, ""); - base = `${cleanRoot}/${cleanBase}`; - } - - return `${base}${routeSegment}`; +function uiBase(): string { + 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; }