refactor(ui): single source of truth for migrated-page routing (#29949)
Consolidate the three hand-synced copies of the migrated-pages map (LEGACY_REDIRECTS in app/page.tsx, MIGRATED_PAGES in the dashboard layout, and MIGRATED_PAGES in leftnav) into one shared module, src/utils/migratedPages.ts, which also owns the migratedHref helper. Delete the unused, incomplete Sidebar2 prototype. No runtime behavior change: the map is still empty and Sidebar2 had no importers, so this is pure deduplication ahead of the per-page App Router migration. Follow-up work will unify the remaining base-URL builders (layout's withBase and page.tsx's redirect) onto migratedHref.
This commit is contained in:
parent
f5b11b72a6
commit
ff6cea4833
@ -1,472 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Layout, Menu, ConfigProvider } from "antd";
|
||||
import {
|
||||
KeyOutlined,
|
||||
PlayCircleOutlined,
|
||||
BlockOutlined,
|
||||
BarChartOutlined,
|
||||
TeamOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ApiOutlined,
|
||||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
LineChartOutlined,
|
||||
SafetyOutlined,
|
||||
ExperimentOutlined,
|
||||
ToolOutlined,
|
||||
TagsOutlined,
|
||||
AuditOutlined,
|
||||
} from "@ant-design/icons";
|
||||
// import {
|
||||
// all_admin_roles,
|
||||
// rolesWithWriteAccess,
|
||||
// internalUserRoles,
|
||||
// isAdminRole,
|
||||
// } from "../utils/roles";
|
||||
// import UsageIndicator from "./usage_indicator";
|
||||
import * as React from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles";
|
||||
import UsageIndicator from "@/components/UsageIndicator";
|
||||
import { serverRootPath } from "@/components/networking";
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
// -------- Types --------
|
||||
interface SidebarProps {
|
||||
accessToken: string | null;
|
||||
userRole: string;
|
||||
/** Fallback selection id (legacy), used if path can't be matched */
|
||||
defaultSelectedKey: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItemCfg {
|
||||
key: string;
|
||||
newTab?: boolean;
|
||||
page: string; // legacy id; we map this to a path below
|
||||
label: string;
|
||||
roles?: string[];
|
||||
children?: MenuItemCfg[];
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** ---------- Base URL helpers ---------- */
|
||||
/**
|
||||
* Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash).
|
||||
* Supported env values: "" or "ui/".
|
||||
* Also considers the serverRootPath from the proxy config (e.g., "/my-custom-path").
|
||||
*/
|
||||
const getBasePath = () => {
|
||||
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
|
||||
const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes
|
||||
const uiPath = trimmed ? `/${trimmed}/` : "/";
|
||||
|
||||
// If serverRootPath is set and not "/", prepend it to the UI path
|
||||
if (serverRootPath && serverRootPath !== "/") {
|
||||
// Remove trailing slash from serverRootPath and ensure uiPath has no leading slash for proper joining
|
||||
const cleanServerRoot = serverRootPath.replace(/\/+$/, "");
|
||||
const cleanUiPath = uiPath.replace(/^\/+/, "");
|
||||
return `${cleanServerRoot}/${cleanUiPath}`;
|
||||
}
|
||||
|
||||
return uiPath;
|
||||
};
|
||||
|
||||
/** Map legacy `page` ids to real app routes (relative, no leading slash). */
|
||||
const routeFor = (slug: string): string => {
|
||||
switch (slug) {
|
||||
// top level
|
||||
case "api-keys":
|
||||
return "virtual-keys";
|
||||
case "llm-playground":
|
||||
return "test-key";
|
||||
case "models":
|
||||
return "models-and-endpoints";
|
||||
case "new_usage":
|
||||
return "usage";
|
||||
case "teams":
|
||||
return "teams";
|
||||
case "organizations":
|
||||
return "organizations";
|
||||
case "users":
|
||||
return "users";
|
||||
case "api_ref":
|
||||
return "api-reference";
|
||||
case "model-hub-table":
|
||||
// If you intend the newer in-dashboard page, use "model-hub".
|
||||
return "model-hub";
|
||||
case "logs":
|
||||
return "logs";
|
||||
case "guardrails":
|
||||
return "guardrails";
|
||||
case "policies":
|
||||
return "policies";
|
||||
case "chat":
|
||||
return "chat";
|
||||
|
||||
// tools
|
||||
case "mcp-servers":
|
||||
return "tools/mcp-servers";
|
||||
case "vector-stores":
|
||||
return "tools/vector-stores";
|
||||
case "byok-demo":
|
||||
return "tools/byok-demo";
|
||||
|
||||
// experimental
|
||||
case "caching":
|
||||
return "experimental/caching";
|
||||
case "prompts":
|
||||
return "experimental/prompts";
|
||||
case "budgets":
|
||||
return "experimental/budgets";
|
||||
case "transform-request":
|
||||
return "experimental/api-playground";
|
||||
case "tag-management":
|
||||
return "experimental/tag-management";
|
||||
case "claude-code-plugins":
|
||||
return "experimental/claude-code-plugins";
|
||||
case "usage": // "Old Usage"
|
||||
return "experimental/old-usage";
|
||||
|
||||
// settings
|
||||
case "general-settings":
|
||||
return "settings/router-settings";
|
||||
case "settings": // "Logging & Alerts"
|
||||
return "settings/logging-and-alerts";
|
||||
case "admin-panel":
|
||||
return "settings/admin-settings";
|
||||
case "ui-theme":
|
||||
return "settings/ui-theme";
|
||||
|
||||
default:
|
||||
// treat as already a relative path
|
||||
return slug.replace(/^\/+/, "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Prefix base path ("/" or "/ui/") */
|
||||
const toHref = (slugOrPath: string) => {
|
||||
const base = getBasePath(); // "/" or "/ui/"
|
||||
const rel = routeFor(slugOrPath).replace(/^\/+|\/+$/g, "");
|
||||
return `${base}${rel}`;
|
||||
};
|
||||
|
||||
// ----- Menu config (unchanged labels/icons; same appearance) -----
|
||||
const menuItems: MenuItemCfg[] = [
|
||||
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "3",
|
||||
page: "llm-playground",
|
||||
label: "Test Key",
|
||||
icon: <PlayCircleOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
page: "models",
|
||||
label: "Models + Endpoints",
|
||||
icon: <BlockOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "12",
|
||||
page: "new_usage",
|
||||
label: "Usage",
|
||||
icon: <BarChartOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "17",
|
||||
page: "organizations",
|
||||
label: "Organizations",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
page: "users",
|
||||
label: "Internal Users",
|
||||
icon: <UserOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "14", page: "api-reference", label: "API Reference", icon: <ApiOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "16",
|
||||
page: "model-hub-table",
|
||||
label: "Model Hub",
|
||||
icon: <AppstoreOutlined style={{ fontSize: 18 }} />,
|
||||
},
|
||||
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "11",
|
||||
page: "guardrails",
|
||||
label: "Guardrails",
|
||||
icon: <SafetyOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "28",
|
||||
page: "policies",
|
||||
label: "Policies",
|
||||
icon: <AuditOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "26",
|
||||
page: "tools",
|
||||
label: "Tools",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "21",
|
||||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "experimental",
|
||||
page: "experimental",
|
||||
label: "Experimental",
|
||||
icon: <ExperimentOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{
|
||||
key: "9",
|
||||
page: "caching",
|
||||
label: "Caching",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
page: "prompts",
|
||||
label: "Prompts",
|
||||
icon: <FileTextOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
page: "transform-request",
|
||||
label: "API Playground",
|
||||
icon: <ApiOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "19",
|
||||
page: "tag-management",
|
||||
label: "Tag Management",
|
||||
icon: <TagsOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "27",
|
||||
page: "claude-code-plugins",
|
||||
label: "Claude Code Plugins",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
page: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{
|
||||
key: "11",
|
||||
page: "general-settings",
|
||||
label: "Router Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "8",
|
||||
page: "settings",
|
||||
label: "Logging & Alerts",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "13",
|
||||
page: "admin-panel",
|
||||
label: "Admin Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "14",
|
||||
page: "ui-theme",
|
||||
label: "UI Theme",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() || "/";
|
||||
|
||||
// ----- Filter by role without mutating originals -----
|
||||
const filteredMenuItems = React.useMemo<MenuItemCfg[]>(() => {
|
||||
return menuItems
|
||||
.filter((item) => !item.roles || item.roles.includes(userRole))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? item.children.filter((c) => !c.roles || c.roles.includes(userRole)) : undefined,
|
||||
}));
|
||||
}, [userRole]);
|
||||
|
||||
// ----- Compute selected key from current path -----
|
||||
const selectedMenuKey = React.useMemo(() => {
|
||||
const base = getBasePath();
|
||||
// strip base prefix and leading slash -> "virtual-keys", "tools/mcp-servers", etc.
|
||||
const rel = pathname.startsWith(base) ? pathname.slice(base.length) : pathname.replace(/^\/+/, "");
|
||||
const relLower = rel.toLowerCase();
|
||||
|
||||
const matchesPath = (slug: string) => {
|
||||
const route = routeFor(slug).toLowerCase();
|
||||
return relLower === route || relLower.startsWith(`${route}/`);
|
||||
};
|
||||
|
||||
// search top-level
|
||||
for (const item of filteredMenuItems) {
|
||||
if (!item.children && matchesPath(item.page)) return item.key;
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
if (matchesPath(child.page)) return child.key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to legacy defaultSelectedKey mapping
|
||||
const fallback = filteredMenuItems.find((i) => i.page === defaultSelectedKey)?.key;
|
||||
if (fallback) return fallback;
|
||||
|
||||
for (const item of filteredMenuItems) {
|
||||
if (item.children?.some((c) => c.page === defaultSelectedKey)) {
|
||||
const child = item.children.find((c) => c.page === defaultSelectedKey)!;
|
||||
return child.key;
|
||||
}
|
||||
}
|
||||
|
||||
return "1";
|
||||
}, [pathname, filteredMenuItems, defaultSelectedKey]);
|
||||
|
||||
// ----- Navigation -----
|
||||
const goTo = (slug: string, newTab?: boolean) => {
|
||||
const href = toHref(slug);
|
||||
if (newTab) {
|
||||
window.open(href, "_blank");
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => {
|
||||
const href = toHref(page);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
rel={newTab ? "noopener noreferrer" : undefined}
|
||||
onClick={(e) => {
|
||||
if (newTab) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
theme="light"
|
||||
width={220}
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={80}
|
||||
collapsible
|
||||
trigger={null}
|
||||
style={{
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Menu: {
|
||||
iconSize: 18,
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedMenuKey]}
|
||||
defaultOpenKeys={collapsed ? [] : ["llm-tools"]} // kept to preserve original appearance
|
||||
inlineCollapsed={collapsed}
|
||||
className="custom-sidebar-menu"
|
||||
style={{
|
||||
borderRight: 0,
|
||||
backgroundColor: "transparent",
|
||||
fontSize: "14px",
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
items={filteredMenuItems.map((item) => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label: renderNavLink(item.label, item.page, item.newTab),
|
||||
children: item.children?.map((child) => ({
|
||||
key: child.key,
|
||||
icon: child.icon,
|
||||
label: renderNavLink(child.label, child.page, child.newTab),
|
||||
onClick: () => goTo(child.page, child.newTab),
|
||||
})),
|
||||
onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined,
|
||||
}))}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar2;
|
||||
@ -7,6 +7,7 @@ import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
||||
import { MIGRATED_PAGES } from "@/utils/migratedPages";
|
||||
|
||||
/** ---- BASE URL HELPERS ---- */
|
||||
function normalizeBasePrefix(raw: string | undefined | null): string {
|
||||
@ -23,15 +24,6 @@ function withBase(path: string): string {
|
||||
}
|
||||
/** -------------------------------- */
|
||||
|
||||
/**
|
||||
* Pages that have been migrated to path-based routing under (dashboard)/.
|
||||
* When the leftnav triggers one of these, navigate to the path route instead
|
||||
* of the legacy query-param root page.
|
||||
*
|
||||
* Key = legacy page id used in leftnav, Value = route segment under (dashboard)/
|
||||
*/
|
||||
const MIGRATED_PAGES: Record<string, string> = {};
|
||||
|
||||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@ -54,6 +54,7 @@ import {
|
||||
storeReturnUrl,
|
||||
} from "@/utils/returnUrlUtils";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import { MIGRATED_PAGES } from "@/utils/migratedPages";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -65,13 +66,6 @@ interface ProxySettings {
|
||||
LITELLM_UI_API_DOC_BASE_URL?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of legacy query-param page keys → new path-based route segments.
|
||||
* When a user visits ?page=<key>, they are redirected to /ui/<value>.
|
||||
* Add entries here as pages are migrated from the if/else chain to path-based routes.
|
||||
*/
|
||||
const LEGACY_REDIRECTS: Record<string, string> = {};
|
||||
|
||||
function CreateKeyPageContent() {
|
||||
const { authLoading, token, userID, userRole, userEmail, accessToken, premiumUser, setUserRole, setUserEmail } =
|
||||
useAuth();
|
||||
@ -202,11 +196,11 @@ function CreateKeyPageContent() {
|
||||
}, [redirectToLogin]);
|
||||
|
||||
// Redirect legacy query-param pages to their new path-based routes
|
||||
const isLegacyRedirect = page in LEGACY_REDIRECTS;
|
||||
const isLegacyRedirect = page in MIGRATED_PAGES;
|
||||
useEffect(() => {
|
||||
if (!authLoading && isLegacyRedirect) {
|
||||
const base = (proxyBaseUrl || "") + "/ui";
|
||||
router.replace(`${base}/${LEGACY_REDIRECTS[page]}`);
|
||||
router.replace(`${base}/${MIGRATED_PAGES[page]}`);
|
||||
}
|
||||
}, [authLoading, isLegacyRedirect, page, router]);
|
||||
|
||||
|
||||
@ -43,31 +43,9 @@ import {
|
||||
import NewBadge from "./common_components/NewBadge";
|
||||
import type { Organization } from "./networking";
|
||||
import UsageIndicator from "./UsageIndicator";
|
||||
import { serverRootPath } from "./networking";
|
||||
import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages";
|
||||
const { Sider } = Layout;
|
||||
|
||||
/**
|
||||
* Pages migrated to path-based routing under (dashboard)/.
|
||||
* Key = legacy page id, Value = route segment.
|
||||
* Keep in sync with MIGRATED_PAGES in (dashboard)/layout.tsx.
|
||||
*/
|
||||
const MIGRATED_PAGES: Record<string, string> = {};
|
||||
|
||||
/** Build an absolute href for a migrated page, respecting base URL + serverRootPath. */
|
||||
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}`;
|
||||
}
|
||||
|
||||
// Define the props type
|
||||
interface SidebarProps {
|
||||
setPage: (page: string) => void;
|
||||
|
||||
43
ui/litellm-dashboard/src/utils/migratedPages.test.ts
Normal file
43
ui/litellm-dashboard/src/utils/migratedPages.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
describe("migratedHref", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns an absolute path rooted at / when serverRootPath is /", async () => {
|
||||
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
|
||||
const { migratedHref } = await import("./migratedPages");
|
||||
|
||||
expect(migratedHref("api-reference")).toBe("/api-reference");
|
||||
expect(migratedHref("virtual-keys")).toBe("/virtual-keys");
|
||||
});
|
||||
|
||||
it("prefixes a non-root serverRootPath without duplicating slashes", async () => {
|
||||
vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" }));
|
||||
const { migratedHref } = await import("./migratedPages");
|
||||
|
||||
expect(migratedHref("api-reference")).toBe("/team-x/api-reference");
|
||||
});
|
||||
|
||||
it("honors NEXT_PUBLIC_BASE_URL as a base path segment", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "ui");
|
||||
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
|
||||
const { migratedHref } = await import("./migratedPages");
|
||||
|
||||
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");
|
||||
|
||||
expect(migratedHref("api-reference")).toBe("/team-x/ui/api-reference");
|
||||
});
|
||||
});
|
||||
25
ui/litellm-dashboard/src/utils/migratedPages.ts
Normal file
25
ui/litellm-dashboard/src/utils/migratedPages.ts
Normal file
@ -0,0 +1,25 @@
|
||||
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. Empty until a page is migrated.
|
||||
*/
|
||||
export const MIGRATED_PAGES: Record<string, string> = {};
|
||||
|
||||
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}`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user