diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 5e322598a10..02982f677bf 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1855,39 +1855,11 @@
"count": 12
}
},
- "src/components/Navbar/BlogDropdown/BlogDropdown.tsx": {
- "no-restricted-imports": {
- "count": 2
- }
- },
- "src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
- "src/components/Navbar/NotificationsBell/NotificationsBell.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/components/Navbar/UserDropdown/UserDropdown.tsx": {
- "no-restricted-imports": {
- "count": 2
- },
"react-hooks/set-state-in-effect": {
"count": 1
}
},
- "src/components/Navbar/ViewSwitcher.tsx": {
- "no-restricted-imports": {
- "count": 2
- }
- },
- "src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/components/SCIM.tsx": {
"no-restricted-imports": {
"count": 2
diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx
index 8871780ae96..bad740fa361 100644
--- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx
@@ -66,6 +66,27 @@ describe("BlogDropdown", () => {
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
});
+ it("should not render menu content before the trigger is hovered", () => {
+ mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
+ renderWithProviders();
+
+ expect(screen.queryByRole("link", { name: /view all posts/i })).not.toBeInTheDocument();
+ expect(screen.queryByText("Post One")).not.toBeInTheDocument();
+ });
+
+ it("should open the menu on hover", async () => {
+ mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 1) } };
+ renderWithProviders();
+
+ expect(screen.queryByText("Post One")).not.toBeInTheDocument();
+
+ await openDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByText("Post One")).toBeInTheDocument();
+ });
+ });
+
describe("loading state", () => {
it("should show a loading spinner", async () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
@@ -74,7 +95,7 @@ describe("BlogDropdown", () => {
await openDropdown();
await waitFor(() => {
- expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
+ expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument();
});
});
});
diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
index be659967a0f..5f6b5eacc0f 100644
--- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx
@@ -1,13 +1,17 @@
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass";
-import { DownOutlined, LoadingOutlined } from "@ant-design/icons";
-import { Button, Dropdown, Space, Typography } from "antd";
-import type { MenuProps } from "antd";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ChevronDown, LoaderCircle } from "lucide-react";
import React from "react";
-const { Text, Title, Paragraph } = Typography;
-
function formatDate(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
@@ -26,63 +30,70 @@ export const BlogDropdown: React.FC = () => {
return null;
}
- let items: MenuProps["items"];
+ const renderMenuContent = () => {
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
- if (isLoading) {
- items = [{ key: "loading", label: , disabled: true }];
- } else if (isError) {
- items = [
- {
- key: "error",
- label: (
-
- Failed to load posts
-
-
- ),
- disabled: true,
- },
- ];
- } else if (!data || data.posts.length === 0) {
- items = [{ key: "empty", label: No posts available, disabled: true }];
- } else {
- items = [
- ...data.posts.slice(0, 5).map((post: BlogPost) => ({
- key: post.url,
- label: (
-
-
- {post.title}
-
-
- {formatDate(post.date)}
-
- {post.description}
-
- ),
- })),
- { type: "divider" as const },
- {
- key: "view-all",
- label: (
+ if (isError) {
+ return (
+
+ Failed to load posts
+
+
+ );
+ }
+
+ if (!data || data.posts.length === 0) {
+ return No posts available
;
+ }
+
+ return (
+ <>
+ {data.posts.slice(0, 5).map((post: BlogPost) => (
+
+
+
+ {post.title}
+
+
+ {formatDate(post.date)}
+
+ {post.description}
+
+
+ ))}
+
+
View all posts
- ),
- },
- ];
- }
+
+ >
+ );
+ };
// Blog opens a post list; Docs is a single outbound link — navbar adds a layout-only chevron there for alignment.
return (
-
-
-
+
+
+
+ {renderMenuContent()}
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx
index f6a43196a32..8ec31d74cd6 100644
--- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx
@@ -1,6 +1,6 @@
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
-import { GithubOutlined, SlackOutlined } from "@ant-design/icons";
-import { Tooltip } from "antd";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { Github, Slack } from "lucide-react";
import React from "react";
const iconBtnClass =
@@ -18,28 +18,40 @@ export const CommunityEngagementButtons: React.FC = () => {
className="flex items-center gap-0.5 rounded-md border border-gray-200/80 bg-gray-50 px-0.5 py-0"
aria-label="Community links"
>
-
-
-
-
-
-
-
-
-
-
+
+
+
+ }
+ >
+
+
+ LiteLLM Slack community
+
+
+
+ }
+ >
+
+
+ LiteLLM on GitHub
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
index a3d5db4afd7..f3adcf6d8be 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
@@ -5,8 +5,11 @@ import {
useHideAutoRouterAnnouncement,
} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
-import { BellOutlined } from "@ant-design/icons";
-import { Badge, Button, Popover, Typography } from "antd";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
+import { cn } from "@/lib/cva.config";
+import { Bell } from "lucide-react";
import React, { useState } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
@@ -24,18 +27,21 @@ export const NotificationsBell: React.FC = () => {
const content = (
-
- LiteLLM Auto Router
-
-
+ LiteLLM Auto Router
+
Route every request to the cheapest model that can handle it, no prompt changes needed.
-
+
-
+
{hasUnread ? (
-
+
) : (
-
+ }
>
@@ -261,10 +247,27 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar
{displayName}
-
-
+
+
)}
-
+
+ {renderUserInfoSection()}
+
+
+
+ Logout
+
+
+
);
};
diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
index 09a4538ae18..f1dec137305 100644
--- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
@@ -1,9 +1,12 @@
import React from "react";
import { usePathname } from "next/navigation";
-import { Dropdown } from "antd";
-import { AppstoreOutlined, CheckOutlined } from "@ant-design/icons";
-import { ChevronsUpDown } from "lucide-react";
-import type { MenuProps } from "antd";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { migratedHref } from "@/utils/migratedPages";
@@ -11,6 +14,13 @@ import { migratedHref } from "@/utils/migratedPages";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
+interface ViewSwitcherItem {
+ key: string;
+ label: React.ReactNode;
+ disabled?: boolean;
+ onClick?: () => void;
+}
+
export default function ViewSwitcher() {
const { mode, setMode, plugins } = usePluginMode();
const { data: uiSettings } = useUISettings();
@@ -29,15 +39,25 @@ export default function ViewSwitcher() {
...plugins.map((p) => ({ key: p.name, label: p.display_name })),
];
- const chatItem = chatEnabled
+ const selectMode = (key: string) => {
+ setMode(key);
+ // The chat route lives outside the dashboard SPA shell that reacts to `mode`,
+ // so switching modes from there needs a real navigation, not just state.
+ if (isChatRoute) {
+ window.location.assign(migratedHref(""));
+ }
+ };
+
+ const chatItem: ViewSwitcherItem = chatEnabled
? {
key: CHAT,
label: (
Chat
- {isChatRoute && }
+ {isChatRoute && }
),
+ onClick: () => window.location.assign(migratedHref(CHAT)),
}
: {
key: CHAT,
@@ -52,44 +72,43 @@ export default function ViewSwitcher() {
),
};
- const items: MenuProps["items"] = [
+ const items: ViewSwitcherItem[] = [
...modeEntries.map((e) => ({
key: e.key,
label: (
{e.label}
- {!isChatRoute && e.key === mode && }
+ {!isChatRoute && e.key === mode && }
),
+ onClick: () => selectMode(e.key),
})),
chatItem,
];
- const onClick: MenuProps["onClick"] = ({ key }) => {
- if (key === CHAT) {
- window.location.assign(migratedHref(CHAT));
- return;
- }
- setMode(key);
- // The chat route lives outside the dashboard SPA shell that reacts to `mode`,
- // so switching modes from there needs a real navigation, not just state.
- if (isChatRoute) {
- window.location.assign(migratedHref(""));
- }
- };
-
return (
-
-
+
+ }
>
-
+
{activeLabel}
-
-
+
+
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
);
}
diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx
index a51d6ba055d..0930270eb4b 100644
--- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx
@@ -1,32 +1,21 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
-// Mock the useWorker hook
const mockUseWorker = vi.fn();
vi.mock("@/hooks/useWorker", () => ({
useWorker: () => mockUseWorker(),
}));
-// Mock antd Select
-vi.mock("antd", () => ({
- Select: ({ value, options, onChange, style, disabled, ...props }: any) => (
-
- ),
-}));
-
-// Mock icon
-vi.mock("@ant-design/icons", () => ({
- CloudServerOutlined: () => ,
-}));
-
import WorkerDropdown from "./WorkerDropdown";
+async function openWorkerList(user: ReturnType) {
+ await user.click(screen.getByRole("combobox"));
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "true");
+ });
+}
+
describe("WorkerDropdown", () => {
const mockOnWorkerSwitch = vi.fn();
const workers = [
@@ -61,31 +50,7 @@ describe("WorkerDropdown", () => {
expect(container).toBeEmptyDOMElement();
});
- it("renders the select when isControlPlane and selectedWorker exist", () => {
- mockUseWorker.mockReturnValue({
- isControlPlane: true,
- selectedWorker: workers[0],
- workers,
- });
-
- render();
- expect(screen.getByTestId("worker-select")).toBeInTheDocument();
- });
-
- it("renders all worker options", () => {
- mockUseWorker.mockReturnValue({
- isControlPlane: true,
- selectedWorker: workers[0],
- workers,
- });
-
- render();
- expect(screen.getByText("Worker 1")).toBeInTheDocument();
- expect(screen.getByText("Worker 2")).toBeInTheDocument();
- expect(screen.getByText("Worker 3")).toBeInTheDocument();
- });
-
- it("sets current worker as selected value", () => {
+ it("renders a collapsed worker combobox when isControlPlane and selectedWorker exist", () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
selectedWorker: workers[1],
@@ -93,37 +58,109 @@ describe("WorkerDropdown", () => {
});
render();
- const select = screen.getByTestId("worker-select") as HTMLSelectElement;
- expect(select.value).toBe("w2");
+ expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
});
- it("disables the currently selected worker in options", () => {
+ it("reveals every worker only once the combobox is opened", async () => {
mockUseWorker.mockReturnValue({
isControlPlane: true,
- selectedWorker: workers[0],
+ selectedWorker: workers[1],
workers,
});
-
- render();
- const options = screen.getAllByRole("option");
- const selectedOption = options.find((opt) => (opt as HTMLOptionElement).value === "w1");
- expect(selectedOption).toBeDisabled();
- });
-
- it("calls onWorkerSwitch when selection changes", async () => {
- mockUseWorker.mockReturnValue({
- isControlPlane: true,
- selectedWorker: workers[0],
- workers,
- });
-
- render();
- const select = screen.getByTestId("worker-select");
-
- const { default: userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
- await user.selectOptions(select, "w2");
- expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w2");
+ render();
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
+ expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
+ expect(screen.queryByText("Worker 3")).not.toBeInTheDocument();
+
+ await openWorkerList(user);
+
+ await waitFor(() => {
+ expect(screen.getByText("Worker 1")).toBeInTheDocument();
+ });
+ expect(screen.getAllByText("Worker 2").length).toBeGreaterThan(0);
+ expect(screen.getByText("Worker 3")).toBeInTheDocument();
+ });
+
+ it("marks exactly one option as selected, the current worker", async () => {
+ mockUseWorker.mockReturnValue({
+ isControlPlane: true,
+ selectedWorker: workers[1],
+ workers,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await openWorkerList(user);
+
+ await waitFor(() => {
+ const selected = screen.getAllByRole("option").filter((o) => o.getAttribute("aria-selected") === "true");
+ expect(selected).toHaveLength(1);
+ expect(selected[0]).toHaveAccessibleName("Worker 2");
+ });
+ });
+
+ it("calls onWorkerSwitch with the id of the worker that was picked", async () => {
+ mockUseWorker.mockReturnValue({
+ isControlPlane: true,
+ selectedWorker: workers[1],
+ workers,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await openWorkerList(user);
+ await waitFor(() => {
+ expect(screen.getByText("Worker 3")).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText("Worker 3"));
+
+ expect(mockOnWorkerSwitch).toHaveBeenCalledWith("w3");
+ });
+
+ it("does not call onWorkerSwitch when the already-current worker is picked", async () => {
+ mockUseWorker.mockReturnValue({
+ isControlPlane: true,
+ selectedWorker: workers[1],
+ workers,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await openWorkerList(user);
+ await waitFor(() => {
+ expect(screen.getByText("Worker 3")).toBeInTheDocument();
+ });
+
+ for (const currentWorkerNode of screen.getAllByText("Worker 2")) {
+ fireEvent.click(currentWorkerNode);
+ }
+
+ expect(mockOnWorkerSwitch).not.toHaveBeenCalled();
+ });
+
+ it("filters the worker options by the typed search text", async () => {
+ mockUseWorker.mockReturnValue({
+ isControlPlane: true,
+ selectedWorker: workers[1],
+ workers,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await openWorkerList(user);
+ await waitFor(() => {
+ expect(screen.getByText("Worker 1")).toBeInTheDocument();
+ });
+
+ await user.clear(screen.getByRole("combobox"));
+ await user.type(screen.getByRole("combobox"), "worker 3");
+
+ await waitFor(() => {
+ expect(screen.queryByText("Worker 1")).not.toBeInTheDocument();
+ });
+ expect(screen.getByText("Worker 3")).toBeInTheDocument();
});
});
diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx
index 186cc611117..432bab8c9ef 100644
--- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx
@@ -1,35 +1,66 @@
"use client";
import React from "react";
-import { Select } from "antd";
-import { CloudServerOutlined } from "@ant-design/icons";
+import { Server } from "lucide-react";
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox";
+import { InputGroupAddon } from "@/components/ui/input-group";
import { useWorker } from "@/hooks/useWorker";
interface WorkerDropdownProps {
onWorkerSwitch: (workerId: string) => void;
}
+interface WorkerOption {
+ label: string;
+ value: string;
+ disabled: boolean;
+}
+
const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => {
const { isControlPlane, selectedWorker, workers } = useWorker();
if (!isControlPlane || !selectedWorker) return null;
+ const options: WorkerOption[] = workers.map((w) => ({
+ label: w.name,
+ value: w.worker_id,
+ disabled: w.worker_id === selectedWorker.worker_id,
+ }));
+
return (
-