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
/<root>/ui/api-reference. Adds the missing updateServerRootPath call plus a
regression test asserting getUiConfig sets serverRootPath and that migratedHref
carries the prefix
This commit is contained in:
ryan-crabbe-berri 2026-06-08 13:05:12 -07:00 committed by GitHub
parent 728f057c5e
commit 1afc41cb29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 112 additions and 96 deletions

View File

@ -5,51 +5,29 @@ import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext"; import { ThemeProvider } from "@/contexts/ThemeContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; 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 { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { MIGRATED_PAGES } from "@/utils/migratedPages"; import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } 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}`;
}
/** -------------------------------- */
function LayoutContent({ children }: { children: React.ReactNode }) { function LayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname();
const { accessToken } = useAuthorized(); const { accessToken } = useAuthorized();
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
const [page, setPage] = useState(() => { const [page, setPage] = useState(() => {
return searchParams.get("page") || "api-keys"; return legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys";
}); });
const handleSetPage = (newPage: string) => { const handleSetPage = (newPage: string) => {
// If the page has been migrated to path routing, navigate there
const migratedRoute = MIGRATED_PAGES[newPage]; const migratedRoute = MIGRATED_PAGES[newPage];
if (migratedRoute) { router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage));
router.push(withBase(migratedRoute));
setPage(newPage);
return;
}
// Otherwise, navigate back to the legacy root page with query params
router.push(withBase(`?page=${newPage}`));
setPage(newPage); setPage(newPage);
}; };
useEffect(() => { useEffect(() => {
setPage(searchParams.get("page") || "api-keys"); setPage(legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys");
}, [searchParams]); }, [pathname, searchParams]);
const toggleSidebar = () => setSidebarCollapsed((v) => !v); const toggleSidebar = () => setSidebarCollapsed((v) => !v);

View File

@ -1,6 +1,5 @@
"use client"; "use client";
import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import OldModelDashboard from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView"; import OldModelDashboard from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView";
import PlaygroundPage from "@/app/(dashboard)/playground/page"; import PlaygroundPage from "@/app/(dashboard)/playground/page";
@ -54,7 +53,7 @@ import {
storeReturnUrl, storeReturnUrl,
} from "@/utils/returnUrlUtils"; } from "@/utils/returnUrlUtils";
import { isAdminRole } from "@/utils/roles"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Suspense, useEffect, useMemo, useRef, useState } from "react";
@ -156,15 +155,16 @@ function CreateKeyPageContent() {
return searchParams.get("page") || "api-keys"; return searchParams.get("page") || "api-keys";
}); });
// Custom setPage function that updates URL
const updatePage = (newPage: string) => { 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); const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", newPage); newSearchParams.set("page", newPage);
// Use Next.js router to update URL
window.history.pushState(null, "", `?${newSearchParams.toString()}`); window.history.pushState(null, "", `?${newSearchParams.toString()}`);
setPage(newPage); setPage(newPage);
}; };
@ -199,8 +199,7 @@ function CreateKeyPageContent() {
const isLegacyRedirect = page in MIGRATED_PAGES; const isLegacyRedirect = page in MIGRATED_PAGES;
useEffect(() => { useEffect(() => {
if (!authLoading && isLegacyRedirect) { if (!authLoading && isLegacyRedirect) {
const base = (proxyBaseUrl || "") + "/ui"; router.replace(migratedHref(MIGRATED_PAGES[page]));
router.replace(`${base}/${MIGRATED_PAGES[page]}`);
} }
}, [authLoading, isLegacyRedirect, page, router]); }, [authLoading, isLegacyRedirect, page, router]);
@ -441,8 +440,6 @@ function CreateKeyPageContent() {
/> />
) : page == "admin-panel" ? ( ) : page == "admin-panel" ? (
<AdminPanel proxySettings={proxySettings} /> <AdminPanel proxySettings={proxySettings} />
) : page == "api_ref" || page == "api-reference" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? ( ) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} /> <Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? ( ) : page == "budgets" ? (

View File

@ -43,7 +43,7 @@ import {
import NewBadge from "./common_components/NewBadge"; import NewBadge from "./common_components/NewBadge";
import type { Organization } from "./networking"; import type { Organization } from "./networking";
import UsageIndicator from "./UsageIndicator"; import UsageIndicator from "./UsageIndicator";
import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages"; import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
const { Sider } = Layout; const { Sider } = Layout;
// Define the props type // Define the props type
@ -418,18 +418,9 @@ const Sidebar: React.FC<SidebarProps> = ({
// Check if user is a team admin for any team // Check if user is a team admin for any team
const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]);
// Navigate to page helper // The parent (legacy root page or dashboard layout) owns navigation for both
const navigateToPage = (page: string) => { // migrated and legacy pages; the sidebar only reports the selected page.
// For migrated pages, just call setPage — the parent layout handles routing const navigateToPage = (page: string) => setPage(page);
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);
};
// Wrap label in <a> so every nav item supports right-click → "Open in new tab" // Wrap label in <a> 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. // 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<SidebarProps> = ({
</a> </a>
); );
} }
// For migrated pages, generate a path-based href for right-click "Open in new tab"
const migratedRoute = MIGRATED_PAGES[page]; const migratedRoute = MIGRATED_PAGES[page];
const href = migratedRoute const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page);
? migratedHref(migratedRoute)
: (() => {
const params = new URLSearchParams(window.location.search);
params.set("page", page);
return `?${params.toString()}`;
})();
return ( return (
<a <a
href={href} href={href}

View File

@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearTokenCookies } from "@/utils/cookieUtils";
import * as Networking from "./networking"; import * as Networking from "./networking";
import { migratedHref } from "@/utils/migratedPages";
vi.mock("@/utils/cookieUtils", () => ({ vi.mock("@/utils/cookieUtils", () => ({
clearTokenCookies: vi.fn(), clearTokenCookies: vi.fn(),
@ -349,6 +350,20 @@ describe("UI config and public endpoints", () => {
); );
expect(configCall).toBeDefined(); 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", () => { describe("individualModelHealthCheckCall", () => {

View File

@ -404,6 +404,7 @@ export const getUiConfig = async () => {
* Update the proxy base url and server root path * Update the proxy base url and server root path
*/ */
console.log("jsonData in getUiConfig:", jsonData); console.log("jsonData in getUiConfig:", jsonData);
updateServerRootPath(jsonData.server_root_path);
updateProxyBaseUrl(jsonData.server_root_path, jsonData.proxy_base_url); updateProxyBaseUrl(jsonData.server_root_path, jsonData.proxy_base_url);
return jsonData; return jsonData;
}; };

View File

@ -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(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.unstubAllEnvs();
}); });
afterEach(() => { it("builds a /ui-rooted path when serverRootPath is /", async () => {
vi.unstubAllEnvs();
});
it("returns an absolute path rooted at / when serverRootPath is /", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); 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("api-reference")).toBe("/ui/api-reference");
expect(migratedHref("virtual-keys")).toBe("/virtual-keys"); expect(legacyPageHref("models")).toBe("/ui/?page=models");
}); });
it("prefixes a non-root serverRootPath without duplicating slashes", async () => { it("prefixes a non-root serverRootPath without duplicating slashes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" })); 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 () => { it("tolerates a leading slash in the route segment", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "ui");
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" })); vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { migratedHref } = await import("./migratedPages"); 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 () => { it("maps both the api_ref id and the hyphenated alias to the api-reference route", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "ui"); vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x" })); const { MIGRATED_PAGES } = await import("./migratedPages");
const { migratedHref } = 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();
}); });
}); });

View File

@ -6,20 +6,35 @@ import { serverRootPath } from "@/components/networking";
* *
* Key = legacy page id emitted by the sidebar. Value = route segment under (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 * 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<string, string> = {}; export const MIGRATED_PAGES: Record<string, string> = {
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 { function uiBase(): string {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? ""; const root = serverRootPath && serverRootPath !== "/" ? `/${serverRootPath.replace(/^\/+|\/+$/g, "")}` : "";
const trimmed = raw.replace(/^\/+|\/+$/g, ""); return `${root}/ui`;
let base = trimmed ? `/${trimmed}/` : "/"; }
if (serverRootPath && serverRootPath !== "/") { /** Absolute (same-origin) href for a migrated route segment, e.g. "api-reference" -> "/ui/api-reference". */
const cleanRoot = serverRootPath.replace(/\/+$/, ""); export function migratedHref(routeSegment: string): string {
const cleanBase = base.replace(/^\/+/, ""); return `${uiBase()}/${routeSegment.replace(/^\/+/, "")}`;
base = `${cleanRoot}/${cleanBase}`; }
}
/** Href for a not-yet-migrated page, served by the legacy `?page=` switch at the UI root. */
return `${base}${routeSegment}`; 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;
} }