Merge pull request #39978 from BerriAI/litellm_remove_migrated_pages_shim

refactor(ui): route the sidebar by pathname and shrink the ?page= shim to a redirect table
This commit is contained in:
ryan-crabbe-berri 2026-09-05 17:17:34 -07:00 committed by GitHub
commit a9f8a8d794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 385 additions and 474 deletions

View file

@ -752,7 +752,7 @@ def interpolate_headers(headers: Mapping[str, str], variables: Mapping[str, str]
def build_env_var_setup_url(server_id: str) -> str:
"""The frontend URL where a user can fill in their per-user env vars."""
base: Final = os.environ.get("PROXY_BASE_URL", "").rstrip("/")
path: Final = f"/ui/?page=mcp-servers&fill_env_vars={quote(server_id, safe='')}"
path: Final = f"/ui/mcp-servers?fill_env_vars={quote(server_id, safe='')}"
return f"{base}{path}" if base else path

View file

@ -95,7 +95,7 @@ def test_interpolate_headers_returns_independent_copy():
def test_build_env_var_setup_url_includes_server_id(monkeypatch):
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
url = _u("build_env_var_setup_url")("abc-123")
assert url.startswith("/ui/?page=mcp-servers")
assert url.startswith("/ui/mcp-servers?")
assert "fill_env_vars=abc-123" in url
@ -123,7 +123,7 @@ def test_missing_user_env_vars_error_message_is_friendly():
server_id="abc-123",
server_name="CorporateDB",
missing=["CORP_USERNAME", "CORP_PASSWORD"],
setup_url="https://proxy.example.com/ui/?page=mcp-servers&fill_env_vars=abc-123",
setup_url="https://proxy.example.com/ui/mcp-servers?fill_env_vars=abc-123",
)
err = exc_info.value
text = str(err)
@ -1694,7 +1694,7 @@ async def test_missing_user_env_vars_error_renders_in_mcp_call_tool():
server_id="srv-99",
server_name="CorporateDB",
missing=["CORP_USERNAME"],
setup_url="/ui/?page=mcp-servers&fill_env_vars=srv-99",
setup_url="/ui/mcp-servers?fill_env_vars=srv-99",
)
# We don't want to spin up the full MCP server framework — just
# mimic the except-clause behavior the @server.call_tool handler uses.

View file

@ -6,6 +6,6 @@
"local/no-large-inline-object-arg": { "max": 554, "target": 300 },
"local/no-long-condition-chain": { "max": 265, "target": 120 },
"testing-library/no-container": { "max": 133, "target": 50 },
"testing-library/no-node-access": { "max": 716, "target": 500 },
"testing-library/no-node-access": { "max": 707, "target": 500 },
"testing-library/prefer-screen-queries": { "max": 18, "target": 18 }
}

View file

