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) => ( +