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 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);

View File

@ -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" ? (
<AdminPanel proxySettings={proxySettings} />
) : page == "api_ref" || page == "api-reference" ? (
<APIReferenceView proxySettings={proxySettings} />
) : page == "logging-and-alerts" ? (
<Settings userID={userID} userRole={userRole} accessToken={accessToken} premiumUser={premiumUser} />
) : page == "budgets" ? (

View File

@ -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<SidebarProps> = ({
// 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 <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.
@ -447,15 +438,8 @@ const Sidebar: React.FC<SidebarProps> = ({
</a>
);
}
// 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 (
<a
href={href}

View File

@ -1,6 +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";
vi.mock("@/utils/cookieUtils", () => ({
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", () => {

View File

@ -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;
};

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(() => {
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();
});
});

View File

@ -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<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 {
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;
}