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