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 ? ( - ) : null} @@ -44,16 +50,17 @@ export const NotificationsBell: React.FC = () => { ); return ( - - + + + {hasUnread ? : null} + + + {content} ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 28e981c57a1..50c44367020 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -9,23 +9,17 @@ import { setLocalStorageItem, } from "@/utils/localStorageUtils"; import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; -import { - CrownOutlined, - DownOutlined, - LogoutOutlined, - MailOutlined, - SafetyOutlined, - UserOutlined, -} 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 { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import CopyButton from "@/components/shared/CopyButton"; import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; -const { Text } = Typography; - function hueFromString(seed: string): number { let h = 0; for (let i = 0; i < seed.length; i += 1) { @@ -80,60 +74,57 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar setDisableShowNewBadge(storedValue === "true"); }, []); - const userItems: MenuProps["items"] = [ - { - key: "logout", - label: ( - - - Logout - - ), - onClick: onLogout, - }, - ]; - const renderUserInfoSection = () => ( - - - - - {userEmail || "-"} - +
+
+
+ + {userEmail || "-"} +
{premiumUser ? ( - } color="gold"> + + Premium - + ) : ( - - }>Standard - + + + }> + + Standard + + Upgrade to Premium for advanced features + + )} - - - - - - User ID - - - {userId || "-"} - - - - - - Role - - {userRole} - - - - Hide New Feature Indicators +
+ +
+
+ + User ID +
+
+ + {userId || "-"} + + +
+
+
+
+ + Role +
+ {userRole} +
+ +
+ Hide New Feature Indicators { + onCheckedChange={(checked) => { setDisableShowNewBadge(checked); if (checked) { setLocalStorageItem("disableShowNewBadge", "true"); @@ -145,13 +136,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide new feature indicators" /> - - - Hide All Prompts +
+
+ Hide All Prompts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableShowPrompts", "true"); emitLocalStorageChange("disableShowPrompts"); @@ -162,13 +153,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide all prompts" /> - - - Hide Blog Posts +
+
+ Hide Blog Posts { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBlogPosts", "true"); emitLocalStorageChange("disableBlogPosts"); @@ -179,13 +170,13 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide blog posts" /> - - - Hide Bouncing Icon +
+
+ Hide Bouncing Icon { + onCheckedChange={(checked) => { if (checked) { setLocalStorageItem("disableBouncingIcon", "true"); emitLocalStorageChange("disableBouncingIcon"); @@ -196,8 +187,8 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar }} aria-label="Toggle hide bouncing icon" /> - - +
+
); const seed = userEmail || userId || "user"; @@ -206,30 +197,21 @@ const UserDropdown: React.FC = ({ onLogout, variant = "navbar const displayName = navAccountDisplayName(userEmail, userId); return ( - ( -
- {renderUserInfoSection()} - - {React.cloneElement(menu as React.ReactElement, { - style: { boxShadow: "none" }, - })} -
- )} - > + {variant === "sidebar" ? ( - + ) : ( - + + )} -
+ + {renderUserInfoSection()} + + + + ); }; 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 ( - - - + + + {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 ( -