From 931b617a51f775327cde2d2cbb75dc3ce0873e55 Mon Sep 17 00:00:00 2001 From: Tin Date: Fri, 10 Jul 2026 00:45:38 -0700 Subject: [PATCH 01/49] feat(mcp): persist admin-entered OAuth app credentials for the client-forwarded modes --- .../mcp_tools/PassthroughAuthorizeSection.tsx | 31 ++++---- .../mcp_tools/create_mcp_server.test.tsx | 71 ++++++++++++++++++- .../mcp_tools/create_mcp_server.tsx | 4 +- .../mcp_tools/mcp_server_edit.test.tsx | 46 ++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 4 +- 5 files changed, 138 insertions(+), 18 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx index af81f2713ae..314b45fff6c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx @@ -11,13 +11,14 @@ interface PassthroughOAuthFlow { /** * Browser-only Authorize & Fetch for the client-forwarded token modes - * (true_passthrough / oauth_delegate). LiteLLM never stores upstream - * credentials for these modes, so the token obtained here lives in this - * browser session only: it is forwarded per-server for the tools preview and - * allowlist configuration, and is never written to the server row or the - * per-user credential store. The optional client credentials cover IdPs - * without dynamic client registration (e.g. a pre-registered Slack app) and - * ride the temporary authorize session only. + * (true_passthrough / oauth_delegate). Tokens are never stored: the token + * obtained here lives in this browser session only, forwarded per-server for + * the tools preview and allowlist configuration, and is never written to the + * server row or the per-user credential store. The optional OAuth client + * credentials cover IdPs without dynamic client registration (e.g. a + * pre-registered Slack app); unlike the token they ARE saved onto the server + * as declared config, so internal users' Authorize relays through the org's + * app instead of dead-ending on upstreams that cannot mint clients. */ export default function PassthroughAuthorizeSection({ authType, @@ -35,14 +36,15 @@ export default function PassthroughAuthorizeSection({ return (

- Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview - tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser - session only and is never saved to LiteLLM. + Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and + configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only + and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who + authorize from the Tools page go through it.

OAuth Client ID (optional, not saved)} + label={OAuth Client ID (optional, saved)} name={["credentials", "client_id"]} - extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only." + extra="Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)." > OAuth Client Secret (optional, not saved)} + label={OAuth Client Secret (optional, saved)} name={["credentials", "client_secret"]} > {oauthFlow.error}

} {oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (

- Token held for this browser session. Tools can now be previewed and configured; nothing was saved to LiteLLM. + Token held for this browser session. Tools can now be previewed and configured; the token was not saved to + LiteLLM.

)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 02374a3ffa4..0a3128c6e7f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -202,8 +202,8 @@ describe("CreateMCPServer", () => { await waitFor(() => { expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument(); }); - expect(screen.getByText("OAuth Client ID (optional, not saved)")).toBeInTheDocument(); - expect(screen.getByText("OAuth Client Secret (optional, not saved)")).toBeInTheDocument(); + expect(screen.getByText("OAuth Client ID (optional, saved)")).toBeInTheDocument(); + expect(screen.getByText("OAuth Client Secret (optional, saved)")).toBeInTheDocument(); }, ); @@ -436,6 +436,73 @@ describe("CreateMCPServer", () => { ); }); + it.each([ + ["true_passthrough", "True Passthrough (no LiteLLM auth)"], + ["oauth_delegate", "OAuth Delegate (client-supplied upstream token)"], + ])( + "persists admin-entered OAuth app credentials on create for %s while the token stays browser-held", + async (_authType, optionLabel) => { + oauthHook.tokenResponse = { access_token: "upstream-tok", token_type: "Bearer" }; + await selectHttpTransport(); + + const user = userEvent.setup({ delay: null }); + await user.type(getServerNameInput(), "CF_App_Server"); + await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp"); + + await selectAntOption("Authentication", optionLabel); + + // Admin declares the org's pre-registered upstream app; unlike the browser-authorized + // token, this is config and must survive onto the server row so internal users' + // Tools-page Authorize relays through it (required for non-DCR upstreams like Slack). + await user.type( + screen.getByPlaceholderText("Leave blank to use dynamic client registration"), + "org-app-client-id", + ); + await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret"); + + await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy()); + await act(async () => { + oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined); + }); + + const createdServer = { + server_id: "new-cf-app-server", + server_name: "CF_App_Server", + alias: "CF_App_Server", + url: "https://example.com/mcp", + transport: "http", + auth_type: _authType, + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }; + vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + + // The declared app persists; the browser-authorized token still appears nowhere in the + // payload and no per-user DB credential is written. + expect(payload.credentials).toEqual({ + client_id: "org-app-client-id", + client_secret: "org-app-secret", + }); + expect(JSON.stringify(payload)).not.toContain("upstream-tok"); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + expect(setToken).toHaveBeenCalledWith( + "new-cf-app-server", + expect.objectContaining({ access_token: "upstream-tok" }), + undefined, + ); + }, + ); + it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index eb48fd02474..79db031008e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -60,6 +60,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.TRUE_PASSTHROUGH, + AUTH_TYPE.OAUTH_DELEGATE, ]; const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state"; @@ -209,7 +211,7 @@ const CreateMCPServer: React.FC = ({ // edit form's onTokenReceived early return. setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true))); NotificationsManager.success( - "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", + "Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.", ); return; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index adb3e161da5..a1bad0307b0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import MCPServerEdit from "./mcp_server_edit"; import * as networking from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; @@ -1377,6 +1378,51 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => { }, ); + it.each([["true_passthrough"], ["oauth_delegate"]])( + "persists admin-entered OAuth app credentials in the update payload for the %s mode", + async (authType) => { + mockOauth.tokenResponse = { access_token: "cf-tok", expires_in: 1800, token_type: "bearer" }; + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + auth_type: authType, + }); + + render( + , + ); + + const user = userEvent.setup({ delay: null }); + await user.type( + screen.getByPlaceholderText("Leave blank to use dynamic client registration"), + "org-app-client-id", + ); + await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret"); + + await act(async () => { + fireEvent.click(screen.getAllByRole("button", { name: "Save Changes" })[0]); + }); + + await waitFor(() => expect(networking.updateMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + + // The declared app is config and persists onto the row; the browser-held token still never + // reaches the payload or the per-user credential store. + expect(payload.credentials).toMatchObject({ + client_id: "org-app-client-id", + client_secret: "org-app-secret", + }); + expect(JSON.stringify(payload)).not.toContain("cf-tok"); + expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled(); + }, + ); + it("forwards a newly authorized browser-held token for tool loading before the form is saved", async () => { // Regression: fetchTools keyed the browser-held decision off the saved mcpServer.auth_type, so // after switching the form to true_passthrough and authorizing, the fresh token was not sent as diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 7446c96c40e..9b2c5af861f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -56,6 +56,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.TRUE_PASSTHROUGH, + AUTH_TYPE.OAUTH_DELEGATE, ]; export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -202,7 +204,7 @@ const MCPServerEdit: React.FC = ({ }; setToken(mcpServer.server_id, browserHeldToken, userID); NotificationsManager.success( - "Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.", + "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.", ); return; } From 5a654c5c61a7c77bd176757f10461c0dbd9d841b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 09:30:33 -0700 Subject: [PATCH 02/49] 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) => ( +