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:
parent
728f057c5e
commit
1afc41cb29
@ -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);
|
||||
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user