diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx new file mode 100644 index 00000000000..a3d75df9606 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.test.tsx @@ -0,0 +1,45 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { expect, it, vi } from "vitest"; +import Sidebar from "@/components/leftnav"; +import SidebarProvider from "./SidebarProvider"; + +const { getSettings } = vi.hoisted(() => ({ + getSettings: vi.fn().mockResolvedValue({ values: { enable_projects_ui: true } }), +})); + +vi.mock("@/components/networking", () => ({ getUiSettings: getSettings, getUISettings: getSettings })); +vi.mock("@/components/leftnav", () => ({ + default: ({ enableProjectsUI }: ComponentProps) => ( + + ), +})); + +it("reuses loaded navigation settings immediately when the mobile sidebar mounts again", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const shell = (mobileOpen: boolean) => ( + +
+ +
+ {mobileOpen && ( +
+ +
+ )} +
+ ); + const { rerender } = render(shell(false)); + expect(await screen.findByRole("link", { name: "Projects" })).toBeVisible(); + + for (const open of [true, false, true]) { + rerender(shell(open)); + if (open) { + expect( + within(screen.getByRole("region", { name: "Mobile navigation" })).getByRole("link", { name: "Projects" }), + ).toBeVisible(); + } + } + expect(getSettings).toHaveBeenCalledTimes(1); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index 6aaf08dae79..c7794233ded 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -1,9 +1,7 @@ "use client"; import Sidebar from "@/components/leftnav"; -import { getUISettings } from "@/components/networking"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useEffect, useState } from "react"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; interface SidebarProviderProps { sidebarCollapsed: boolean; @@ -11,66 +9,19 @@ interface SidebarProviderProps { } const SidebarProvider = ({ sidebarCollapsed, onToggleCollapsed }: SidebarProviderProps) => { - const { accessToken } = useAuthorized(); - const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); - const [enableProjectsUI, setEnableProjectsUI] = useState(false); - const [disableAgentsForInternalUsers, setDisableAgentsForInternalUsers] = useState(false); - const [allowAgentsForTeamAdmins, setAllowAgentsForTeamAdmins] = useState(false); - const [disableVectorStoresForInternalUsers, setDisableVectorStoresForInternalUsers] = useState(false); - const [allowVectorStoresForTeamAdmins, setAllowVectorStoresForTeamAdmins] = useState(false); - - useEffect(() => { - const fetchUISettings = async () => { - if (!accessToken) { - return; - } - - try { - const settings = await getUISettings(accessToken); - - // API returns 'values' not 'settings' - if (settings?.values?.enabled_ui_pages_internal_users !== undefined) { - setEnabledPagesInternalUsers(settings.values.enabled_ui_pages_internal_users); - } else { - } - - if (settings?.values?.enable_projects_ui !== undefined) { - setEnableProjectsUI(Boolean(settings.values.enable_projects_ui)); - } - - if (settings?.values?.disable_agents_for_internal_users !== undefined) { - setDisableAgentsForInternalUsers(Boolean(settings.values.disable_agents_for_internal_users)); - } - - if (settings?.values?.allow_agents_for_team_admins !== undefined) { - setAllowAgentsForTeamAdmins(Boolean(settings.values.allow_agents_for_team_admins)); - } - - if (settings?.values?.disable_vector_stores_for_internal_users !== undefined) { - setDisableVectorStoresForInternalUsers(Boolean(settings.values.disable_vector_stores_for_internal_users)); - } - - if (settings?.values?.allow_vector_stores_for_team_admins !== undefined) { - setAllowVectorStoresForTeamAdmins(Boolean(settings.values.allow_vector_stores_for_team_admins)); - } - } catch (error) { - console.error("[SidebarProvider] Failed to fetch UI settings:", error); - } - }; - - fetchUISettings(); - }, [accessToken]); + const { data: settings } = useUISettings(); + const values = settings?.values; return ( ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 7cdf7aa0489..876a4e24d84 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { usePathname } from "next/navigation"; import { AuthProvider } from "@/contexts/AuthContext"; import Layout from "./layout"; @@ -19,12 +19,17 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({ })); vi.mock("@/components/DashboardHeader", () => ({ - DashboardHeader: () =>
, + DashboardHeader: ({ navigationTrigger }: { navigationTrigger?: React.ReactNode }) => ( +
{navigationTrigger}
+ ), })); vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({ - default: ({ sidebarCollapsed }: { sidebarCollapsed: boolean }) => ( -
+ default: ({ sidebarCollapsed, onToggleCollapsed }: { sidebarCollapsed: boolean; onToggleCollapsed: () => void }) => ( +
+ + Settings +
), })); @@ -89,6 +94,47 @@ describe("(dashboard) Layout", () => { vi.mocked(usePathname).mockReturnValue("/ui/guardrails"); }); + it("starts mobile navigation closed, opens a modal drawer and closes it after choosing a page", async () => { + render( + + +

Gateway content

+
+
, + ); + pendingUiConfig.resolve(); + + const trigger = await screen.findByRole("button", { name: "Open navigation" }); + expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument(); + + fireEvent.click(trigger); + const navigation = await screen.findByRole("dialog", { name: "Navigation" }); + expect(within(navigation).getByRole("button", { name: "Close navigation" })).toBeInTheDocument(); + fireEvent.click(within(navigation).getByRole("link", { name: "Settings" })); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument()); + }); + + it("closes the mobile drawer when navigation changes outside the drawer", async () => { + const dashboard = () => ( + + +

Gateway content

+
+
+ ); + const { rerender } = render(dashboard()); + pendingUiConfig.resolve(); + fireEvent.click(await screen.findByRole("button", { name: "Open navigation" })); + expect(await screen.findByRole("dialog", { name: "Navigation" })).toBeInTheDocument(); + + vi.mocked(usePathname).mockReturnValue("/ui/api-keys"); + rerender(dashboard()); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument()); + vi.mocked(usePathname).mockReturnValue("/ui/guardrails"); + rerender(dashboard()); + expect(screen.queryByRole("dialog", { name: "Navigation" })).not.toBeInTheDocument(); + }); + it("collapses the sidebar on Logs for a full-screen view and expands it again after leaving", async () => { const dashboard = () => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index d705089cee8..4762dd2fed1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -19,6 +19,10 @@ import { routeSegmentForPathname, uiHref } from "@/utils/uiHref"; import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; import { createApiClient } from "@/lib/http/client"; import { getProxyBaseUrl } from "@/components/networking"; +import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Menu } from "lucide-react"; +import { useMediaQuery } from "usehooks-ts"; const pluginApiClient = createApiClient({ getBaseUrl: () => getProxyBaseUrl() ?? "" }); @@ -104,7 +108,16 @@ const FULL_BLEED_SEGMENTS = new Set(["logs"]); function DashboardShell({ children }: { children: React.ReactNode }) { const { accessToken } = useAuth(); const { mode } = usePluginMode(); - const routeSegment = routeSegmentForPathname(usePathname()); + const pathname = usePathname(); + const routeSegment = routeSegmentForPathname(pathname); + const searchParams = useSearchParams(); + const navigationKey = `${pathname}?${searchParams.toString()}`; + const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false }); + const [mobileNavigationKey, setMobileNavigationKey] = useState(null); + if (mobileNavigationKey !== null && (isDesktop || mobileNavigationKey !== navigationKey)) { + setMobileNavigationKey(null); + } + const mobileNavigationOpen = !isDesktop && mobileNavigationKey === navigationKey; const isFullBleed = FULL_BLEED_SEGMENTS.has(routeSegment); // A manual toggle holds only for the route it was made on; full-bleed routes default to collapsed. const [sidebarOverride, setSidebarOverride] = useState<{ segment: string; collapsed: boolean } | null>(null); @@ -138,21 +151,46 @@ function DashboardShell({ children }: { children: React.ReactNode }) { // sidebar owns its own scroll and the content column scrolls independently, // so the page can't be dragged past the end of the nav. return ( -
- - -
- - - - - - - -
{children}
+ setMobileNavigationKey(open ? navigationKey : null)}> +
+
+
- -
+ { + if (event.target instanceof Element && event.target.closest("a[href]")) setMobileNavigationKey(null); + }} + > + Navigation + setMobileNavigationKey(null)} /> + + +
+ + } + > + + + } + /> + + + + + + +
{children}
+
+
+
+ ); } diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx new file mode 100644 index 00000000000..756997b7ede --- /dev/null +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PluginModeProvider } from "@/contexts/PluginModeContext"; +import { DashboardHeader } from "./DashboardHeader"; + +vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" })); + +beforeEach(() => { + localStorage.clear(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/get/ui_settings")) return Response.json({ values: {} }); + if (url.endsWith("/litellm/.well-known/litellm-ui-config")) { + const config = { + server_root_path: "", + proxy_base_url: "", + is_control_plane: true, + workers: [ + { worker_id: "primary", name: "Primary worker", url: "http://primary.test" }, + { worker_id: "backup", name: "Backup worker", url: "http://backup.test" }, + ], + }; + return Response.json(config); + } + if (url.endsWith("/api/plugins")) { + return Response.json([{ name: "metrics", display_name: "Metrics", url: "/metrics" }]); + } + if (url.endsWith("/public/litellm_blog_posts")) { + return Response.json({ + posts: [ + { + title: "Gateway update", + description: "Release notes", + date: "2026-01-01", + url: "https://example.com/update", + }, + ], + }); + } + return Response.json({}, { status: 404 }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +async function openTools() { + const user = userEvent.setup(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + await user.click(screen.getByRole("button", { name: "More options" })); + const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); + return { user, tools }; +} + +describe("mobile header nested controls", () => { + it.each(["mouse", "touch"] as const)( + "selects a portalled gateway item with %s without dismissing More", + async (pointer) => { + const { user, tools } = await openTools(); + await user.click(within(tools).getByRole("button", { name: "AI Gateway" })); + const item = await screen.findByRole("menuitem", { name: "Metrics" }); + if (pointer === "touch") { + await user.pointer([ + { keys: "[TouchA>]", target: item }, + { keys: "[/TouchA]", target: item }, + ]); + } else { + await user.click(item); + } + expect(within(tools).getByRole("button", { name: "Metrics" })).toBeVisible(); + expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); + }, + ); + + it("lets a nested blog link receive the click before closing its menu", async () => { + const { user, tools } = await openTools(); + await user.click(within(tools).getByRole("button", { name: "Blog" })); + const link = await screen.findByRole("link", { name: /Gateway update/ }); + expect(link).toHaveAttribute("href", "https://example.com/update"); + await user.click(link); + expect(screen.queryByRole("menu", { name: "Blog" })).not.toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); + }); + + it("allows filtering and selecting a worker from the nested combobox", async () => { + localStorage.setItem("litellm_selected_worker_id", "primary"); + const { user, tools } = await openTools(); + const worker = await within(tools).findByRole("combobox", { name: "Worker" }); + await user.clear(worker); + await user.type(worker, "Backup"); + await user.click(await screen.findByRole("option", { name: "Backup worker" })); + expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull(); + expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 751a0192b8a..63f7b7aa136 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -8,6 +8,7 @@ const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { plugins: [] as { name: string; display_name: string; url: string }[], enableChatUI: false, pathname: "/ui/logs", + isDesktop: false, }; return { state, @@ -19,6 +20,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: () => state.pathname })); +vi.mock("usehooks-ts", () => ({ useMediaQuery: () => state.isDesktop })); 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 })); @@ -34,6 +36,7 @@ describe("DashboardHeader breadcrumb", () => { state.plugins = []; state.enableChatUI = false; state.pathname = "/ui/logs"; + state.isDesktop = false; }); it("titles the breadcrumb from the current route, not from a sidebar page id", () => { @@ -99,4 +102,27 @@ describe("DashboardHeader breadcrumb", () => { Node.DOCUMENT_POSITION_FOLLOWING, ); }); + + it("keeps the gateway selector and tools available from the compact header menu", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "More options" })); + const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); + expect(within(tools).getByRole("button", { name: "AI Gateway" })).toBeInTheDocument(); + expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument(); + expect(within(tools).getByRole("button", { name: "LiteAdmin" })).toBeInTheDocument(); + }); + + it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "More options" })); + expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument(); + + state.isDesktop = true; + rerender(); + expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); + + state.isDesktop = false; + rerender(); + expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index e4107669336..43440b0c92d 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -22,11 +22,19 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPro import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; import { usePathname } from "next/navigation"; +import { useState, type ReactNode } from "react"; +import { useMediaQuery } from "usehooks-ts"; +import { Ellipsis } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; // 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() { +export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: ReactNode }) { const { title } = getBreadcrumb(usePathname()); + const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false }); + const [mobileToolsOpen, setMobileToolsOpen] = useState(false); + if (isDesktop && mobileToolsOpen) setMobileToolsOpen(false); const { isControlPlane, selectedWorker } = useWorker(); const showWorkerSwitch = isControlPlane && selectedWorker !== null; const hideCommunityLinks = useDisableShowPrompts(); @@ -40,20 +48,21 @@ export function DashboardHeader() { }; return ( -
- +
+ {navigationTrigger} + - + - + {title} -
+
{showWorkerSwitch && ( <> @@ -68,6 +77,26 @@ export function DashboardHeader() {
+
+ + + }> + + + + Gateway tools + + {showWorkerSwitch && } +
+ + + + + {!hideCommunityLinks && } +
+
+
+
); } diff --git a/ui/litellm-dashboard/src/components/shared/Sidebar.tsx b/ui/litellm-dashboard/src/components/shared/Sidebar.tsx index 06e19d1b674..d57d54de789 100644 --- a/ui/litellm-dashboard/src/components/shared/Sidebar.tsx +++ b/ui/litellm-dashboard/src/components/shared/Sidebar.tsx @@ -139,7 +139,7 @@ SidebarMenuBadge.displayName = "SidebarMenuBadge"; const sidebarMenuButtonVariants = cva( [ - "group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline", + "group/menu-btn relative flex w-full items-center gap-2.5 overflow-hidden rounded-md px-2.5 text-left text-[13px] font-medium no-underline max-md:min-h-11", "text-sidebar-foreground/70 outline-none transition-colors", "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", "focus-visible:ring-2 focus-visible:ring-sidebar-ring",