From 5a654c5c61a7c77bd176757f10461c0dbd9d841b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 09:30:33 -0700 Subject: [PATCH 01/28] refactor(ui): full-height sidebar shell with content-scoped top bar Move the admin dashboard to a standard fixed-viewport shell. The sidebar is now full-height with its own scrolling nav (fixed logo header, pinned footer) and the top bar sits only over the content, so the page can no longer scroll past the end of the sidebar The brand, version, collapse toggle, and account menu move into the sidebar; the AI Gateway/Chat switch, docs/blog/community links, notifications, and worker switcher stay in the top bar. The sidebar is rebuilt on a new shadcn ui/sidebar primitive that uses the existing design-system tokens instead of the antd Menu This is a pure move-around of the sidebar, header, and content with no behavioral change intended. Chat keeps its own shell and navbar and is deliberately out of scope --- .../components/SidebarProvider.tsx | 9 +- .../src/app/(dashboard)/hooks/useLogout.ts | 19 + .../src/app/(dashboard)/layout.tsx | 44 +- .../src/components/DashboardHeader.tsx | 74 ++ .../Navbar/UserDropdown/UserDropdown.tsx | 74 +- .../src/components/SidebarUsageCard.tsx | 145 ++++ .../src/components/leftnav.test.tsx | 81 +- .../src/components/leftnav.tsx | 731 +++++++++--------- .../src/components/ui/sidebar.tsx | 203 +++++ 9 files changed, 926 insertions(+), 454 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts create mode 100644 ui/litellm-dashboard/src/components/DashboardHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/SidebarUsageCard.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/sidebar.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index b21759f136d..d14357b5026 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -9,9 +9,15 @@ interface SidebarProviderProps { setPage: (page: string) => void; defaultSelectedKey: string; sidebarCollapsed: boolean; + onToggleCollapsed?: () => void; } -const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { +const SidebarProvider = ({ + setPage, + defaultSelectedKey, + sidebarCollapsed, + onToggleCollapsed, +}: SidebarProviderProps) => { const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); @@ -72,6 +78,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} + onToggleCollapsed={onToggleCollapsed} enabledPagesInternalUsers={enabledPagesInternalUsers} enableProjectsUI={enableProjectsUI} enableChatUI={enableChatUI} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts new file mode 100644 index 00000000000..8da057ef9be --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts @@ -0,0 +1,19 @@ +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; +import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; + +/** + * Shared sign-out handler. Used by both the top navbar and the sidebar footer so + * the two entry points can never drift on which client state gets cleared. + */ +export function useLogout(accessToken: string | null): () => void { + const proxySettings = useProxySettings(accessToken); + + return () => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = proxySettings.PROXY_LOGOUT_URL || ""; + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index b8eb4e66ed0..d2e8ea4e540 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import React, { Suspense, useState, useRef, useEffect } from "react"; -import Navbar from "@/components/navbar"; +import { DashboardHeader } from "@/components/DashboardHeader"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -102,34 +102,36 @@ function DashboardShell({ children }: { children: React.ReactNode }) { 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)); }; + // Standard app shell: the viewport is fixed height and never scrolls. The + // 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 ( -
- setSidebarCollapsed((v) => !v)} - /> - - -
- {mode !== "ai-gateway" ? ( -
- -
+
+ {isGateway && ( + setSidebarCollapsed((v) => !v)} + /> + )} +
+ + + + {isGateway ? ( +
{children}
) : ( - <> -
- -
-
{children}
- +
+ +
)}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx new file mode 100644 index 00000000000..d765cdd45cb --- /dev/null +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getBreadcrumb } from "@/components/leftnav"; +import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; +import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; +import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; +import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; +import { useWorker } from "@/hooks/useWorker"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; + +interface DashboardHeaderProps { + page: string; +} + +// 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 { section, title } = getBreadcrumb(page); + const { isControlPlane, selectedWorker } = useWorker(); + const showWorkerSwitch = isControlPlane && selectedWorker !== null; + const hideCommunityLinks = useDisableShowPrompts(); + + const handleWorkerSwitch = (workerId: string) => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`; + }; + + return ( +
+ + +
+ {showWorkerSwitch && ( + <> + + + + )} + + Docs + + + {!hideCommunityLinks && } + + + + +
+
+ ); +} + +export default DashboardHeader; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index bd197f3ec8b..dba4c97dbe3 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -20,6 +20,8 @@ import { } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; +import { ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; const { Text } = Typography; @@ -59,9 +61,14 @@ function initialsFromIdentity(email: string | null, userId: string | null): stri interface UserDropdownProps { onLogout: () => void; + // "navbar" (default): compact top-right trigger. "sidebar": full-width footer + // trigger whose menu opens upward, for the redesigned sidebar dock. + variant?: "navbar" | "sidebar"; + // Sidebar rail mode: render the avatar only (no name/role). + collapsed?: boolean; } -const UserDropdown: React.FC = ({ onLogout }) => { +const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); @@ -219,6 +226,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { return ( (
@@ -230,24 +238,54 @@ const UserDropdown: React.FC = ({ onLogout }) => {
)} > - + + {initials} + + {!collapsed && ( + <> + + {displayName} + {userRole && {userRole}} + + + + )} + + ) : ( + + )}
); }; diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx new file mode 100644 index 00000000000..51bdf5de208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -0,0 +1,145 @@ +import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; +import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { cn } from "@/lib/cva.config"; +import { useQuery } from "@tanstack/react-query"; +import { Award, ChevronDown, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { getRemainingUsers } from "./networking"; + +interface SidebarUsageCardProps { + accessToken: string | null; + collapsed: boolean; + onExpandRail: () => void; +} + +interface Meter { + label: string; + used: number; + total: number; +} + +const formatExpiration = (daysRemaining: number | null): string => { + if (daysRemaining === null) return "No expiration"; + if (daysRemaining < 0) return "Expired"; + if (daysRemaining === 0) return "Expires today"; + if (daysRemaining === 1) return "1 day remaining"; + if (daysRemaining < 30) return `${daysRemaining} days remaining`; + if (daysRemaining < 60) return "1 month remaining"; + return `${Math.floor(daysRemaining / 30)} months remaining`; +}; + +const meterBarClass = (pct: number): string => { + if (pct > 100) return "bg-destructive"; + if (pct >= 90) return "bg-amber-500"; + return "bg-sidebar-primary"; +}; + +const Meter = ({ label, used, total }: Meter) => { + const pct = total > 0 ? (used / total) * 100 : 0; + return ( +
+
+ {label} + + {used.toLocaleString()} + / {total.toLocaleString()} + +
+
+
+
+
+ ); +}; + +type RemainingUsage = NonNullable>>; + +const remainingUsersQuery = (accessToken: string | null) => ({ + queryKey: ["sidebarRemainingUsers", accessToken] as const, + queryFn: () => getRemainingUsers(accessToken as string), + enabled: Boolean(accessToken), + retry: false as const, + staleTime: 5 * 60 * 1000, +}); + +const buildMeters = (data: RemainingUsage | null): Meter[] => { + if (!data) return []; + return [ + ...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []), + ...(data.total_teams != null ? [{ label: "Teams", used: data.total_teams_used, total: data.total_teams }] : []), + ]; +}; + +/** + * Bottom-dock "Enterprise usage" card for the sidebar. Backed only by data + * LiteLLM actually exposes: seat (user) and team allocations from the license, + * plus the license expiry. There is no plan-level spend or request cap, so the + * design's Spend / API-request meters are intentionally omitted. + */ +export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) { + const disableUsageIndicator = useDisableUsageIndicator(); + const [open, setOpen] = useState(true); + const licenseInfo = useLicenseInfo(accessToken).data ?? null; + const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken)); + const data = usageData ?? null; + + const hasData = data !== null && (data.total_users !== null || data.total_teams !== null); + const noUsableData = !isLoading && !hasData; + if (disableUsageIndicator || !accessToken || noUsableData) { + return null; + } + + if (collapsed) { + return ( + + ); + } + + const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null; + const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan"; + const meters = buildMeters(data); + + return ( +
+ + + {open && ( +
+ {isLoading && meters.length === 0 ? ( +
+ Loading… +
+ ) : ( + meters.map((m) => ) + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index dac4a35ebb6..96a895f15d7 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; -import Sidebar from "./leftnav"; +import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; vi.mock("../utils/roles", () => { return { @@ -56,6 +56,23 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => { }; }); +// The redesigned sidebar reads the custom logo from ThemeContext; the test tree +// has no ThemeProvider, so stub the hook. +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, faviconUrl: null, setLogoUrl: vi.fn(), setFaviconUrl: vi.fn() }), +})); + +// Version tag + logout target come from network hooks; keep them inert in unit tests. +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: () => ({ data: undefined }), +})); +vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ + useLogout: () => vi.fn(), +})); + +const collectNavKeys = (): string[] => + menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)])); + describe("Sidebar (leftnav)", () => { const defaultProps = { setPage: vi.fn(), @@ -117,33 +134,11 @@ describe("Sidebar (leftnav)", () => { }); }); it("has no duplicate keys among all menu items and their children", () => { - // Helper to recursively extract all keys from Ant Design Menu items - function getAllKeysFromMenu(wrapper: HTMLElement): string[] { - const allKeys: string[] = []; - // Ant Design renders key as data-menu-id or inside attributes, but for this case, we look for text as fallback. - // For a generic check, here we fetch ids from rendered list items, and also descend into submenus - const items = wrapper.querySelectorAll("[data-menu-id]"); - items.forEach((item) => { - const dataMenuId = item.getAttribute("data-menu-id"); - if (dataMenuId) { - allKeys.push(dataMenuId); - } - }); - return allKeys; - } - - const { container } = renderWithProviders(); - const allRenderedKeys = getAllKeysFromMenu(container); - - const keySet = new Set(); - const duplicates: string[] = []; - for (const key of allRenderedKeys) { - if (keySet.has(key)) { - duplicates.push(key); - } - keySet.add(key); - } - expect(duplicates).toHaveLength(0); + // React keys must be unique across the whole nav config, otherwise the + // active-item highlight and group expansion collide. + const keys = collectNavKeys(); + const duplicates = keys.filter((key, i) => keys.indexOf(key) !== i); + expect(duplicates).toEqual([]); }); describe("Admin Viewer parity", () => { @@ -231,4 +226,34 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); + + it("marks the selected page's nav item active", () => { + renderWithProviders(); + 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("hides labels but keeps items when collapsed to the rail", () => { + const { container } = renderWithProviders(); + expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true"); + // Items still render (icons), so navigation is reachable in rail mode. + expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + }); +}); + +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 nested child page to its parent section", () => { + expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" }); + }); + + 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" }); + }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 74b45942006..90bdca06595 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,38 +1,68 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { useLogout } from "@/app/(dashboard)/hooks/useLogout"; +import { getProxyBaseUrl } from "@/components/networking"; +import { useTheme } from "@/contexts/ThemeContext"; +import { Button } from "@/components/ui/button"; import { - ApiOutlined, - ApartmentOutlined, - AppstoreOutlined, - AuditOutlined, - BankOutlined, - BarChartOutlined, - BgColorsOutlined, - BlockOutlined, - BookOutlined, - CommentOutlined, - CreditCardOutlined, - DatabaseOutlined, - ExperimentOutlined, - ExportOutlined, - FileTextOutlined, - FolderOutlined, - KeyOutlined, - LineChartOutlined, - PlayCircleOutlined, - RobotOutlined, - SafetyOutlined, - SearchOutlined, - SettingOutlined, - TagsOutlined, - TeamOutlined, - ToolOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { ConfigProvider, Layout, Menu } from "antd"; -import { useMemo } from "react"; + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarSeparator, + sidebarMenuButtonVariants, +} from "@/components/ui/sidebar"; +import { + Activity, + BarChart3, + Bell, + Blocks, + Bot, + BookOpen, + Building2, + Boxes, + ChevronRight, + Code2, + Database, + ExternalLink, + FileText, + FlaskConical, + Folder, + HeartPulse, + KeyRound, + LayoutGrid, + MessageSquare, + Network, + Palette, + PanelLeftClose, + PanelLeftOpen, + PlayCircle, + Route, + ScrollText, + Search, + Server, + Settings as SettingsIcon, + Shield, + ShieldCheck, + Tags, + Terminal, + User, + Users, + Wallet, + Wrench, + Workflow, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { cn } from "@/lib/cva.config"; import { all_admin_roles, internalUserRoles, @@ -43,15 +73,17 @@ import { } from "../utils/roles"; import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; -import UsageIndicator from "./UsageIndicator"; +import SidebarUsageCard from "./SidebarUsageCard"; +import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; -const { Sider } = Layout; -// Define the props type +const ICON = { strokeWidth: 1.75 } as const; + interface SidebarProps { setPage: (page: string) => void; defaultSelectedKey: string; collapsed?: boolean; + onToggleCollapsed?: () => void; enabledPagesInternalUsers?: string[] | null; enableProjectsUI?: boolean; enableChatUI?: boolean; @@ -61,7 +93,6 @@ interface SidebarProps { allowVectorStoresForTeamAdmins?: boolean; } -// Menu item configuration interface MenuItem { key: string; page: string; @@ -72,29 +103,25 @@ interface MenuItem { external_url?: string; } -// Group configuration interface MenuGroup { groupLabel: string; items: MenuItem[]; roles?: string[]; } -// Menu groups organized by category - defined outside component for export +// Menu groups organized by category - defined outside component for export. +// Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the +// icons changed to lucide as part of the sidebar redesign. const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ - { - key: "api-keys", - page: "api-keys", - label: "Virtual Keys", - icon: , - }, + { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, { key: "llm-playground", page: "llm-playground", label: "Playground", - icon: , + icon: , roles: rolesWithWriteAccess, }, { @@ -105,96 +132,51 @@ const menuGroups: MenuGroup[] = [ Chat ), - icon: , + icon: , }, { key: "models", page: "models", label: "Models + Endpoints", - icon: , - // Admin Viewer can view models read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, { key: "agentic", page: "agentic", label: "Agentic", - icon: , + icon: , children: [ { key: "agents", page: "agents", label: "Agents", - icon: , - // Admin Viewer can view agents read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, - { - key: "workflows", - page: "workflows", - label: "Workflow Runs", - icon: , - }, - { - key: "memory", - page: "memory", - label: "Memory", - icon: , - }, + { key: "workflows", page: "workflows", label: "Workflow Runs", icon: }, + { key: "memory", page: "memory", label: "Memory", icon: }, ], }, - { - key: "mcp-servers", - page: "mcp-servers", - label: "MCP Servers", - icon: , - }, - { - key: "skills", - page: "skills", - label: "Skills", - icon: , - roles: all_admin_roles, - }, - { - key: "guardrails", - page: "guardrails", - label: "Guardrails", - icon: , - }, + { key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: }, + { key: "skills", page: "skills", label: "Skills", icon: , roles: all_admin_roles }, + { key: "guardrails", page: "guardrails", label: "Guardrails", icon: }, { key: "policies", page: "policies", - label: Policies, - icon: , + label: "Policies", + icon: , roles: all_admin_roles, }, { key: "tools", page: "tools", label: "Tools", - icon: , + icon: , children: [ - { - key: "search-tools", - page: "search-tools", - label: "Search Tools", - icon: , - }, - { - key: "vector-stores", - page: "vector-stores", - label: "Vector Stores", - icon: , - }, - { - key: "tool-policies", - page: "tool-policies", - label: "Tool Policies", - icon: , - }, + { key: "search-tools", page: "search-tools", label: "Search Tools", icon: }, + { key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: }, + { key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: }, ], }, ], @@ -205,21 +187,16 @@ const menuGroups: MenuGroup[] = [ { key: "new_usage", page: "new_usage", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], label: "Usage", }, - { - key: "logs", - page: "logs", - label: "Logs", - icon: , - }, + { key: "logs", page: "logs", label: "Logs", icon: }, { key: "guardrails-monitor", page: "guardrails-monitor", label: "Guardrails Monitor", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, ], @@ -227,12 +204,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "ACCESS CONTROL", items: [ - { - key: "teams", - page: "teams", - label: "Teams", - icon: , - }, + { key: "teams", page: "teams", label: "Teams", icon: }, { key: "projects", page: "projects", @@ -241,102 +213,62 @@ const menuGroups: MenuGroup[] = [ Projects ), - icon: , - roles: all_admin_roles, - }, - { - key: "users", - page: "users", - label: "Internal Users", - icon: , + icon: , roles: all_admin_roles, }, + { key: "users", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, { key: "organizations", page: "organizations", label: "Organizations", - icon: , + icon: , roles: all_admin_roles, }, { key: "access-groups", page: "access-groups", label: "Access Groups", - icon: , - roles: all_admin_roles, - }, - { - key: "budgets", - page: "budgets", - label: "Budgets", - icon: , + icon: , roles: all_admin_roles, }, + { key: "budgets", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, ], }, { groupLabel: "DEVELOPER TOOLS", items: [ - { - key: "api_ref", - page: "api_ref", - label: "API Reference", - icon: , - }, - { - key: "model-hub-table", - page: "model-hub-table", - label: "AI Hub", - icon: , - }, - + { key: "api_ref", page: "api_ref", label: "API Reference", icon: }, + { key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: }, { key: "learning-resources", page: "learning-resources", label: "Learning Resources", - icon: , + icon: , external_url: "https://models.litellm.ai/cookbook", }, { key: "experimental", page: "experimental", label: "Experimental", - icon: , + icon: , children: [ - { - key: "caching", - page: "caching", - label: "Caching", - icon: , - roles: all_admin_roles, - }, - { - key: "prompts", - page: "prompts", - label: "Prompts", - icon: , - roles: all_admin_roles, - }, + { key: "caching", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, + { key: "prompts", page: "prompts", label: "Prompts", icon: , roles: all_admin_roles }, { key: "transform-request", page: "transform-request", label: "API Playground", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, { key: "tag-management", page: "tag-management", label: "Tag Management", - icon: , + icon: , roles: all_admin_roles, }, - { - key: "4", - page: "usage", - label: "Old Usage", - icon: , - }, + { key: "4", page: "usage", label: "Old Usage", icon: }, ], }, ], @@ -353,21 +285,21 @@ const menuGroups: MenuGroup[] = [ Settings ), - icon: , + icon: , roles: all_admin_roles, children: [ { key: "router-settings", page: "router-settings", label: "Router Settings", - icon: , + icon: , roles: all_admin_roles, }, { key: "logging-and-alerts", page: "logging-and-alerts", label: "Logging & Alerts", - icon: , + icon: , roles: all_admin_roles, }, { @@ -381,33 +313,78 @@ const menuGroups: MenuGroup[] = [ ), - icon: , + icon: , roles: all_admin_roles, }, { key: "cost-tracking", page: "cost-tracking", label: "Cost Tracking", - icon: , - roles: all_admin_roles, - }, - { - key: "ui-theme", - page: "ui-theme", - label: "UI Theme", - icon: , + icon: , roles: all_admin_roles, }, + { key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: , roles: all_admin_roles }, ], }, ], }, ]; -const Sidebar: React.FC = ({ +const findParentKey = (page: 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; + } + } + return null; +}; + +const findMenuItemKey = (page: 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 (child) return child.key; + } + } + return "api-keys"; +}; + +const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : item.key); + +const SECTION_DISPLAY: Record = { + "AI GATEWAY": "AI Gateway", + OBSERVABILITY: "Observability", + "ACCESS CONTROL": "Access Control", + "DEVELOPER TOOLS": "Developer Tools", + SETTINGS: "Settings", +}; + +const prettify = (key: string): string => + key + .split(/[-_]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + +// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. +export const getBreadcrumb = (page: string): { section: string | null; title: string } => { + 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) }; + } + } + return { section: null, title: prettify(page) }; +}; + +const Sidebar_: React.FC = ({ setPage, defaultSelectedKey, collapsed = false, + onToggleCollapsed, enabledPagesInternalUsers, enableProjectsUI, enableChatUI, @@ -419,8 +396,31 @@ const Sidebar: React.FC = ({ const { userId, accessToken, userRole } = useAuthorized(); const { data: organizations } = useOrganizations(); const { data: teams } = useTeams(); + const { logoUrl } = useTheme(); + const { data: healthData } = useHealthReadinessDetails(accessToken); + const logout = useLogout(accessToken); + + const baseUrl = getProxyBaseUrl(); + const version = healthData?.litellm_version; + const selectedKey = findMenuItemKey(defaultSelectedKey); + + const [openGroups, setOpenGroups] = useState>(() => { + const parent = findParentKey(defaultSelectedKey); + 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); + if (parent && !openGroups.has(parent)) { + setOpenGroups((prev) => new Set(prev).add(parent)); + } + } - // Check if user is an org_admin const isOrgAdmin = useMemo(() => { if (!userId || !organizations) return false; return organizations.some((org: Organization) => @@ -428,83 +428,21 @@ const Sidebar: React.FC = ({ ); }, [userId, organizations]); - // Check if user is a team admin for any team const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); - // The parent (legacy root page or dashboard layout) owns navigation for both - // migrated and legacy pages; the sidebar only reports the selected page. - const navigateToPage = (page: string) => setPage(page); - - // Wrap label in so every nav item supports right-click → "Open in new tab" - // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: React.ReactNode, page: string, externalUrl?: string): React.ReactNode => { - if (externalUrl) { - return ( - e.stopPropagation()} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - } - const migratedRoute = MIGRATED_PAGES[page]; - const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page); - return ( - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { - e.stopPropagation(); - return; - } - e.preventDefault(); - }} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - }; - - // Filter items based on user role and enabled pages for internal users const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { const isAdmin = isAdminRole(userRole); - - // Debug logging - if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - } - return items - .map((item) => ({ - ...item, - children: item.children ? filterItemsByRole(item.children) : undefined, - })) + .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { - // Special handling for organizations and users menu items - allow org_admins if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; - } + if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - - // Hide Projects page if enableProjectsUI is not enabled if (item.key === "projects" && !enableProjectsUI) return false; - - // Hide Chat page if enableChatUI is not enabled if (item.key === "chat" && !enableChatUI) return false; - - // Hide agents and vector-stores pages for non-admin users when disabled, - // unless allow_*_for_team_admins is on and the user is a team admin. if ( !isAdmin && item.key === "agents" && @@ -519,160 +457,181 @@ const Sidebar: React.FC = ({ !(allowVectorStoresForTeamAdmins && isTeamAdmin) ) return false; - - // Existing role check if (item.roles && !item.roles.includes(userRole)) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - // If item has children, check if any children are visible + if (!isAdmin && enabledPagesInternalUsers != null) { if (item.children && item.children.length > 0) { const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page)); - if (hasVisibleChildren) { - return true; - } + if (hasVisibleChildren) return true; } - - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; + return enabledPagesInternalUsers.includes(item.page); } - return true; }); }; - // Build menu items with groups - const buildMenuItems = (): MenuProps["items"] => { - const items: MenuProps["items"] = []; + const visibleGroups = menuGroups + .filter((group) => !group.roles || group.roles.includes(userRole)) + .map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) })) + .filter((group) => group.items.length > 0); - menuGroups.forEach((group) => { - // Check if group has role restriction - if (group.roles && !group.roles.includes(userRole)) { - return; - } - - const filteredItems = filterItemsByRole(group.items); - if (filteredItems.length === 0) return; - - // Add group with items - items.push({ - type: "group", - label: collapsed ? null : ( - - {group.groupLabel} - - ), - children: filteredItems.map((item) => ({ - key: item.key, - icon: item.icon, - label: renderNavLink(item.label, item.page, item.external_url), - children: item.children?.map((child) => ({ - key: child.key, - icon: child.icon, - label: renderNavLink(child.label, child.page, child.external_url), - onClick: () => { - if (child.external_url) { - window.open(child.external_url, "_blank"); - } else { - navigateToPage(child.page); - } - }, - })), - onClick: !item.children - ? () => { - if (item.external_url) { - window.open(item.external_url, "_blank"); - } else { - navigateToPage(item.page); - } - } - : undefined, - })), - }); - }); - - return items; - }; - - // Find selected menu key - const findMenuItemKey = (page: string): string => { - for (const group of menuGroups) { - for (const item of group.items) { - if (item.page === page) return item.key; - if (item.children) { - const child = item.children.find((c) => c.page === page); - if (child) return child.key; - } - } + const toggleGroup = (key: string) => { + if (collapsed) { + onToggleCollapsed?.(); + setOpenGroups((prev) => new Set(prev).add(key)); + return; } - return "api-keys"; + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); }; - const selectedMenuKey = findMenuItemKey(defaultSelectedKey); + 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"; + const label = {item.label}; + + if (item.external_url) { + return ( + + {item.icon} + {label} + + + ); + } + + const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page); + return ( + handleLeafClick(e, item)} + title={collapsed ? labelText(item) : undefined} + data-active={active || undefined} + className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} + > + {item.icon} + {label} + + ); + }; + + const renderItem = (item: MenuItem) => { + const isGroup = !!item.children && item.children.length > 0; + if (!isGroup) { + return {renderLeaf(item, false)}; + } + + const active = selectedKey === item.key; + const open = openGroups.has(item.key); + return ( + + toggleGroup(item.key)} + title={collapsed ? labelText(item) : undefined} + > + {item.icon} + {item.label} + + + {open && ( + + {item.children!.map((child) => ( + {renderLeaf(child, true)} + ))} + + )} + + ); + }; + + const logoSrc = logoUrl || `${baseUrl}/get_image`; return ( - - - - + +
+
+ + LiteLLM + + {version && ( + + v{version} + + )} +
+ {onToggleCollapsed && ( + + )} +
+
+ + + {visibleGroups.map((group, gi) => ( + + {gi > 0 && } + {group.groupLabel} + {group.items.map((item) => renderItem(item))} + + ))} + + + + {isAdminRole(userRole) && ( + onToggleCollapsed?.()} /> - - {isAdminRole(userRole) && !collapsed && } - - + )} + + + ); }; -export default Sidebar; +export default Sidebar_; -// Also export menuGroups for advanced use cases export { menuGroups }; diff --git a/ui/litellm-dashboard/src/components/ui/sidebar.tsx b/ui/litellm-dashboard/src/components/ui/sidebar.tsx new file mode 100644 index 00000000000..5ac82788d87 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/sidebar.tsx @@ -0,0 +1,203 @@ +"use client"; + +import * as React from "react"; +import { type VariantProps } from "cva"; + +import { cn, cva } from "@/lib/cva.config"; + +type SidebarContextValue = { collapsed: boolean }; +const SidebarContext = React.createContext({ collapsed: false }); + +export function useSidebar(): SidebarContextValue { + return React.useContext(SidebarContext); +} + +const Sidebar = React.forwardRef & { collapsed?: boolean }>( + ({ className, collapsed = false, children, ...props }, ref) => ( + + + + ), +); +Sidebar.displayName = "Sidebar"; + +const SidebarHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +SidebarHeader.displayName = "SidebarHeader"; + +const SidebarContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +