mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
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:
commit
a9f8a8d794
31 changed files with 385 additions and 474 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts
Normal file
54
ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 "{teamAccessLabel}" 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>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
.
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" /> }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")} />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}/`;
|
||||
};
|
||||
|
||||
|
|
|
|||
55
ui/litellm-dashboard/src/utils/uiHref.test.ts
Normal file
55
ui/litellm-dashboard/src/utils/uiHref.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
23
ui/litellm-dashboard/src/utils/uiHref.ts
Normal file
23
ui/litellm-dashboard/src/utils/uiHref.ts
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue