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:
ryan-crabbe-berri 2026-06-08 11:25:50 -07:00 committed by GitHub
parent f5b11b72a6
commit ff6cea4833
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 73 additions and 513 deletions

View File

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

View File

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

View File

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

View File

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

View 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");
});
});

View 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}`;
}