@ -6,18 +6,11 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useEffect, useState } from "react";
interface SidebarProviderProps {
setPage: (page: string) => void;
defaultSelectedKey: string;
sidebarCollapsed: boolean;
onToggleCollapsed?: () => void;
}
const SidebarProvider = ({
setPage,
defaultSelectedKey,
sidebarCollapsed,
onToggleCollapsed,
}: SidebarProviderProps) => {
const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProviderProps) => {
const { accessToken } = useAuthorized();
const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState<string[] | null>(null);
const [enableProjectsUI, setEnableProjectsUI] = useState<boolean>(false);
@ -70,8 +63,6 @@ const SidebarProvider = ({
return (
<Sidebar
setPage={setPage}
defaultSelectedKey={defaultSelectedKey}
collapsed={sidebarCollapsed}
onToggleCollapsed={onToggleCollapsed}
enabledPagesInternalUsers={enabledPagesInternalUsers}

View file

@ -7,12 +7,12 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { useAuth } from "@/contexts/AuthContext";
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { DebugWarningBanner } from "@/components/DebugWarningBanner";
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
import { getProxyBaseUrl } from "@/components/networking";
@ -97,21 +97,12 @@ export function AgentControlPlaneView() {
}
function DashboardShell({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
const { accessToken } = useAuth();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const { mode } = usePluginMode();
const page = legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys";
const isGateway = mode === "ai-gateway";
const navigateToPage = (newPage: string) => {
const migratedRoute = MIGRATED_PAGES[newPage];
router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage));
};
// Non-gateway (agent control plane) mode keeps the original full-width Navbar,
// which carries the account menu; the redesigned sidebar + header shell is
// scoped to the ai-gateway dashboard. Chat and the public model hub are
@ -136,14 +127,9 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
// so the page can't be dragged past the end of the nav.
return (
<div className="flex h-screen overflow-hidden bg-background">
<SidebarProvider
setPage={navigateToPage}
defaultSelectedKey={page}
sidebarCollapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
/>
<SidebarProvider sidebarCollapsed={sidebarCollapsed} onToggleCollapsed={() => setSidebarCollapsed((v) => !v)} />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<DashboardHeader page={page} />
<DashboardHeader />
<DebugWarningBanner accessToken={accessToken} />
<NoRedisWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
@ -161,10 +147,10 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
const isInvitationFlow = Boolean(searchParams.get("invitation_id"));
// Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own
// /onboarding route. Redirect once ui-config has loaded so migratedHref resolves the SERVER_ROOT_PATH base.
// /onboarding route. Redirect once ui-config has loaded so uiHref resolves the SERVER_ROOT_PATH base.
useEffect(() => {
if (!authLoading && isInvitationFlow) {
router.replace(`${migratedHref("onboarding")}?${searchParams.toString()}`);
router.replace(`${uiHref("onboarding")}?${searchParams.toString()}`);
}
}, [authLoading, isInvitationFlow, router, searchParams]);

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { menuGroups } from "@/components/leftnav";
import { legacyPageRedirectHref } from "./legacyPageRoutes";
const redirect = (query: string) => legacyPageRedirectHref(new URLSearchParams(query));
describe("legacyPageRedirectHref", () => {
it("sends an old ?page= bookmark to the path route that replaced it", () => {
expect(redirect("page=logs")).toBe("/ui/logs");
expect(redirect("page=models")).toBe("/ui/models-and-endpoints");
expect(redirect("page=llm-playground")).toBe("/ui/playground");
expect(redirect("page=new_usage")).toBe("/ui/usage");
expect(redirect("page=usage")).toBe("/ui/old-usage");
});
it("keeps the older aliases for renamed pages", () => {
expect(redirect("page=api_ref")).toBe("/ui/api-reference");
expect(redirect("page=api-reference")).toBe("/ui/api-reference");
expect(redirect("page=claude-code-plugins")).toBe("/ui/skills");
});
it("forwards the remaining query params so the MCP env-var setup link still opens its form", () => {
expect(redirect("page=mcp-servers&fill_env_vars=srv-1")).toBe("/ui/mcp-servers?fill_env_vars=srv-1");
expect(redirect("fill_env_vars=srv-1&page=mcp-servers")).toBe("/ui/mcp-servers?fill_env_vars=srv-1");
});
it("keeps forwarded values encoded", () => {
expect(redirect("page=mcp-servers&fill_env_vars=a%26b%3Dc")).toBe("/ui/mcp-servers?fill_env_vars=a%26b%3Dc");
});
it("returns null when there is no page param or the id is unknown", () => {
expect(redirect("")).toBeNull();
expect(redirect("login=success")).toBeNull();
expect(redirect("page=does-not-exist")).toBeNull();
expect(redirect("page=constructor")).toBeNull();
});
it("covers every sidebar page id with the route the sidebar itself links to", () => {
const leaves = menuGroups
.flatMap((group) => group.items.flatMap((item) => item.children ?? [item]))
.filter((item) => !item.external_url);
expect(leaves.length).toBeGreaterThan(30);
for (const leaf of leaves) {
expect(redirect(`page=${leaf.page}`), leaf.page).toBe(`/ui/${leaf.route ?? leaf.page}`);
}
});
});

View file

@ -0,0 +1,54 @@
import { uiHref } from "@/utils/uiHref";
const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
Object.entries({
"api-keys": "api-keys",
models: "models-and-endpoints",
api_ref: "api-reference",
"api-reference": "api-reference",
"llm-playground": "playground",
projects: "projects",
chat: "chat",
"access-groups": "access-groups",
budgets: "budgets",
workflows: "workflows",
"guardrails-monitor": "guardrails-monitor",
"mcp-servers": "mcp-servers",
"search-tools": "search-tools",
"tag-management": "tag-management",
"vector-stores": "vector-stores",
memory: "memory",
policies: "policies",
guardrails: "guardrails",
prompts: "prompts",
"tool-policies": "tool-policies",
skills: "skills",
"claude-code-plugins": "skills",
caching: "caching",
"cost-tracking": "cost-tracking",
"transform-request": "transform-request",
"ui-theme": "ui-theme",
logs: "logs",
"admin-panel": "admin-panel",
"logging-and-alerts": "logging-and-alerts",
"model-hub-table": "model-hub-table",
new_usage: "usage",
usage: "old-usage",
"cost-optimization": "cost-optimization",
agents: "agents",
"router-settings": "router-settings",
users: "users",
teams: "teams",
organizations: "organizations",
}),
);
export function legacyPageRedirectHref(searchParams: URLSearchParams): string | null {
const page = searchParams.get("page");
const route = page === null ? undefined : LEGACY_PAGE_ROUTES.get(page);
if (route === undefined) return null;
const rest = new URLSearchParams(searchParams);
rest.delete("page");
const query = rest.toString();
return query ? `${uiHref(route)}?${query}` : uiHref(route);
}

View file

@ -7,7 +7,7 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
import { ModelData } from "@/components/model_dashboard/types";
import { toast } from "@/lib/toast";
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
import { useQueryClient } from "@tanstack/react-query";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
@ -294,7 +294,7 @@ const AllModelsTab = ({
{selectedTeamValue === PERSONAL_TEAM_VALUE ? (
<span>
To access these models, create a Virtual Key without selecting a team on the{" "}
<a href={migratedHref("api-keys")} className="font-medium text-info hover:underline">
<a href={uiHref("api-keys")} className="font-medium text-info hover:underline">
Virtual Keys page
</a>
.
@ -302,7 +302,7 @@ const AllModelsTab = ({
) : (
<span>
To access these models, create a Virtual Key and select Team as &quot;{teamAccessLabel}&quot; on the{" "}
<a href={migratedHref("api-keys")} className="font-medium text-info hover:underline">
<a href={uiHref("api-keys")} className="font-medium text-info hover:underline">
Virtual Keys page
</a>
.

View file

@ -6,9 +6,9 @@ interface KeyRow {
token: string;
}
const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => {
const { mockReplace, mockUseKeys, mockUiHref, state } = vi.hoisted(() => {
const state = {
login: "success" as string | null,
search: "login=success",
userRole: "Internal User",
keys: [] as KeyRow[],
returnUrl: null as string | null,
@ -16,7 +16,7 @@ const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => {
return {
state,
mockReplace: vi.fn(),
mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUseKeys: vi.fn(() => ({
data: { keys: state.keys, total_count: state.keys.length },
isLoading: false,
@ -26,7 +26,7 @@ const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => {
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }),
useSearchParams: () => new URLSearchParams(state.search),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
@ -44,7 +44,7 @@ vi.mock("@/components/common_components/LoadingScreen", () => ({
default: () => <div data-testid="loading-screen" />,
}));
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref }));
vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref }));
vi.mock("@/utils/returnUrlUtils", () => ({
buildLoginUrlWithReturn: (u: string) => u,
consumeReturnUrl: () => state.returnUrl,
@ -71,13 +71,13 @@ describe("dashboard landing", () => {
afterEach(() => {
Object.defineProperty(window, "location", { configurable: true, value: realLocation });
state.login = "success";
state.search = "login=success";
state.userRole = "Internal User";
state.keys = [];
state.returnUrl = null;
mockReplace.mockClear();
mockUseKeys.mockClear();
mockMigratedHref.mockClear();
mockUiHref.mockClear();
mockLocationReplace.mockClear();
});
@ -89,7 +89,7 @@ describe("dashboard landing", () => {
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
expect(mockMigratedHref).not.toHaveBeenCalledWith("connect");
expect(mockUiHref).not.toHaveBeenCalledWith("connect");
},
);
@ -105,6 +105,20 @@ describe("dashboard landing", () => {
expect(mockUseKeys).not.toHaveBeenCalled();
});
it("redirects an old ?page= bookmark to its path route without rendering the keys dashboard", () => {
state.search = "page=logs";
render(<CreateKeyPage />);
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs");
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
});
it("carries the MCP env-var deep link's other params through the legacy redirect", () => {
state.search = "page=mcp-servers&fill_env_vars=srv-1";
render(<CreateKeyPage />);
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/mcp-servers?fill_env_vars=srv-1");
});
it("still sends the user to an explicit stored return URL", () => {
state.returnUrl = "/ui/models-and-endpoints";
render(<CreateKeyPage />);

View file

@ -12,7 +12,7 @@ import {
normalizeUrlForCompare,
storeReturnUrl,
} from "@/utils/returnUrlUtils";
import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages";
import { legacyPageRedirectHref } from "@/app/(dashboard)/legacyPageRoutes";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
@ -22,8 +22,6 @@ function CreateKeyPageContent() {
const router = useRouter();
const searchParams = useSearchParams()!;
const explicitPage = searchParams.get("page");
// Track if we've already attempted a return URL redirect to prevent race conditions
const hasAttemptedReturnRedirectRef = useRef(false);
@ -41,13 +39,12 @@ function CreateKeyPageContent() {
}
}, [redirectToLogin]);
// Redirect legacy ?page= deep links (old bookmarks) to their path-based routes.
const isLegacyRedirect = explicitPage !== null && explicitPage in MIGRATED_PAGES;
const legacyRedirectHref = legacyPageRedirectHref(searchParams);
useEffect(() => {
if (!authLoading && isLegacyRedirect) {
router.replace(migratedHref(MIGRATED_PAGES[explicitPage]));
if (!authLoading && legacyRedirectHref !== null) {
router.replace(legacyRedirectHref);
}
}, [authLoading, isLegacyRedirect, explicitPage, router]);
}, [authLoading, legacyRedirectHref, router]);
// Check for a stored return URL after successful authentication
// This handles the case where user comes back from SSO and we need to redirect to the original URL
@ -86,7 +83,7 @@ function CreateKeyPageContent() {
}
}, [token]);
const isRedirecting = redirectToLogin || isLegacyRedirect;
const isRedirecting = redirectToLogin || legacyRedirectHref !== null;
if (authLoading || isRedirecting) {
return <LoadingScreen />;

View file

@ -77,6 +77,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { uiHref } from "@/utils/uiHref";
import {
AUDIO_ACCEPT,
IMAGE_EDIT_ACCEPT,
@ -1650,7 +1651,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select vector store(s) to use for this LLM API call. You can set up your vector store{" "}
<a href="?page=vector-stores" className="text-info underline">
<a href={uiHref("vector-stores")} className="text-info underline">
here
</a>
.
@ -1674,7 +1675,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select guardrail(s) to use for this LLM API call. You can set up your guardrails{" "}
<a href="?page=guardrails" className="text-info underline">
<a href={uiHref("guardrails")} className="text-info underline">
here
</a>
.
@ -1700,7 +1701,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<TooltipContent className="max-w-xs">
Select policy/policies to apply to this LLM API call. Policies define which guardrails are
applied based on conditions. You can set up your policies{" "}
<a href="?page=policies" className="text-info underline">
<a href={uiHref("policies")} className="text-info underline">
here
</a>
.

View file

@ -48,7 +48,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(window.location.search),
}));
// entityLinks -> migratedPages imports serverRootPath from the same module, so the mock must export it too.
vi.mock("@/components/networking", () => {
return {
serverRootPath: "/",

View file

@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import ChatLayout from "./layout";
const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, state } = vi.hoisted(() => {
const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = vi.hoisted(() => {
const state = {
enableChatUI: false,
isUISettingsLoading: false,
@ -10,7 +10,7 @@ const { mockUseAuthorized, mockUseUISettings, mockReplace, mockMigratedHref, sta
return {
state,
mockReplace: vi.fn(),
mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUiHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUseAuthorized: vi.fn(() => ({
accessToken: "token-123",
userRole: "Internal User",
@ -30,7 +30,7 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("@/utils/migratedPages", () => ({ migratedHref: mockMigratedHref }));
vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref }));
vi.mock("@/components/navbar", () => ({ default: () => <div data-testid="navbar" /> }));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@ -47,7 +47,7 @@ describe("ChatLayout", () => {
state.enableChatUI = false;
state.isUISettingsLoading = false;
mockReplace.mockClear();
mockMigratedHref.mockClear();
mockUiHref.mockClear();
});
it("renders the chat shell when enable_chat_ui is on", () => {

View file

@ -8,7 +8,7 @@ import Navbar from "@/components/navbar";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { ChatShellProvider } from "@/contexts/ChatShellContext";
import ChatShell from "@/components/chat/ChatShell";
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
// ChatShellProvider uses useSearchParams(), which requires a Suspense boundary for static export.
function ChatLayoutContent({ children }: { children: React.ReactNode }) {
@ -20,7 +20,7 @@ function ChatLayoutContent({ children }: { children: React.ReactNode }) {
const blocked = !isUISettingsLoading && !chatEnabled;
useEffect(() => {
if (blocked) router.replace(migratedHref(""));
if (blocked) router.replace(uiHref(""));
}, [blocked, router]);
if (isUISettingsLoading || blocked) return null;

View file

@ -7,6 +7,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
const state = {
plugins: [] as { name: string; display_name: string; url: string }[],
enableChatUI: false,
pathname: "/ui/logs",
};
return {
state,
@ -17,8 +18,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => {
vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMode }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: () => "/ui/" }));
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` }));
vi.mock("next/navigation", () => ({ usePathname: () => state.pathname }));
vi.mock("@/hooks/useWorker", () => ({ useWorker: () => ({ isControlPlane: false, selectedWorker: null }) }));
vi.mock("@/app/(dashboard)/hooks/useDisableShowPrompts", () => ({ useDisableShowPrompts: () => false }));
vi.mock("@/components/Navbar/BlogDropdown/BlogDropdown", () => ({ BlogDropdown: () => null }));
@ -32,11 +32,26 @@ describe("DashboardHeader breadcrumb", () => {
afterEach(() => {
state.plugins = [];
state.enableChatUI = false;
state.pathname = "/ui/logs";
});
it("titles the breadcrumb from the current route, not from a sidebar page id", () => {
state.pathname = "/ui/models-and-endpoints";
render(<DashboardHeader />);
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
});
it("titles the dashboard root as Virtual Keys", () => {
state.pathname = "/ui/";
render(<DashboardHeader />);
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
});
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {
state.enableChatUI = true;
render(<DashboardHeader page="logs" />);
render(<DashboardHeader />);
expect(screen.getByText("Logs")).toBeInTheDocument();
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
@ -49,7 +64,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => {
render(<DashboardHeader page="logs" />);
render(<DashboardHeader />);
expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument();
expect(screen.getByText("Logs")).toBeInTheDocument();
@ -57,7 +72,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("styles Docs with the shared product-link class instead of a muted toolbar button", () => {
render(<DashboardHeader page="logs" />);
render(<DashboardHeader />);
const docs = screen.getByRole("link", { name: "Docs" });
for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) {
@ -67,7 +82,7 @@ describe("DashboardHeader breadcrumb", () => {
});
it("renders the tools divider centered rather than stretched to the top of the row", () => {
const { container } = render(<DashboardHeader page="logs" />);
const { container } = render(<DashboardHeader />);
const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]');
expect(separators).toHaveLength(1);

View file

@ -20,15 +20,12 @@ import { useWorker } from "@/hooks/useWorker";
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
interface DashboardHeaderProps {
page: string;
}
import { usePathname } from "next/navigation";
// Top bar for the dashboard shell. Sits only over the content column (the brand
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
export function DashboardHeader({ page }: DashboardHeaderProps) {
const { title } = getBreadcrumb(page);
export function DashboardHeader() {
const { title } = getBreadcrumb(usePathname());
const { isControlPlane, selectedWorker } = useWorker();
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
const hideCommunityLinks = useDisableShowPrompts();

View file

@ -28,7 +28,7 @@ vi.mock("@/contexts/PluginModeContext", () => ({ usePluginMode: mockUsePluginMod
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ useUISettings: mockUseUISettings }));
vi.mock("next/navigation", () => ({ usePathname: mockUsePathname }));
// Deterministic hrefs so navigation assertions don't depend on server_root_path.
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}` }));
vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}` }));
describe("ViewSwitcher", () => {
let assignSpy: ReturnType<typeof vi.fn>;

View file

@ -9,7 +9,7 @@ import {
import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
@ -28,7 +28,7 @@ export default function ViewSwitcher() {
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
const chatHref = migratedHref(CHAT);
const chatHref = uiHref(CHAT);
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
@ -44,7 +44,7 @@ export default function ViewSwitcher() {
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
// so switching modes from there needs a real navigation, not just state.
if (isChatRoute) {
window.location.assign(migratedHref(""));
window.location.assign(uiHref(""));
}
};
@ -57,7 +57,7 @@ export default function ViewSwitcher() {
{isChatRoute && <Check className="size-4 text-info" />}
</div>
),
onClick: () => window.location.assign(migratedHref(CHAT)),
onClick: () => window.location.assign(uiHref(CHAT)),
}
: {
key: CHAT,

View file

@ -18,7 +18,7 @@ vi.mock("next/navigation", () => ({
usePathname: mockUsePathname,
}));
// Deterministic hrefs so navigation/active-state assertions don't depend on server_root_path.
vi.mock("@/utils/migratedPages", () => ({ migratedHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}`.replace(/\/$/, "") || "/ui" }));
vi.mock("@/contexts/ChatShellContext", () => ({ useChatShell: mockUseChatShell }));
vi.mock("./ConversationList", () => ({ default: () => <div data-testid="conversation-list" /> }));

View file

@ -5,12 +5,12 @@ import { usePathname, useRouter } from "next/navigation";
import { Plus, MessageSquare, LayoutGrid, KeyRound, Lock, BarChart3, ScrollText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
import { useChatShell } from "@/contexts/ChatShellContext";
import ConversationList from "./ConversationList";
export function getChatRoutes() {
const base = migratedHref("chat");
const base = uiHref("chat");
return {
chats: base,
integrations: `${base}/integrations`,

View file

@ -17,6 +17,12 @@ vi.mock("../utils/roles", async (importOriginal) => {
};
});
const navState = vi.hoisted(() => ({ pathname: "/ui/api-keys" }));
vi.mock("next/navigation", () => ({
usePathname: () => navState.pathname,
}));
const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
const mockUseAuthorized = vi.fn(() => ({
userId: "test-user-id",
@ -98,8 +104,6 @@ const placementsOf = (page: string): string[] =>
describe("Sidebar (leftnav)", () => {
const defaultProps = {
setPage: vi.fn(),
defaultSelectedKey: "api-keys",
collapsed: false,
};
@ -107,6 +111,7 @@ describe("Sidebar (leftnav)", () => {
mockUseAuthorized.mockReset();
mockUseOrganizations.mockReset();
mockUseThemeImpl = unbrandedTheme;
navState.pathname = "/ui/api-keys";
});
it("should link the logo to the UI home route rather than the proxy origin", () => {
@ -509,12 +514,52 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Organizations")).toBeInTheDocument();
});
it("marks the selected page's nav item active", () => {
renderWithProviders(<Sidebar {...defaultProps} defaultSelectedKey="logs" />);
const logs = screen.getByText("Logs").closest("a");
expect(logs).toHaveAttribute("data-active", "true");
// A different item must not be active.
expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active");
it("marks the nav item for the current route active", () => {
navState.pathname = "/ui/logs";
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByRole("link", { name: "Logs" })).toHaveAttribute("data-active", "true");
expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active");
});
it("marks Virtual Keys active at the dashboard root", () => {
navState.pathname = "/ui/";
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true");
});
it("expands the parent group of the current nested route and marks the child active", () => {
navState.pathname = "/ui/search-tools";
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByRole("link", { name: "Search Tools" })).toHaveAttribute("data-active", "true");
expect(screen.getByRole("button", { name: "Tools" })).toHaveAttribute("aria-expanded", "true");
});
it("links every leaf to its path route, including the ids that differ from their route", () => {
renderWithProviders(<Sidebar {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByText("Experimental"));
});
const expectHref = (label: string, href: string) =>
expect(screen.getByRole("link", { name: label })).toHaveAttribute("href", href);
expectHref("Virtual Keys", "/ui/api-keys");
expectHref("Playground", "/ui/playground");
expectHref("Models + Endpoints", "/ui/models-and-endpoints");
expectHref("Usage", "/ui/usage");
expectHref("API Reference", "/ui/api-reference");
expectHref("Old Usage", "/ui/old-usage");
});
it("never links a leaf to the legacy ?page= switch", () => {
renderWithProviders(<Sidebar {...defaultProps} enableProjectsUI />);
for (const group of ["Agentic", "Tools", "Experimental", "Settings"]) {
act(() => {
fireEvent.click(screen.getByText(group));
});
}
const hrefs = screen.getAllByRole("link").map((link) => link.getAttribute("href") ?? "");
expect(hrefs.filter((href) => href.includes("page="))).toHaveLength(0);
expect(hrefs.filter((href) => href.startsWith("/ui/")).length).toBeGreaterThan(30);
});
it("hides labels but keeps items reachable (icon + link) when collapsed to the rail", () => {
@ -550,20 +595,30 @@ describe("Sidebar (leftnav)", () => {
});
describe("getBreadcrumb", () => {
it("resolves a top-level page to its section + title", () => {
expect(getBreadcrumb("api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" });
it("resolves a top-level route to its section + title", () => {
expect(getBreadcrumb("/ui/api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
expect(getBreadcrumb("/ui/logs")).toEqual({ section: "Observability", title: "Logs" });
});
it("resolves a nested child page to its parent section", () => {
expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" });
it("resolves routes whose segment differs from the sidebar page id", () => {
expect(getBreadcrumb("/ui/models-and-endpoints")).toEqual({ section: "AI Gateway", title: "Models + Endpoints" });
expect(getBreadcrumb("/ui/usage")).toEqual({ section: "Observability", title: "Usage" });
expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" });
});
it("titles the dashboard root as Virtual Keys", () => {
expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
});
it("resolves a nested child route to its parent section", () => {
expect(getBreadcrumb("/ui/search-tools/")).toEqual({ section: "AI Gateway", title: "Search Tools" });
});
it("resolves router-settings under the Settings section", () => {
expect(getBreadcrumb("router-settings")).toEqual({ section: "Settings", title: "Router Settings" });
expect(getBreadcrumb("/ui/router-settings")).toEqual({ section: "Settings", title: "Router Settings" });
});
it("falls back to a prettified title with no section for unknown pages", () => {
expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
it("falls back to a prettified title with no section for unknown routes", () => {
expect(getBreadcrumb("/ui/some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" });
});
});

View file

@ -62,6 +62,7 @@ import {
Workflow,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { cn } from "@/lib/cva.config";
import { rolesWithCapability } from "../utils/capabilities";
@ -76,15 +77,13 @@ import {
import BetaBadge from "./BetaBadge";
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
import SidebarUsageCard from "./SidebarUsageCard";
import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages";
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
const ICON = { strokeWidth: 1.75 } as const;
const LOGO_CLASS_NAME = "h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7";
interface SidebarProps {
setPage: (page: string) => void;
defaultSelectedKey: string;
collapsed?: boolean;
onToggleCollapsed?: () => void;
enabledPagesInternalUsers?: string[] | null;
@ -98,6 +97,7 @@ interface SidebarProps {
interface MenuItem {
key: string;
page: string;
route?: string;
label: string | React.ReactNode;
roles?: string[];
children?: MenuItem[];
@ -122,6 +122,7 @@ const menuGroups: MenuGroup[] = [
{
key: "llm-playground",
page: "llm-playground",
route: "playground",
label: "Playground",
icon: <PlayCircle {...ICON} />,
roles: rolesWithWriteAccess,
@ -129,6 +130,7 @@ const menuGroups: MenuGroup[] = [
{
key: "models",
page: "models",
route: "models-and-endpoints",
label: "Models + Endpoints",
icon: <Network {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
@ -197,6 +199,7 @@ const menuGroups: MenuGroup[] = [
{
key: "new_usage",
page: "new_usage",
route: "usage",
icon: <BarChart3 {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
label: "Usage",
@ -258,7 +261,7 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "DEVELOPER TOOLS",
items: [
{ key: "api_ref", page: "api_ref", label: "API Reference", icon: <Code2 {...ICON} /> },
{ key: "api_ref", page: "api_ref", route: "api-reference", label: "API Reference", icon: <Code2 {...ICON} /> },
{ key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: <LayoutGrid {...ICON} /> },
{
key: "learning-resources",
@ -304,6 +307,7 @@ const menuGroups: MenuGroup[] = [
{
key: "4",
page: "usage",
route: "old-usage",
label: "Old Usage",
icon: <BarChart3 {...ICON} />,
roles: rolesWithCapability("viewGlobalSpend"),
@ -358,24 +362,30 @@ const menuGroups: MenuGroup[] = [
},
];
const findParentKey = (page: string): string | null => {
const HOME_ROUTE = "api-keys";
const routeOf = (item: MenuItem): string => item.route ?? item.page;
const routeForPathname = (pathname: string): string => routeSegmentForPathname(pathname) || HOME_ROUTE;
const findParentKey = (route: string): string | null => {
for (const group of menuGroups) {
for (const item of group.items) {
if (item.children?.some((c) => c.page === page || c.key === page)) return item.key;
if (item.children?.some((c) => routeOf(c) === route)) return item.key;
}
}
return null;
};
const findMenuItemKey = (page: string): string => {
const findMenuItemKey = (route: string): string => {
for (const group of menuGroups) {
for (const item of group.items) {
if (item.page === page) return item.key;
const child = item.children?.find((c) => c.page === page);
if (routeOf(item) === route) return item.key;
const child = item.children?.find((c) => routeOf(c) === route);
if (child) return child.key;
}
}
return "api-keys";
return HOME_ROUTE;
};
const SECTION_DISPLAY: Record<string, string> = {
@ -395,22 +405,20 @@ const prettify = (key: string): string =>
const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
export const getBreadcrumb = (page: string): { section: string | null; title: string } => {
export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => {
const route = routeForPathname(pathname);
for (const group of menuGroups) {
for (const item of group.items) {
const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel;
if (item.page === page)
return { section, title: typeof item.label === "string" ? item.label : prettify(item.key) };
const child = item.children?.find((c) => c.page === page);
if (child) return { section, title: typeof child.label === "string" ? child.label : prettify(child.key) };
if (routeOf(item) === route) return { section, title: labelText(item) };
const child = item.children?.find((c) => routeOf(c) === route);
if (child) return { section, title: labelText(child) };
}
}
return { section: null, title: prettify(page) };
return { section: null, title: prettify(route) };
};
const Sidebar_: React.FC<SidebarProps> = ({
setPage,
defaultSelectedKey,
collapsed = false,
onToggleCollapsed,
enabledPagesInternalUsers,
@ -430,20 +438,21 @@ const Sidebar_: React.FC<SidebarProps> = ({
const baseUrl = getProxyBaseUrl();
const version = healthData?.litellm_version;
const selectedKey = findMenuItemKey(defaultSelectedKey);
const currentRoute = routeForPathname(usePathname());
const selectedKey = findMenuItemKey(currentRoute);
const [openGroups, setOpenGroups] = useState<Set<string>>(() => {
const parent = findParentKey(defaultSelectedKey);
const parent = findParentKey(currentRoute);
return new Set(parent ? [parent] : []);
});
// Keep the active page's parent group expanded as the user navigates, using the
// "adjust state during render" pattern rather than an effect (avoids a
// setState-in-effect render cascade).
const [prevSelectedKey, setPrevSelectedKey] = useState(defaultSelectedKey);
if (defaultSelectedKey !== prevSelectedKey) {
setPrevSelectedKey(defaultSelectedKey);
const parent = findParentKey(defaultSelectedKey);
const [prevRoute, setPrevRoute] = useState(currentRoute);
if (currentRoute !== prevRoute) {
setPrevRoute(currentRoute);
const parent = findParentKey(currentRoute);
if (parent && !openGroups.has(parent)) {
setOpenGroups((prev) => new Set(prev).add(parent));
}
@ -512,13 +521,6 @@ const Sidebar_: React.FC<SidebarProps> = ({
});
};
const handleLeafClick = (e: React.MouseEvent, item: MenuItem) => {
if (item.external_url) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
setPage(item.page);
};
const renderLeaf = (item: MenuItem, isChild: boolean) => {
const active = selectedKey === item.key;
const size = isChild ? "sub" : "default";
@ -542,19 +544,17 @@ const Sidebar_: React.FC<SidebarProps> = ({
);
}
const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page);
return (
<a
<Link
key={item.key}
href={href}
onClick={(e) => handleLeafClick(e, item)}
href={uiHref(routeOf(item))}
title={collapsed ? labelText(item) : undefined}
data-active={active || undefined}
className={cn(sidebarMenuButtonVariants({ isActive: active, size }))}
>
{item.icon}
{label}
</a>
</Link>
);
};
@ -603,7 +603,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
<SidebarHeader className="h-14 border-b border-border group-data-[collapsed=true]/sidebar:h-auto">
<div className="flex items-center justify-between gap-2 group-data-[collapsed=true]/sidebar:flex-col">
<div className="flex min-w-0 items-center gap-2">
<Link href={migratedHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
<Link href={uiHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
<img
src={darkLogoSrc}

View file

@ -3,7 +3,7 @@ import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBounci
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
import { useWorker } from "@/hooks/useWorker";
import { getProxyBaseUrl } from "@/components/networking";
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
import { useTheme } from "@/contexts/ThemeContext";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils";
@ -87,7 +87,7 @@ const Navbar: React.FC<NavbarProps> = ({
)}
<div className="flex items-center gap-2">
<Link href={migratedHref("")} className="flex items-center">
<Link href={uiHref("")} className="flex items-center">
<div className="relative">
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />

View file

@ -1,7 +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";
import { uiHref } from "@/utils/uiHref";
vi.mock("@/utils/cookieUtils", () => ({
clearTokenCookies: vi.fn(),
@ -392,7 +392,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
expect(Networking.serverRootPath).toBe("/litellm");
expect(migratedHref("api-reference")).toBe("/litellm/ui/api-reference");
expect(uiHref("api-reference")).toBe("/litellm/ui/api-reference");
});
});

View file

@ -12,8 +12,7 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(window.location.search),
}));
// Mock networking calls used by the component's mutation handlers. entityLinks -> migratedPages
// imports serverRootPath from the same module, so the mock must export it too.
// Mock networking calls used by the component's mutation handlers.
vi.mock("../networking", () => {
return {
__esModule: true,

View file

@ -1,4 +1,4 @@
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
"all-proxy-models",
@ -7,22 +7,22 @@ const MODEL_GRANT_SENTINELS: ReadonlySet<string> = new Set([
]);
export function teamDetailHref(teamId: string): string {
return `${migratedHref("teams")}?team=${encodeURIComponent(teamId)}`;
return `${uiHref("teams")}?team=${encodeURIComponent(teamId)}`;
}
export function keyDetailHref(keyToken: string): string {
return `${migratedHref("api-keys")}?key=${encodeURIComponent(keyToken)}`;
return `${uiHref("api-keys")}?key=${encodeURIComponent(keyToken)}`;
}
export function userDetailHref(userId: string): string {
return `${migratedHref("users")}?user=${encodeURIComponent(userId)}`;
return `${uiHref("users")}?user=${encodeURIComponent(userId)}`;
}
export function orgDetailHref(orgId: string): string {
return `${migratedHref("organizations")}?org=${encodeURIComponent(orgId)}`;
return `${uiHref("organizations")}?org=${encodeURIComponent(orgId)}`;
}
export function modelGroupHref(modelGroup: string): string | undefined {
if (MODEL_GRANT_SENTINELS.has(modelGroup)) return undefined;
return `${migratedHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`;
return `${uiHref("models-and-endpoints")}?model_group=${encodeURIComponent(modelGroup)}`;
}

View file

@ -1,239 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("migratedHref / legacyPageHref", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("NODE_ENV", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("builds a /ui-rooted path when serverRootPath is /", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { migratedHref, legacyPageHref } = await import("./migratedPages");
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, legacyPageHref } = await import("./migratedPages");
expect(migratedHref("api-reference")).toBe("/team-x/ui/api-reference");
expect(legacyPageHref("models")).toBe("/team-x/ui/?page=models");
});
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");
});
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(MIGRATED_PAGES.api_ref).toBe("api-reference");
expect(MIGRATED_PAGES["api-reference"]).toBe("api-reference");
});
it("maps the api-keys landing id to its route and builds its redirect href", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages");
expect(MIGRATED_PAGES["api-keys"]).toBe("api-keys");
expect(migratedHref(MIGRATED_PAGES["api-keys"])).toBe("/ui/api-keys");
});
it("maps the llm-playground sidebar id to the playground route", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES["llm-playground"]).toBe("playground");
});
it("maps the models sidebar id to the models-and-endpoints route and builds its redirect href", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages");
expect(MIGRATED_PAGES.models).toBe("models-and-endpoints");
expect(migratedHref(MIGRATED_PAGES.models)).toBe("/ui/models-and-endpoints");
});
it("maps the projects and access-groups sidebar ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.projects).toBe("projects");
expect(MIGRATED_PAGES["access-groups"]).toBe("access-groups");
});
it("maps the budgets, workflows, and guardrails-monitor sidebar ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.budgets).toBe("budgets");
expect(MIGRATED_PAGES.workflows).toBe("workflows");
expect(MIGRATED_PAGES["guardrails-monitor"]).toBe("guardrails-monitor");
});
it("maps the mcp-servers, search-tools, tag-management, vector-stores, and memory ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES["mcp-servers"]).toBe("mcp-servers");
expect(MIGRATED_PAGES["search-tools"]).toBe("search-tools");
expect(MIGRATED_PAGES["tag-management"]).toBe("tag-management");
expect(MIGRATED_PAGES["vector-stores"]).toBe("vector-stores");
expect(MIGRATED_PAGES.memory).toBe("memory");
});
it("maps the policies, guardrails, prompts, tool-policies, and skills ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.policies).toBe("policies");
expect(MIGRATED_PAGES.guardrails).toBe("guardrails");
expect(MIGRATED_PAGES.prompts).toBe("prompts");
expect(MIGRATED_PAGES["tool-policies"]).toBe("tool-policies");
expect(MIGRATED_PAGES.skills).toBe("skills");
// Old bookmarks used ?page=claude-code-plugins for the same panel.
expect(MIGRATED_PAGES["claude-code-plugins"]).toBe("skills");
});
it("maps the caching, cost-tracking, transform-request, ui-theme, and logs ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.caching).toBe("caching");
expect(MIGRATED_PAGES["cost-tracking"]).toBe("cost-tracking");
expect(MIGRATED_PAGES["transform-request"]).toBe("transform-request");
expect(MIGRATED_PAGES["ui-theme"]).toBe("ui-theme");
expect(MIGRATED_PAGES.logs).toBe("logs");
});
it("maps the admin-panel, logging-and-alerts, model-hub-table, and new_usage ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES["admin-panel"]).toBe("admin-panel");
expect(MIGRATED_PAGES["logging-and-alerts"]).toBe("logging-and-alerts");
expect(MIGRATED_PAGES["model-hub-table"]).toBe("model-hub-table");
// new_usage routes to /usage; the legacy ?page=usage report routes to /old-usage (asserted below).
expect(MIGRATED_PAGES.new_usage).toBe("usage");
});
it("maps the legacy usage report id to the old-usage route and builds its redirect href", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES, migratedHref } = await import("./migratedPages");
expect(MIGRATED_PAGES.usage).toBe("old-usage");
expect(migratedHref(MIGRATED_PAGES.usage)).toBe("/ui/old-usage");
});
it("maps the agents and router-settings ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.agents).toBe("agents");
expect(MIGRATED_PAGES["router-settings"]).toBe("router-settings");
});
it("maps the users id to its route", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.users).toBe("users");
});
it("maps the teams id to its route", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.teams).toBe("teams");
});
it("maps the organizations id to its route", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.organizations).toBe("organizations");
});
});
describe("dev server (NODE_ENV=development)", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("NODE_ENV", "development");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("builds root-relative hrefs because next dev serves the app at /, not /ui", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { migratedHref, legacyPageHref } = await import("./migratedPages");
expect(migratedHref("api-reference")).toBe("/api-reference");
expect(legacyPageHref("models")).toBe("/?page=models");
});
it("ignores serverRootPath, which only applies to proxy-mounted deployments", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/team-x/" }));
const { migratedHref } = await import("./migratedPages");
expect(migratedHref("api-reference")).toBe("/api-reference");
});
it("maps a bare migrated path back to its legacy sidebar key", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { legacyKeyForPathname } = await import("./migratedPages");
expect(legacyKeyForPathname("/api-reference/")).toBe("api_ref");
expect(legacyKeyForPathname("/")).toBeNull();
});
});
describe("legacyKeyForPathname", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("NODE_ENV", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
});
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");
// Same for skills: the claude-code-plugins alias maps to the same segment,
// and first-match-wins iteration must keep returning the sidebar key.
expect(legacyKeyForPathname("/ui/skills")).toBe("skills");
});
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

@ -1,83 +0,0 @@
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.
*/
export const MIGRATED_PAGES: Record<string, string> = {
"api-keys": "api-keys",
models: "models-and-endpoints",
api_ref: "api-reference",
// Legacy alias: older bookmarks used the hyphenated ?page=api-reference form.
"api-reference": "api-reference",
"llm-playground": "playground",
projects: "projects",
chat: "chat",
"access-groups": "access-groups",
budgets: "budgets",
workflows: "workflows",
"guardrails-monitor": "guardrails-monitor",
"mcp-servers": "mcp-servers",
"search-tools": "search-tools",
"tag-management": "tag-management",
"vector-stores": "vector-stores",
memory: "memory",
policies: "policies",
guardrails: "guardrails",
prompts: "prompts",
"tool-policies": "tool-policies",
skills: "skills",
// Legacy alias: the old switch matched ?page=claude-code-plugins for the same panel.
"claude-code-plugins": "skills",
caching: "caching",
"cost-tracking": "cost-tracking",
"transform-request": "transform-request",
"ui-theme": "ui-theme",
logs: "logs",
"admin-panel": "admin-panel",
"logging-and-alerts": "logging-and-alerts",
"model-hub-table": "model-hub-table",
// The modern usage dashboard; the legacy ?page=usage report routes to /old-usage.
new_usage: "usage",
usage: "old-usage",
"cost-optimization": "cost-optimization",
agents: "agents",
"router-settings": "router-settings",
users: "users",
teams: "teams",
organizations: "organizations",
};
function uiBase(): string {
// next dev serves the app at the root; only the proxy mounts the static export under /ui
// (and optionally under server_root_path). Inlined at build time, so production is unaffected.
if (process.env.NODE_ENV === "development") {
return "";
}
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;
}

View file

@ -1,4 +1,4 @@
import { migratedHref } from "@/utils/migratedPages";
import { uiHref } from "@/utils/uiHref";
export interface TabRoutes<Slug extends string> {
baseSegment: string;
@ -9,7 +9,7 @@ export interface TabRoutes<Slug extends string> {
export function createTabRoutes<Slug extends string>(baseSegment: string, slugs: readonly Slug[]): TabRoutes<Slug> {
const tabHref = (slug: string): string => {
const base = migratedHref(baseSegment);
const base = uiHref(baseSegment);
return slug ? `${base}/${slug}/` : `${base}/`;
};

View file

@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { setServerRootPath } from "@/lib/serverRootPath";
import { routeSegmentForPathname, uiHref } from "./uiHref";
afterEach(() => {
setServerRootPath("/");
vi.unstubAllEnvs();
});
describe("uiHref", () => {
it("builds a /ui-rooted path when serverRootPath is /", () => {
expect(uiHref("api-reference")).toBe("/ui/api-reference");
});
it("prefixes a non-root serverRootPath without duplicating slashes", () => {
setServerRootPath("/team-x/");
expect(uiHref("api-reference")).toBe("/team-x/ui/api-reference");
});
it("tolerates a leading slash in the route segment", () => {
expect(uiHref("/api-reference")).toBe("/ui/api-reference");
});
it("stays root-relative under next dev, which serves the app at /", () => {
vi.stubEnv("NODE_ENV", "development");
expect(uiHref("logs")).toBe("/logs");
});
});
describe("routeSegmentForPathname", () => {
it("strips the /ui base and any trailing slash", () => {
expect(routeSegmentForPathname("/ui/api-reference")).toBe("api-reference");
expect(routeSegmentForPathname("/ui/api-reference/")).toBe("api-reference");
});
it("returns an empty segment for the dashboard root", () => {
expect(routeSegmentForPathname("/ui/")).toBe("");
expect(routeSegmentForPathname("/ui")).toBe("");
});
it("keeps only the first segment of a nested path", () => {
expect(routeSegmentForPathname("/ui/models-and-endpoints/anything")).toBe("models-and-endpoints");
});
it("strips a non-root serverRootPath too", () => {
setServerRootPath("/team-x/");
expect(routeSegmentForPathname("/team-x/ui/guardrails")).toBe("guardrails");
});
it("reads the segment straight after / under next dev", () => {
vi.stubEnv("NODE_ENV", "development");
expect(routeSegmentForPathname("/logs")).toBe("logs");
expect(routeSegmentForPathname("/")).toBe("");
});
});

View file

@ -0,0 +1,23 @@
import { serverRootPath } from "@/lib/serverRootPath";
function uiBase(): string {
// next dev serves the app at the root; only the proxy mounts the static export under /ui
// (and optionally under server_root_path). Inlined at build time, so production is unaffected.
if (process.env.NODE_ENV === "development") {
return "";
}
const root = serverRootPath && serverRootPath !== "/" ? `/${serverRootPath.replace(/^\/+|\/+$/g, "")}` : "";
return `${root}/ui`;
}
/** Absolute (same-origin) href for a dashboard route segment, e.g. "api-reference" -> "/ui/api-reference". */
export function uiHref(routeSegment: string): string {
return `${uiBase()}/${routeSegment.replace(/^\/+/, "")}`;
}
/** First route segment under the UI base, e.g. "/ui/api-reference/" -> "api-reference" and "/ui/" -> "". */
export function routeSegmentForPathname(pathname: string): string {
const base = uiBase();
const relative = pathname.startsWith(base) ? pathname.slice(base.length) : pathname;
return relative.replace(/^\/+/, "").split("/")[0];
}