From 7d63e452f160b9675900cfb011bdea35287bcae3 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 10:30:36 -0700 Subject: [PATCH] refactor(ui): move sidebar chrome onto shadcn primitives and whiten the sidebar Follow-up to the full-height sidebar shell. Replaces hand-rolled markup in the sidebar, top bar, and account/usage docks with shadcn primitives so the surface reuses shared components instead of one-off elements Adds three Base-UI-native primitives that follow the existing conventions (cva + data-slot, no Radix): Meter, Avatar, and Breadcrumb. Badge gains a render prop via Base UI useRender so it can render as an anchor, which activates the variant's existing [a&] styles Wires the redesigned surface onto them: the version tag is an outline Badge linking to the release notes, the breadcrumb uses the Breadcrumb primitive, the Docs link is a ghost Button, the account initials use Avatar, and the Enterprise usage card uses Collapsible for its open state and Meter for the seat and team gauges. Meter is the correct element for a used-of-total measurement and Base UI ships it natively, so it replaces the hand-rolled bars Sets the sidebar background to pure white by pointing the --sidebar token at oklch(1 0 0) in light mode, which keeps SidebarUsageCard consistent since it shares the token; the dark block is left untouched Also repairs layout.test.tsx, which the shell refactor had left red: the dashboard shell now renders DashboardHeader in place of the old navbar, so the test mocks DashboardHeader and asserts on it instead of the navbar the layout no longer mounts --- .../src/app/(dashboard)/layout.test.tsx | 10 +- ui/litellm-dashboard/src/app/globals.css | 2 +- .../src/components/DashboardHeader.tsx | 44 ++++--- .../Navbar/UserDropdown/UserDropdown.tsx | 25 ++-- .../src/components/SidebarUsageCard.test.tsx | 115 ++++++++++++++++++ .../src/components/SidebarUsageCard.tsx | 64 ++++------ .../src/components/leftnav.tsx | 12 +- .../src/components/ui/avatar.test.tsx | 15 +++ .../src/components/ui/avatar.tsx | 48 ++++++++ .../src/components/ui/badge.test.tsx | 32 +++++ .../src/components/ui/badge.tsx | 33 +++-- .../src/components/ui/breadcrumb.test.tsx | 38 ++++++ .../src/components/ui/breadcrumb.tsx | 78 ++++++++++++ .../src/components/ui/meter.test.tsx | 35 ++++++ .../src/components/ui/meter.tsx | 69 +++++++++++ 15 files changed, 528 insertions(+), 92 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/avatar.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/avatar.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/badge.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/breadcrumb.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/meter.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/meter.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index 92a1d40b0e3..f08258900eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -13,8 +13,8 @@ vi.mock("next/navigation", () => ({ usePathname: vi.fn(() => "/ui/guardrails"), })); -vi.mock("@/components/navbar", () => ({ - default: () =>
, +vi.mock("@/components/DashboardHeader", () => ({ + DashboardHeader: () =>
, })); vi.mock("@/app/(dashboard)/components/SidebarProvider", () => ({ @@ -76,12 +76,12 @@ describe("(dashboard) Layout", () => { await waitFor(() => expect(screen.getByTestId("loading-screen")).toBeTruthy()); expect(screen.queryByTestId("page-content")).toBeNull(); - expect(screen.queryByTestId("navbar")).toBeNull(); + expect(screen.queryByTestId("dashboard-header")).toBeNull(); pendingUiConfig.resolve(); await waitFor(() => expect(screen.getByTestId("page-content")).toBeTruthy()); - expect(screen.getByTestId("navbar")).toBeTruthy(); + expect(screen.getByTestId("dashboard-header")).toBeTruthy(); expect(screen.queryByTestId("loading-screen")).toBeNull(); }); @@ -102,7 +102,7 @@ describe("(dashboard) Layout", () => { expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/onboarding?invitation_id=abc123")), ); expect(screen.queryByTestId("page-content")).toBeNull(); - expect(screen.queryByTestId("navbar")).toBeNull(); + expect(screen.queryByTestId("dashboard-header")).toBeNull(); expect(screen.queryByTestId("sidebar")).toBeNull(); }); }); diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index 3f38b6b0831..0b555fcdd52 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -36,7 +36,7 @@ --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.002 247.839); + --sidebar: oklch(1 0 0); --sidebar-foreground: oklch(0.13 0.028 261.692); --sidebar-primary: oklch(0.21 0.034 264.665); --sidebar-primary-foreground: oklch(0.985 0.002 247.839); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index d765cdd45cb..4f285cefed9 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -1,6 +1,13 @@ "use client"; -import { ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; import { Separator } from "@/components/ui/separator"; import { getBreadcrumb } from "@/components/leftnav"; import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; @@ -35,15 +42,19 @@ export function DashboardHeader({ page }: DashboardHeaderProps) { return (
- + + + {section && ( + <> + {section} + + + )} + + {title} + + +
{showWorkerSwitch && ( @@ -52,14 +63,15 @@ export function DashboardHeader({ page }: DashboardHeaderProps) { )} - } + className="text-muted-foreground" > Docs - + {!hideCommunityLinks && } diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index dba4c97dbe3..983ab980d2f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -21,6 +21,7 @@ import { import type { MenuProps } from "antd"; import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; import { ChevronsUpDown } from "lucide-react"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; @@ -249,13 +250,11 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar aria-haspopup="menu" title={collapsed ? displayName : undefined} > - - {initials} - + + + {initials} + + {!collapsed && ( <> @@ -273,13 +272,11 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar aria-label={`Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`} aria-haspopup="menu" > - - {initials} - + + + {initials} + + {displayName} diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx new file mode 100644 index 00000000000..927ea657f46 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import SidebarUsageCard from "./SidebarUsageCard"; + +vi.mock("./networking", () => ({ getRemainingUsers: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({ + useDisableUsageIndicator: vi.fn(() => false), +})); + +vi.mock("@/app/(dashboard)/hooks/license/useLicenseInfo", () => ({ + useLicenseInfo: vi.fn(() => ({ data: null })), +})); + +import { getRemainingUsers } from "./networking"; + +const mockGetRemainingUsers = vi.mocked(getRemainingUsers); + +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +}; + +const SEATS_DATA = { + total_users: 100, + total_users_used: 20, + total_users_remaining: 80, + total_teams: null, + total_teams_used: 0, + total_teams_remaining: null, +}; + +const OVER_LIMIT_DATA = { + total_users: 100, + total_users_used: 130, + total_users_remaining: -30, + total_teams: null, + total_teams_used: 0, + total_teams_remaining: null, +}; + +const NO_LIMITS_DATA = { + total_users: null, + total_users_used: 186, + total_users_remaining: null, + total_teams: null, + total_teams_used: 125, + total_teams_remaining: null, +}; + +describe("SidebarUsageCard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetRemainingUsers.mockResolvedValue(SEATS_DATA); + }); + + it("renders an expanded seat meter reporting value and range when data loads", async () => { + const { container } = renderWithClient( + {}} />, + ); + + await screen.findByText("Enterprise usage"); + const meter = await screen.findByRole("meter"); + expect(meter).toHaveAttribute("aria-valuenow", "20"); + expect(meter).toHaveAttribute("aria-valuemax", "100"); + expect(screen.getByText("Seats")).toBeInTheDocument(); + + const indicator = container.querySelector('[data-slot="meter-indicator"]'); + expect(indicator).toHaveStyle({ width: "20%" }); + }); + + it("collapses the meter panel when the trigger is toggled", async () => { + const user = userEvent.setup(); + renderWithClient( {}} />); + + await screen.findByRole("meter"); + await user.click(screen.getByRole("button", { name: /Enterprise usage/i })); + + await waitFor(() => expect(screen.queryByRole("meter")).not.toBeInTheDocument()); + }); + + it("flags over-limit usage with a destructive, capped indicator", async () => { + mockGetRemainingUsers.mockResolvedValue(OVER_LIMIT_DATA); + + const { container } = renderWithClient( + {}} />, + ); + + await screen.findByRole("meter"); + const indicator = container.querySelector('[data-slot="meter-indicator"]'); + expect(indicator).toHaveClass("bg-destructive"); + expect(indicator).toHaveStyle({ width: "100%" }); + }); + + it("renders nothing when neither seat nor team limits are set", async () => { + mockGetRemainingUsers.mockResolvedValue(NO_LIMITS_DATA); + + renderWithClient( {}} />); + + await waitFor(() => expect(screen.queryByText("Enterprise usage")).not.toBeInTheDocument()); + }); + + it("shows a collapsed rail button that expands the sidebar", async () => { + const onExpandRail = vi.fn(); + const user = userEvent.setup(); + renderWithClient(); + + const rail = await screen.findByTitle("Enterprise usage"); + await user.click(rail); + expect(onExpandRail).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx index 51bdf5de208..7ab50001ae9 100644 --- a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -1,10 +1,10 @@ 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter"; import { useQuery } from "@tanstack/react-query"; import { Award, ChevronDown, Loader2 } from "lucide-react"; -import { useState } from "react"; import { getRemainingUsers } from "./networking"; interface SidebarUsageCardProps { @@ -13,7 +13,7 @@ interface SidebarUsageCardProps { onExpandRail: () => void; } -interface Meter { +interface MeterData { label: string; used: number; total: number; @@ -35,24 +35,21 @@ const meterBarClass = (pct: number): string => { return "bg-sidebar-primary"; }; -const Meter = ({ label, used, total }: Meter) => { +const UsageMeter = ({ label, used, total }: MeterData) => { const pct = total > 0 ? (used / total) * 100 : 0; return ( -
-
- {label} + +
+ {label} {used.toLocaleString()} / {total.toLocaleString()}
-
-
-
-
+ + + +
); }; @@ -66,7 +63,7 @@ const remainingUsersQuery = (accessToken: string | null) => ({ staleTime: 5 * 60 * 1000, }); -const buildMeters = (data: RemainingUsage | null): Meter[] => { +const buildMeters = (data: RemainingUsage | null): MeterData[] => { if (!data) return []; return [ ...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []), @@ -82,7 +79,6 @@ const buildMeters = (data: RemainingUsage | null): Meter[] => { */ 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; @@ -111,12 +107,8 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail const meters = buildMeters(data); return ( -
- + + - {open && ( -
- {isLoading && meters.length === 0 ? ( -
- Loading… -
- ) : ( - meters.map((m) => ) - )} -
- )} -
+ + {isLoading && meters.length === 0 ? ( +
+ Loading… +
+ ) : ( + meters.map((m) => ) + )} +
+ ); } diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 90bdca06595..f6762c85c1e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -5,6 +5,7 @@ import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadine import { useLogout } from "@/app/(dashboard)/hooks/useLogout"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Sidebar, @@ -584,14 +585,13 @@ const Sidebar_: React.FC = ({ /> {version && ( - } + className="px-1.5 py-0 font-mono text-[10px] font-medium text-muted-foreground group-data-[collapsed=true]/sidebar:hidden" > v{version} - + )}
{onToggleCollapsed && ( diff --git a/ui/litellm-dashboard/src/components/ui/avatar.test.tsx b/ui/litellm-dashboard/src/components/ui/avatar.test.tsx new file mode 100644 index 00000000000..7e13593b767 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/avatar.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Avatar, AvatarFallback } from "./avatar"; + +describe("Avatar", () => { + it("renders the fallback initials when no image is provided", () => { + render( + + AB + , + ); + const fallback = screen.getByText("AB"); + expect(fallback).toHaveAttribute("data-slot", "avatar-fallback"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/avatar.tsx b/ui/litellm-dashboard/src/components/ui/avatar.tsx new file mode 100644 index 00000000000..e257493a030 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"; +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +const Avatar = React.forwardRef, AvatarPrimitive.Root.Props>( + ({ className, ...props }, ref) => ( + + ), +); +Avatar.displayName = "Avatar"; + +const AvatarImage = React.forwardRef, AvatarPrimitive.Image.Props>( + ({ className, ...props }, ref) => ( + + ), +); +AvatarImage.displayName = "AvatarImage"; + +const AvatarFallback = React.forwardRef< + React.ComponentRef, + AvatarPrimitive.Fallback.Props +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = "AvatarFallback"; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/ui/litellm-dashboard/src/components/ui/badge.test.tsx b/ui/litellm-dashboard/src/components/ui/badge.test.tsx new file mode 100644 index 00000000000..2f380694eae --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/badge.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Badge } from "./badge"; + +describe("Badge", () => { + it("renders a span carrying the badge slot and variant classes by default", () => { + render(v1.2.3); + const badge = screen.getByText("v1.2.3"); + expect(badge.tagName).toBe("SPAN"); + expect(badge).toHaveAttribute("data-slot", "badge"); + expect(badge).toHaveAttribute("data-variant", "outline"); + expect(badge).toHaveClass("border-border"); + }); + + it("renders as an anchor via the render prop while keeping badge styling", () => { + render( + }> + v1.2.3 + , + ); + const link = screen.getByRole("link", { name: "v1.2.3" }); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "https://docs.litellm.ai/release_notes"); + expect(link).toHaveAttribute("data-slot", "badge"); + expect(link).toHaveClass("border-border"); + }); + + it("lets className win over variant classes through twMerge", () => { + render(x); + expect(screen.getByText("x")).toHaveClass("text-muted-foreground"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/badge.tsx b/ui/litellm-dashboard/src/components/ui/badge.tsx index 2e1ebffa109..f64de004b52 100644 --- a/ui/litellm-dashboard/src/components/ui/badge.tsx +++ b/ui/litellm-dashboard/src/components/ui/badge.tsx @@ -1,5 +1,8 @@ +"use client"; + import * as React from "react"; import { type VariantProps } from "cva"; +import { useRender } from "@base-ui/react/use-render"; import { cn, cva } from "@/lib/cva.config"; @@ -21,18 +24,24 @@ const badgeVariants = cva({ }, }); -const Badge = React.forwardRef< - HTMLSpanElement, - React.ComponentPropsWithoutRef<"span"> & VariantProps ->(({ className, variant = "default", ...props }, ref) => ( - -)); +type BadgeProps = React.ComponentPropsWithoutRef<"span"> & + VariantProps & { + render?: useRender.RenderProp; + }; + +const Badge = React.forwardRef( + ({ className, variant = "default", render, ...props }, ref) => + useRender({ + render: render ?? , + ref, + props: { + "data-slot": "badge", + "data-variant": variant, + className: cn(badgeVariants({ variant }), className), + ...props, + }, + }), +); Badge.displayName = "Badge"; export { Badge, badgeVariants }; diff --git a/ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx b/ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx new file mode 100644 index 00000000000..b43eda1116d --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/breadcrumb.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./breadcrumb"; + +describe("Breadcrumb", () => { + it("renders a labelled nav and marks the current page", () => { + render( + + + Observability + + + Logs + + + , + ); + expect(screen.getByRole("navigation", { name: "breadcrumb" })).toBeInTheDocument(); + const page = screen.getByText("Logs"); + expect(page).toHaveAttribute("aria-current", "page"); + expect(page).toHaveAttribute("data-slot", "breadcrumb-page"); + }); + + it("renders a presentational separator", () => { + const { container } = render( + + + A + + B + + , + ); + const sep = container.querySelector('[data-slot="breadcrumb-separator"]'); + expect(sep).toHaveAttribute("aria-hidden", "true"); + expect(sep?.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ui/breadcrumb.tsx b/ui/litellm-dashboard/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000000..731a014362d --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/breadcrumb.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/cva.config"; + +const Breadcrumb = React.forwardRef>(({ ...props }, ref) => ( +