diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 7ea6aa5b2a4..564f32e2573 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -24,6 +24,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "next-themes": "^0.4.6", "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", @@ -9805,6 +9806,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 6b2d4e6106b..ff6448ad75c 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -40,6 +40,7 @@ "lucide-react": "0.513.0", "moment": "2.30.1", "next": "16.2.11", + "next-themes": "^0.4.6", "nuqs": "^2.9.4", "openai": "4.104.0", "openapi-fetch": "^0.17.0", diff --git a/ui/litellm-dashboard/src/app/layout.tsx b/ui/litellm-dashboard/src/app/layout.tsx index 6ffc2a68f4f..60db7104a03 100644 --- a/ui/litellm-dashboard/src/app/layout.tsx +++ b/ui/litellm-dashboard/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { ThemeProvider } from "next-themes"; import { AuthProvider } from "@/contexts/AuthContext"; import ReactQueryProvider from "@/contexts/ReactQueryProvider"; @@ -22,14 +23,18 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + // next-themes stamps the theme class on before paint, which the exported markup + // cannot predict; suppressHydrationWarning confines that mismatch to this element. + - - - {children} - - - + + + + {children} + + + + ); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index 7f474baafe9..31f2a0b715a 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -14,6 +14,7 @@ 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 ThemeToggle from "@/components/ThemeToggle/ThemeToggle"; import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; import { useWorker } from "@/hooks/useWorker"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; @@ -73,6 +74,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) { {!hideCommunityLinks && } + diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx new file mode 100644 index 00000000000..3f7af94bfa0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemeProvider } from "next-themes"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import ThemeToggle from "./ThemeToggle"; + +const renderToggle = () => + render( + + + , + ); + +const openMenu = async () => { + await userEvent.click(screen.getByRole("button", { name: "Theme" })); + await screen.findByRole("menu"); +}; + +const pick = async (label: string) => userEvent.click(screen.getByRole("menuitemradio", { name: label })); + +beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove("dark", "light"); +}); + +afterAll(() => { + document.documentElement.classList.remove("dark", "light"); +}); + +describe("ThemeToggle", () => { + it("starts on light rather than following the system preference", async () => { + renderToggle(); + await openMenu(); + + expect(screen.getByRole("menuitemradio", { name: "Light" })).toBeChecked(); + expect(screen.getByRole("menuitemradio", { name: "Dark" })).not.toBeChecked(); + expect(screen.getByRole("menuitemradio", { name: "System" })).not.toBeChecked(); + }); + + it("puts the dark class on the document and remembers the choice", async () => { + renderToggle(); + await openMenu(); + + await pick("Dark"); + + expect(document.documentElement).toHaveClass("dark"); + expect(localStorage.getItem("theme")).toBe("dark"); + }); + + it("hands control back to the system preference when asked", async () => { + renderToggle(); + await openMenu(); + await pick("Dark"); + + await pick("System"); + + expect(localStorage.getItem("theme")).toBe("system"); + expect(document.documentElement).not.toHaveClass("dark"); + }); + + it("flags dark mode as experimental, and only while it is on", async () => { + renderToggle(); + await openMenu(); + expect(screen.queryByText("Experimental")).not.toBeInTheDocument(); + + await pick("Dark"); + expect(screen.getByText("Experimental")).toBeInTheDocument(); + + await pick("Light"); + expect(screen.queryByText("Experimental")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000000..6a006141523 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const THEMES = [ + { value: "system", label: "System", Icon: Monitor }, + { value: "light", label: "Light", Icon: Sun }, + { value: "dark", label: "Dark", Icon: Moon }, +] as const; + +const ThemeToggle: React.FC = () => { + const { theme, setTheme, resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( + + {isDark && ( + + Experimental + + )} + + + } + > + {isDark ? : } + + + + {THEMES.map(({ value, label, Icon }) => ( + + + {label} + + ))} + + + + + ); +}; + +export default ThemeToggle; diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 96cd2f2e7e0..e33ec404916 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -157,6 +157,21 @@ describe("Navbar", () => { expect(screen.getByRole("link", { name: /litellm brand/i })).toHaveAttribute("href", "/ui"); }); + it("pairs the logo with a dark-mode variant that swaps on the dark class", () => { + renderWithProviders(); + + const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm brand/i }).querySelectorAll("img")); + const classesOf = (el: Element) => new Set(el.className.split(/\s+/)); + + const lightSrc = light.getAttribute("src") ?? ""; + expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image$/)); + expect(dark).toHaveAttribute("src", `${lightSrc}?theme=dark`); + expect(classesOf(light).has("dark:hidden")).toBe(true); + expect(classesOf(light).has("hidden")).toBe(false); + expect(classesOf(dark).has("hidden")).toBe(true); + expect(classesOf(dark).has("dark:block")).toBe(true); + }); + it("should display user information in dropdown", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 2b1568127a6..0cd3a0d44e5 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -15,8 +15,10 @@ import React from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass"; +import { cn } from "@/lib/cva.config"; import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell"; import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; +import ThemeToggle from "./ThemeToggle/ThemeToggle"; import ViewSwitcher from "./Navbar/ViewSwitcher"; import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown"; @@ -27,6 +29,8 @@ interface NavbarProps { onToggleSidebar?: () => void; } +const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain"; + const Navbar: React.FC = ({ accessToken, isPublicPage = false, @@ -44,6 +48,7 @@ const Navbar: React.FC = ({ const showWorkerSwitch = isControlPlane && selectedWorker !== null; const imageUrl = logoUrl || `${baseUrl}/get_image`; + const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`; const handleLogout = () => { clearTokenCookies(); @@ -85,10 +90,12 @@ const Navbar: React.FC = ({
+ LiteLLM Brand LiteLLM Brand
@@ -158,6 +165,8 @@ const Navbar: React.FC = ({ {!isPublicPage && (
+ + @@ -165,7 +174,6 @@ const Navbar: React.FC = ({
)}
- {/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */} diff --git a/ui/litellm-dashboard/src/components/ui/sonner.tsx b/ui/litellm-dashboard/src/components/ui/sonner.tsx index 034e093a118..5c55557b024 100644 --- a/ui/litellm-dashboard/src/components/ui/sonner.tsx +++ b/ui/litellm-dashboard/src/components/ui/sonner.tsx @@ -1,12 +1,15 @@ "use client"; import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react"; +import { useTheme } from "next-themes"; import { Toaster as Sonner, type ToasterProps } from "sonner"; function Toaster({ ...props }: ToasterProps) { + const { resolvedTheme } = useTheme(); + return ( { - document.documentElement.classList.remove("dark"); -}); - -afterAll(() => { - document.documentElement.classList.remove("dark"); -}); - -describe("useIsDarkMode", () => { - it("reports the dark class already on the root element at mount", () => { - document.documentElement.classList.add("dark"); - - const { result } = renderHook(() => useIsDarkMode()); - - expect(result.current).toBe(true); - }); - - it("follows the root element's dark class as it is toggled", async () => { - const { result } = renderHook(() => useIsDarkMode()); - expect(result.current).toBe(false); - - document.documentElement.classList.add("dark"); - await waitFor(() => expect(result.current).toBe(true)); - - document.documentElement.classList.remove("dark"); - await waitFor(() => expect(result.current).toBe(false)); - }); - - it("stops observing the root element once unmounted", () => { - const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect"); - - const { unmount } = renderHook(() => useIsDarkMode()); - unmount(); - - expect(disconnect).toHaveBeenCalled(); - disconnect.mockRestore(); - }); -}); diff --git a/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts b/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts deleted file mode 100644 index bccaa31cb3a..00000000000 --- a/ui/litellm-dashboard/src/hooks/useIsDarkMode.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useSyncExternalStore } from "react"; - -const subscribe = (onStoreChange: () => void): (() => void) => { - const observer = new MutationObserver(onStoreChange); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); - return () => observer.disconnect(); -}; - -const getSnapshot = (): boolean => document.documentElement.classList.contains("dark"); - -const getServerSnapshot = (): boolean => false; - -export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx index 13b7d8136ef..53a152e6f44 100644 --- a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx +++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.test.tsx @@ -1,47 +1,50 @@ import { act, renderHook } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "next-themes"; +import type { ReactNode } from "react"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme"; const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } }; -const setRootDark = async (enabled: boolean) => { - await act(async () => { - document.documentElement.classList.toggle("dark", enabled); - await Promise.resolve(); +const renderSyntaxTheme = (defaultTheme: string) => + renderHook(() => ({ syntax: useSyntaxTheme(callerLightTheme), setTheme: useTheme().setTheme }), { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), }); -}; beforeEach(() => { - document.documentElement.classList.remove("dark"); + localStorage.clear(); + document.documentElement.classList.remove("dark", "light"); }); afterAll(() => { - document.documentElement.classList.remove("dark"); + document.documentElement.classList.remove("dark", "light"); }); describe("useSyntaxTheme", () => { it("keeps the caller's own stylesheet in light mode", () => { - const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); + const { result } = renderSyntaxTheme("light"); - expect(result.current).toBe(callerLightTheme); + expect(result.current.syntax).toBe(callerLightTheme); }); - it("swaps to oneDark when the root element turns dark", async () => { - const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); + it("serves oneDark when the resolved theme is dark", () => { + const { result } = renderSyntaxTheme("dark"); - await setRootDark(true); - - expect(result.current).toBe(oneDark); + expect(result.current.syntax).toBe(oneDark); }); - it("restores the caller's stylesheet when dark mode is turned back off", async () => { - document.documentElement.classList.add("dark"); - const { result } = renderHook(() => useSyntaxTheme(callerLightTheme)); - expect(result.current).toBe(oneDark); + it("swaps stylesheets when the theme is changed at runtime", () => { + const { result } = renderSyntaxTheme("light"); - await setRootDark(false); + act(() => result.current.setTheme("dark")); + expect(result.current.syntax).toBe(oneDark); - expect(result.current).toBe(callerLightTheme); + act(() => result.current.setTheme("light")); + expect(result.current.syntax).toBe(callerLightTheme); }); }); diff --git a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts index 80f5b514751..d107aa9ed18 100644 --- a/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts +++ b/ui/litellm-dashboard/src/hooks/useSyntaxTheme.ts @@ -1,8 +1,8 @@ import type { CSSProperties } from "react"; +import { useTheme } from "next-themes"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { useIsDarkMode } from "./useIsDarkMode"; - export type SyntaxTheme = Record; -export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light); +export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => + useTheme().resolvedTheme === "dark" ? oneDark : light;