From cbe361d97079c73fe5b2800a4f215eb6057c857d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Nov 2026 18:23:01 -0800 Subject: [PATCH 01/94] feat(ui): rebuild the public AI Hub as a searchable list with a details pane (#49496) * feat(ui): rebuild the public AI Hub as a searchable list with a details pane The public AI Hub at /ui/model_hub_table (and the older /ui/model_hub) now renders PublicAiHub, built from shadcn parts only: a header with the gateway's base URL and the admin's links, segmented tabs for models, agents, MCP servers and skills, and a list beside a details pane on desktop that becomes a bottom sheet on phones. The open item and tab live in the URL. It keeps every feature the old page had: server-side search, provider, mode and feature filters, sorting, paging, health, rate limits, prices, custom metadata, supported params, usage snippets, the require-auth redirect and the not-enabled state. cURL shows only for chat models, since the old one called chat completions for every mode. The navbar hides the community buttons below the sm breakpoint on every page, and the public navbar uses a smaller logo on phones, so Docs and Blog no longer collide with them. * fix(ui): harden the public AI Hub after adversarial review - Show "This AI Hub isn't available" only when every hub call fails; a failed models call now shows an inline error with Try again and leaves agents, MCP servers and skills usable (a gateway with no models answers the models list with 400) - Row links keep the page's other query params, so open-in-new-tab and copy link no longer 404 - A shared ?open= link resolves models beyond the first page, keeps the opened item when a search filters it out, and says when it is no longer listed instead of showing a different item - Searching, sorting and paging no longer blank the details pane or drop the Operational badge - Key MCP servers by their listed name, which is never null - Keep sub-cent prices and show Free for zero cost, fold evaluation into Decisions, sort agents, MCP servers and skills by name, restore word-wise search, and show a skill's source, author and date added - Drop the invented description for models without a mode, the empty agent modes row, the stray auth dash on MCP rows and NaN dates - Do not retry the UI config call, and close the 1023-1024px gap between the sheet and the details pane - Update the public hub Playwright spec for the new tabs and list, and prune the lint suppressions the old route pages needed * fix(ui): surface hub load errors and keep the open item steady Agents, MCP servers and skills now load through apiClient so an HTTP error shows the inline error instead of an empty "published yet" state, and an all-failing gateway reaches the unavailable page. Try again shows loading instead of an empty hub, the open model stays in the details pane when a search or page change hides its row, a zero-result search keeps the pane, phones get the not-listed sheet for a stale link, an empty open param means nothing opened, free models read Free in the list, and an invalid skill date is hidden --- tests/e2e/ui/tests/modelHub/modelHub.spec.ts | 29 +- ui/litellm-dashboard/eslint-suppressions.json | 10 - .../src/app/model_hub/page.tsx | 24 +- .../src/app/model_hub_table/page.tsx | 33 +- .../src/components/navbar.tsx | 11 +- .../publicModelHub/AgentInspector.tsx | 80 ++++ .../components/publicModelHub/FilterChip.tsx | 91 +++++ .../components/publicModelHub/HubEmpty.tsx | 92 +++++ .../components/publicModelHub/HubHeader.tsx | 103 ++++++ .../publicModelHub/HubMasterDetail.tsx | 234 ++++++++++++ .../components/publicModelHub/HubParts.tsx | 187 ++++++++++ .../components/publicModelHub/HubStatus.tsx | 49 +++ .../publicModelHub/InspectorHeader.tsx | 49 +++ .../publicModelHub/McpInspector.tsx | 29 ++ .../publicModelHub/ModelInspector.tsx | 164 +++++++++ .../components/publicModelHub/ModelsPane.tsx | 238 ++++++++++++ .../PublicAiHub.integration.test.tsx | 342 ++++++++++++++++++ .../components/publicModelHub/PublicAiHub.tsx | 113 ++++++ .../publicModelHub/ResourcePanes.tsx | 184 ++++++++++ .../publicModelHub/SkillInspector.tsx | 66 ++++ .../publicModelHub/hubFormat.test.ts | 138 +++++++ .../components/publicModelHub/hubFormat.ts | 142 ++++++++ .../publicModelHub/hubSnippets.test.ts | 59 +++ .../components/publicModelHub/hubSnippets.ts | 93 +++++ .../components/publicModelHub/useHubData.ts | 88 +++++ .../publicModelHub/useHubUrlState.ts | 32 ++ .../components/publicModelHub/useIsNarrow.ts | 16 + .../publicModelHub/useRequirePublicHubAuth.ts | 19 + 28 files changed, 2645 insertions(+), 70 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index 04b02d9ccc4..f8ce4faf45e 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -64,7 +64,7 @@ test.describe("AI Hub (internal admin view)", () => { test.describe("Public model hub (/ui/model_hub_table)", () => { // No storageState — the public page is reached anonymously with a `key` query param. - test("Public model_hub_table loads and renders the Model Hub tab", async ({ page }) => { + test("Public model_hub_table loads and renders the Models tab", async ({ page }) => { // The page expects the proxy key as the `key` query param. Use the master // key the e2e runner already exports — this matches what the AI Hub copy // button hands out. @@ -74,13 +74,10 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { // race can't briefly mask the tab while we're evaluating visibility. await dismissFeedbackPopup(page); - // Page loads (no auth redirect) and the Model Hub tab is always present. - // Agent Hub and MCP Hub tabs are conditionally rendered only when public - // agents/MCP servers exist, so we don't assert on them in a fresh CI run. - await expect(page.getByRole("tab", { name: "Model Hub" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 }); }); - test("Agent Hub and MCP Hub tabs render their public entries", async ({ page, request }) => { + test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => { const suffix = `${Date.now()}`; const agentName = `e2e-public-agent-${suffix}`; const mcpServerName = `e2e_public_mcp_${suffix}`; @@ -145,18 +142,18 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { await page.goto(`/ui/model_hub_table?key=${masterKey()}`); await dismissFeedbackPopup(page); - const agentHubTab = page.getByRole("tab", { name: "Agent Hub" }); - await expect(agentHubTab).toBeVisible({ timeout: 15_000 }); - await agentHubTab.click(); - await expect(page.getByText("Available Agents")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); + const agentsTab = page.getByRole("tab", { name: /^Agents/ }); + await expect(agentsTab).toBeVisible({ timeout: 15_000 }); + await agentsTab.click(); + await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); await expect(page.getByText("E2E public agent").first()).toBeVisible(); - const mcpHubTab = page.getByRole("tab", { name: "MCP Hub" }); - await expect(mcpHubTab).toBeVisible(); - await mcpHubTab.click(); - await expect(page.getByText("Available MCP Servers")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); + const mcpTab = page.getByRole("tab", { name: /^MCP/ }); + await expect(mcpTab).toBeVisible(); + await mcpTab.click(); + await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); await expect(page.getByText("E2E public MCP server").first()).toBeVisible(); } finally { await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } }); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 0cadc13a213..3d30964129d 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1153,16 +1153,6 @@ "count": 2 } }, - "src/app/model_hub/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/model_hub_table/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/model_hub/page.tsx b/ui/litellm-dashboard/src/app/model_hub/page.tsx index df6228f3b36..60a2f054dff 100644 --- a/ui/litellm-dashboard/src/app/model_hub/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub/page.tsx @@ -1,27 +1,19 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; + import { useSearchParams } from "next/navigation"; -import PublicModelHubPage from "@/components/public_model_hub"; +import { Suspense } from "react"; -function PublicModelHubContent() { - const searchParams = useSearchParams()!; - const key = searchParams.get("key"); - const [accessToken, setAccessToken] = useState(null); +import PublicAiHub from "@/components/publicModelHub/PublicAiHub"; - useEffect(() => { - if (!key) { - return; - } - setAccessToken(key); - }, [key]); - - return ; +function PublicAiHubPage() { + const accessToken = useSearchParams()?.get("key") ?? null; + return ; } export default function PublicModelHub() { return ( - Loading...}> - + Loading...}> + ); } diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index af6764dcd4c..99df5ed8cfd 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -1,36 +1,19 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; + import { useSearchParams } from "next/navigation"; -import ModelHubTable from "@/components/AIHub/ModelHubTable"; +import { Suspense } from "react"; -function PublicModelHubTableContent() { - const searchParams = useSearchParams()!; - const key = searchParams.get("key"); - const [accessToken, setAccessToken] = useState(null); +import PublicAiHub from "@/components/publicModelHub/PublicAiHub"; - useEffect(() => { - if (!key) { - return; - } - setAccessToken(key); - }, [key]); - - return ( - - ); +function PublicAiHubPage() { + const accessToken = useSearchParams()?.get("key") ?? null; + return ; } export default function PublicModelHubTable() { return ( - Loading...}> - + Loading...}> + ); } diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index cece15663ec..c3f31644c8a 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -61,7 +61,7 @@ const Navbar: React.FC = ({ return ( {!hideCommunityLinks && ( -
+
)} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx new file mode 100644 index 00000000000..1a35dbf0083 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ExternalLink } from "lucide-react"; + +import type { AgentCard } from "@/components/PublicModelHubTableColumns"; +import { Button } from "@/components/ui/button"; +import { Field, FieldDescription } from "@/components/ui/field"; +import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item"; + +import { agentCapabilities } from "./hubFormat"; +import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts"; +import { agentSnippets } from "./hubSnippets"; +import { InspectorHeader } from "./InspectorHeader"; + +const modes = (agent: AgentCard) => { + const input = (agent.defaultInputModes ?? []).join(", "); + const output = (agent.defaultOutputModes ?? []).join(", "); + return input || output ? `${input || "-"} / ${output || "-"}` : null; +}; + +export function AgentInspector({ agent }: { agent: AgentCard }) { + const capabilities = agentCapabilities(agent); + return ( +
+ + 0 && }, + ], + ]} + /> + + {agent.documentationUrl && ( + + )} + {(agent.skills ?? []).length > 0 && ( + + + {agent.skills.map((skill) => ( + + + {skill.name} + {skill.description} + + + + + + ))} + + + )} + + {agentSnippets(agent).map((snippet, index) => ( + + + Step {index + 1}: {snippet.title} + + + + ))} + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx b/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx new file mode 100644 index 00000000000..c25a1619845 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { CirclePlus, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@/components/ui/combobox"; +import { cn } from "@/lib/cva.config"; + +import { firstPlus } from "./hubFormat"; +import { ProviderAvatar } from "./HubParts"; + +interface FilterOption { + value: string; + label: string; + provider?: string; +} + +export function FilterChip({ + label, + noun, + options, + value, + onChange, +}: { + label: string; + noun: string; + options: FilterOption[]; + value: string[]; + onChange: (value: string[]) => void; +}) { + const byValue = new Map(options.map((option) => [option.value, option])); + const selected = value.map((v) => byValue.get(v)?.label ?? v); + const active = value.length > 0; + return ( + + + multiple + items={options} + value={options.filter((option) => value.includes(option.value))} + onValueChange={(next) => onChange(next.map((option) => option.value))} + itemToStringLabel={(option) => option.label} + isItemEqualToValue={(a, b) => a.value === b.value} + > + svg:last-child]:hidden", !active && "border-dashed text-muted-foreground")} + /> + } + > + {!active && } + {label} + {active && {firstPlus(selected)}} + + + + No {noun} found + + {(option: FilterOption) => ( + + {option.provider && } + {option.label} + + )} + + + + {active && ( + + )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx new file mode 100644 index 00000000000..eac852f2ca9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; + +import { SECTION_TITLE } from "./hubFormat"; + +const noResultsTitle = (noun: string, query: string, filtered: boolean) => { + if (!filtered) return `No ${noun} published yet`; + return query ? `No ${noun} match "${query}"` : `No ${noun} match these filters`; +}; + +export function NoResults({ + icon, + noun, + query, + onClear, +}: { + icon: React.ReactNode; + noun: string; + query: string; + onClear?: () => void; +}) { + return ( + + + {icon} + {noResultsTitle(noun, query, Boolean(onClear))} + + {onClear ? "Try a shorter search or fewer filters" : "Your admin publishes them from the AI Hub page"} + + + {onClear && ( + + + + )} + + ); +} + +export function HubNotEnabled() { + return ( + + + + + + This AI Hub isn't available + Ask your proxy admin to make it public from their Admin UI + + + ); +} + +export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) { + return ( + + + + + + Couldn't load {noun} + The gateway returned an error. Try again in a moment + + + + + + ); +} + +export function NotListed({ name }: { name: string }) { + return ( + + + + + + “{name}” isn't listed here + It may have been renamed or unpublished + + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx new file mode 100644 index 00000000000..ca8df489cfa --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldDescription } from "@/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; + +import { gatewayUrl, usefulLinkList } from "./hubFormat"; +import { CopyIconButton } from "./HubParts"; +import { StatusBadge } from "./HubStatus"; +import type { HubInfo } from "./useHubData"; + +export type HubStatus = "loading" | "operational" | "degraded" | "unavailable"; + +function LinksMenu({ info }: { info: HubInfo | undefined }) { + const links = usefulLinkList(info?.useful_links); + if (links.length === 0) return null; + return ( + + }> + Links + + + + {links.map((link) => ( + }> + + {link.title} + + ))} + {info?.litellm_version && ( + <> + + + LiteLLM v{info.litellm_version} + + + )} + + + ); +} + +function BaseUrlField() { + const url = gatewayUrl(); + return ( + + + + Base URL + + + + + + + + Use your LiteLLM key as the API key in any OpenAI-compatible SDK + + + ); +} + +export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) { + return ( +
+
+
+

+ + AI Hub +

+ {status === "operational" && } +
+

+ {info?.custom_docs_description || "Models, agents, MCP servers and skills you can call through this gateway"} +

+
+ {status !== "unavailable" && ( +
+ + +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx new file mode 100644 index 00000000000..63153c63c76 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx @@ -0,0 +1,234 @@ +"use client"; + +import * as React from "react"; + +import { Item, ItemContent, ItemGroup, ItemMedia } from "@/components/ui/item"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; + +import { NotListed } from "./HubEmpty"; +import { InSheet } from "./InspectorHeader"; +import { useOpenHref } from "./useHubUrlState"; +import { useIsNarrow } from "./useIsNarrow"; + +function ListSkeleton() { + return ( + + {[0, 1, 2].map((i) => ( +
+ + + + + + + + + +
+ ))} +
+ ); +} + +function DetailSkeleton() { + return ( +
+ +
+ {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ +
+ ); +} + +const isPlainClick = (event: React.MouseEvent) => { + const opensElsewhere = event.metaKey || event.ctrlKey || event.shiftKey; + return event.button === 0 && !opensElsewhere; +}; + +function Rows({ + rows, + activeKey, + explicit, + getKey, + onSelect, + renderItem, +}: { + rows: T[]; + activeKey: string | undefined; + explicit: boolean; + getKey: (row: T) => string; + onSelect: (key: string) => void; + renderItem: (row: T) => React.ReactNode; +}) { + const hrefFor = useOpenHref(); + const follow = (key: string) => (event: React.MouseEvent) => { + if (!isPlainClick(event)) return; + event.preventDefault(); + onSelect(key); + }; + return ( + + {rows.map((row) => { + const key = getKey(row); + const active = key === activeKey; + return ( +
+ + } + className={cn(active && (explicit ? "bg-muted" : "lg:bg-muted"))} + > + {renderItem(row)} + +
+ ); + })} +
+ ); +} + +const resolveSelection = ( + rows: T[], + selectedKey: string | null, + opened: T | undefined, + getKey: (row: T) => string, +) => { + const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey); + return { chosen, selected: selectedKey === null ? rows[0] : chosen }; +}; + +function useSheet( + chosen: T | undefined, + missingKey: string | undefined, + getKey: (row: T) => string, + renderDetail: (row: T) => React.ReactNode, +) { + const [content, setContent] = React.useState<{ row?: T; missingKey?: string }>({ row: chosen }); + if (chosen !== undefined && chosen !== content.row) setContent({ row: chosen }); + if (missingKey !== undefined && missingKey !== content.missingKey) setContent({ missingKey }); + const { row } = content; + return { + open: chosen !== undefined || missingKey !== undefined, + title: row ? getKey(row) : content.missingKey ?? "Details", + body: row ? renderDetail(row) : content.missingKey && , + }; +} + +export function HubMasterDetail({ + switcher, + toolbar, + rows, + loading, + selectedKey, + opened, + openPending = false, + getKey, + onSelect, + renderItem, + renderDetail, + empty, + footer, +}: { + switcher?: React.ReactNode; + toolbar: React.ReactNode; + rows: T[]; + loading: boolean; + selectedKey: string | null; + opened?: T; + openPending?: boolean; + getKey: (row: T) => string; + onSelect: (key: string | null) => void; + renderItem: (row: T) => React.ReactNode; + renderDetail: (row: T) => React.ReactNode; + empty: React.ReactNode; + footer?: React.ReactNode; +}) { + const narrow = useIsNarrow(); + const { chosen, selected } = resolveSelection(rows, selectedKey, opened, getKey); + const isEmpty = !loading && rows.length === 0; + const settled = !loading && !openPending && !isEmpty; + const missingKey = selectedKey !== null && chosen === undefined && settled ? selectedKey : undefined; + const sheet = useSheet(chosen, missingKey, getKey, renderDetail); + const listOnly = isEmpty && chosen === undefined && !openPending; + const detail = () => { + if (loading || (selected === undefined && openPending)) return ; + if (selected === undefined) return selectedKey && ; + return renderDetail(selected); + }; + const list = isEmpty ? ( + empty + ) : ( + <> + + {footer} + + ); + return ( +
+
+
+ {switcher} +
{toolbar}
+
+ {loading ? : list} +
+ {!listOnly && {detail()}} + onSelect(null)}> + {sheet.body} + +
+ ); +} + +function DetailSheet({ + open, + title, + onClose, + children, +}: { + open: boolean; + title: string; + onClose: () => void; + children: React.ReactNode; +}) { + const body = React.useRef(null); + return ( + !next && onClose()}> + + {title} +
+ {children} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx new file mode 100644 index 00000000000..e325bd42eed --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Copy, Search, X } from "lucide-react"; +import * as React from "react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { ItemTitle } from "@/components/ui/item"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +import { providerLogo, providerName, SECTION_TITLE } from "./hubFormat"; + +export function CopyIconButton({ value, label, className }: { value: string; label: string; className?: string }) { + return ( + + void copyToClipboard(value, "Copied")} + /> + } + > + + + {label} + + ); +} + +export function CodeBlock({ code, label = "Copy code" }: { code: string; label?: string }) { + return ( +
+ +
+        {code}
+      
+
+ ); +} + +export function ProviderAvatar({ provider, className }: { provider: string; className?: string }) { + const logo = providerLogo(provider); + return ( + + {logo && } + {providerName(provider).charAt(0)} + + ); +} + +export function ProviderName({ provider }: { provider: string }) { + return ( + + + {providerName(provider)} + + ); +} + +export function Badges({ items, mono }: { items: string[]; mono?: boolean }) { + if (items.length === 0) return null; + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ); +} + +export function PaneSection({ + title, + aside, + children, +}: { + title: string; + aside?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {aside} +
+ {children} +
+ ); +} + +export type Fact = { label: string; value: React.ReactNode; wide?: boolean }; + +const BLANK: React.ReactNode[] = [null, undefined, false, "", "-"]; + +const present = (value: React.ReactNode) => !BLANK.includes(value); + +export function Facts({ groups }: { groups: Fact[][] }) { + const shown = groups.map((group) => group.filter((fact) => present(fact.value))).filter((group) => group.length); + if (shown.length === 0) return null; + return ( +
+ {shown.map((group) => ( +
+ {group.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ ))} +
+ ); +} + +export function EndpointField({ id, value }: { id: string; value: string }) { + return ( + + Endpoint + + + + + + + + ); +} + +export function SearchInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( + + + + + onChange(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + /> + {value && ( + + onChange("")}> + + + + )} + + ); +} + +export function RowTitle({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function Joined({ parts }: { parts: (string | null | undefined)[] }) { + return parts.filter(Boolean).map((part, index) => ( + + {index > 0 && " · "} + {part} + + )); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx new file mode 100644 index 00000000000..cae5211f3ab --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { CircleCheck, CircleDashed, CircleX } from "lucide-react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Badge } from "@/components/ui/badge"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { cn } from "@/lib/cva.config"; + +import { healthOf, type HealthTone, relativeTime } from "./hubFormat"; + +const TONE_ICON = { success: CircleCheck, error: CircleX, neutral: CircleDashed } as const; +const TONE_CLASS = { + success: "border-success/25 bg-success/10 text-success", + error: "border-destructive/25 bg-destructive/10 text-destructive", + neutral: "text-muted-foreground", +} as const; + +export function StatusBadge({ tone, label }: { tone: HealthTone; label: string }) { + const Icon = TONE_ICON[tone]; + return ( + + + {label} + + ); +} + +export function HealthBadge({ model }: { model: ModelGroupInfo }) { + const badge = ; + if (!model.health_status) return badge; + return ( + + }> + {badge} + + +
+
Response time
+
+ {model.health_response_time ? `${Math.round(model.health_response_time)} ms` : "-"} +
+
Last checked
+
{relativeTime(model.health_checked_at)}
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx new file mode 100644 index 00000000000..4400b18ce1f --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { X } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { SheetClose } from "@/components/ui/sheet"; +import { cn } from "@/lib/cva.config"; + +import { SECTION_TITLE } from "./hubFormat"; +import { CopyIconButton } from "./HubParts"; + +export const InSheet = React.createContext(false); + +export function InspectorHeader({ + media, + title, + copyLabel, + status, + description, +}: { + media?: React.ReactNode; + title: string; + copyLabel: string; + status?: React.ReactNode; + description?: React.ReactNode; +}) { + const inSheet = React.useContext(InSheet); + return ( +
+
+ {media} +

{title}

+ + + {status && {status}} + {inSheet && ( + } + > + + + )} +
+ {status && {status}} + {description &&

{description}

} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx new file mode 100644 index 00000000000..73b6de1ed72 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { MCPServerData } from "@/components/PublicModelHubTableColumns"; + +import { mcpAuthLabel } from "./hubFormat"; +import { CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts"; +import { mcpEndpoint, mcpSnippet } from "./hubSnippets"; +import { InspectorHeader } from "./InspectorHeader"; + +export function McpInspector({ server }: { server: MCPServerData }) { + return ( +
+ + + + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx new file mode 100644 index 00000000000..87f22d81655 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { + formatPerMillion, + formatTokens, + modeLabel, + modelCapabilities, + providerName, + wildcardExample, +} from "./hubFormat"; +import { Badges, CodeBlock, type Fact, Facts, PaneSection, ProviderAvatar, ProviderName } from "./HubParts"; +import { curlSnippet, modelSnippet } from "./hubSnippets"; +import { HealthBadge } from "./HubStatus"; +import { InspectorHeader } from "./InspectorHeader"; + +const summary = (model: ModelGroupInfo): string | undefined => { + if (model.description) return model.description; + const providers = (model.providers ?? []).map(providerName).join(", "); + return model.mode && providers ? `${modeLabel(model.mode)} model from ${providers}` : undefined; +}; + +const tokens = (value: number | undefined) => (value ? `${formatTokens(value)} tokens` : null); + +const modelFacts = (model: ModelGroupInfo): Fact[][] => { + const providers = model.providers ?? []; + const tags = model.custom_tags ?? []; + return [ + [ + { label: "Mode", value: model.mode ? modeLabel(model.mode) : null }, + { label: "Context", value: tokens(model.max_input_tokens) }, + { label: "Max output", value: tokens(model.max_output_tokens) }, + ], + [ + { label: "Input / 1M tokens", value: formatPerMillion(model.input_cost_per_token) }, + { label: "Output / 1M tokens", value: formatPerMillion(model.output_cost_per_token) }, + ], + [ + { label: "Requests per minute", value: model.rpm }, + { label: "Tokens per minute", value: model.tpm ? formatTokens(model.tpm) : null }, + ], + [ + { + label: providers.length > 1 ? "Providers" : "Provider", + wide: true, + value: providers.length > 0 && ( + + {providers.map((provider) => ( + + ))} + + ), + }, + { label: "Tags", wide: true, value: tags.length > 0 && }, + ], + ]; +}; + +function CallThisModel({ model }: { model: ModelGroupInfo }) { + const curl = curlSnippet(model); + if (!curl) { + return ( + + + + ); + } + return ( + + + + Python + cURL + + + + + + + + + + ); +} + +function CustomMetadata({ model }: { model: ModelGroupInfo }) { + const metadata = Object.entries(model.custom_metadata ?? {}); + if (metadata.length === 0) return null; + return ( + +
+ {metadata.map(([key, value]) => ( + +
{key}
+
{String(value)}
+
+ ))} +
+
+ ); +} + +function SupportedParams({ params }: { params: string[] }) { + if (params.length === 0) return null; + return ( + + }> + Show {params.length} + + + } + > + + + + + + ); +} + +export function ModelInspector({ model }: { model: ModelGroupInfo }) { + const providers = model.providers ?? []; + const capabilities = modelCapabilities(model); + return ( +
+ } + title={model.model_group} + copyLabel="Copy model name" + status={} + description={summary(model)} + /> + {model.model_group.includes("*") && ( + + Wildcard model + + Call it as {wildcardExample(model.model_group)}, with any model name this + provider offers + + + )} + + {capabilities.length > 0 && ( + + + + )} + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx new file mode 100644 index 00000000000..1de09e554c1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Toggle } from "@/components/ui/toggle"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { displayMode } from "@/lib/decisionModels"; + +import { FilterChip } from "./FilterChip"; +import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat"; +import { HubMasterDetail } from "./HubMasterDetail"; +import { LoadFailed, NoResults } from "./HubEmpty"; +import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts"; +import { type HubData, useOpenedModel } from "./useHubData"; +import { ModelInspector } from "./ModelInspector"; + +const SORTS = [ + { value: "model_group", label: "Name", desc: false }, + { value: "providers", label: "Provider", desc: false }, + { value: "mode", label: "Mode", desc: false }, + { value: "input_cost_per_token", label: "Input price", desc: false }, + { value: "output_cost_per_token", label: "Output price", desc: false }, + { value: "max_input_tokens", label: "Context", desc: true }, + { value: "max_output_tokens", label: "Max output", desc: true }, + { value: "rpm", label: "Rate limit", desc: true }, +]; + +type Models = HubData["models"]; + +function ModelsPager({ models }: { models: Models }) { + const { pagination, rowCount, rows, onPaginationChange } = models; + if (!rowCount) return null; + const start = pagination.pageIndex * pagination.pageSize + 1; + const end = start + rows.length - 1; + const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex }); + return ( +
+ + {start}–{end} of {rowCount} + + + + + +
+ ); +} + +function SortControl({ models }: { models: Models }) { + const field = models.sorting[0]?.id ?? "model_group"; + const desc = models.sorting[0]?.desc ?? false; + const pick = (value: string | null) => { + const sort = SORTS.find((option) => option.value === value) ?? SORTS[0]; + models.onSortingChange([{ id: sort.value, desc: sort.desc }]); + }; + return ( + + + + models.onSortingChange([{ id: field, desc: pressed }])} + className="dark:bg-input/30 dark:hover:bg-input/50" + /> + } + > + {desc ? : } + + Sort descending + + + ); +} + +function ModelsToolbar({ hub }: { hub: HubData }) { + const { models, facets } = hub; + return ( + <> +
+ + +
+
+ ({ value: p, label: providerName(p), provider: p }))} + value={models.providerValues} + onChange={models.onProvidersChange} + /> + displayMode(m) ?? m))].map((m) => ({ + value: m, + label: modeLabel(m), + }))} + value={models.modeValues} + onChange={models.onModesChange} + /> + ({ value: f, label: featureFacetLabel(f) }))} + value={models.featureValues} + onChange={models.onFeaturesChange} + /> +
+ + ); +} + +const price = (cost: number | null | undefined, direction: "input" | "output") => { + if (cost === 0) return `Free ${direction}`; + return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null; +}; + +const prices = (model: ModelGroupInfo) => + model.input_cost_per_token === 0 && model.output_cost_per_token === 0 + ? ["Free", null] + : [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")]; + +function ModelRow({ model }: { model: ModelGroupInfo }) { + const [input, output] = prices(model); + const priced = Boolean(input || output); + return ( + <> + + + + + {model.model_group} + + + + {priced && ( + + + + )} + + {priced && ( + + {input && {input}} + {output && {output}} + + )} + + ); +} + +export function ModelsPane({ + hub, + selected, + onSelect, +}: { + hub: HubData; + selected: string | null; + onSelect: (key: string | null) => void; +}) { + const { models } = hub; + const opened = useOpenedModel(selected, models.rows, !models.isLoading); + const clear = () => { + models.onSearchChange(""); + models.onColumnFiltersChange([]); + }; + return ( + model.model_group} + onSelect={onSelect} + empty={ + models.error ? ( + + ) : ( + } + noun="models" + query={models.searchValue} + onClear={models.hasActiveQuery ? clear : undefined} + /> + ) + } + toolbar={} + renderItem={(model) => } + renderDetail={(model) => } + footer={} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx new file mode 100644 index 00000000000..bcddc1fe364 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -0,0 +1,342 @@ +import { fireEvent, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; +import PublicAiHub from "./PublicAiHub"; + +const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ + apiGetMock: vi.fn(), + realGet: { current: (..._args: unknown[]): Promise => Promise.reject(new Error("unset")) }, + navigation: { params: new URLSearchParams() }, + uiSettings: { values: {} as Record }, + replaceMock: vi.fn(), + hubInfo: { + docs_title: "", + custom_docs_description: null, + litellm_version: "1.90.0", + useful_links: { "Getting started": { url: "https://docs.example.com", index: 0 } }, + }, +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => navigation.params, + usePathname: () => "/model_hub_table", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +vi.mock("@/components/navbar", () => ({ default: () =>
- {aggregatedFailed && ( )} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx index c89652bb1c5..880f2b56b46 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx +++ b/ui/litellm-dashboard/src/components/CommandPalette/CommandPaletteTrigger.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/cva.config"; import { Search } from "lucide-react"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; @@ -9,7 +10,7 @@ import { paletteScopeForRoute } from "./utils"; import { useCommandPalette } from "./CommandPaletteProvider"; import { useShortcutLabel } from "./useShortcutLabel"; -export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) { +export function CommandPaletteTrigger({ mobile = false, className }: { mobile?: boolean; className?: string }) { const pathname = usePathname(); const { open, setOpen } = useCommandPalette(); const shortcut = useShortcutLabel(); @@ -21,7 +22,7 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean }) ); } diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx index d1a6e9d1cb4..993277dc972 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx @@ -6,7 +6,11 @@ import { PluginModeProvider } from "@/contexts/PluginModeContext"; import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; import { DashboardHeader } from "./DashboardHeader"; -vi.mock("next/navigation", () => ({ usePathname: () => "/ui/logs" })); +vi.mock("next/navigation", () => ({ + usePathname: () => "/ui/logs", + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); beforeEach(() => { localStorage.clear(); @@ -52,7 +56,7 @@ afterEach(() => { localStorage.clear(); }); -async function openTools() { +function renderHeader() { const user = userEvent.setup(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( @@ -64,49 +68,53 @@ async function openTools() { , ); - await user.click(screen.getByRole("button", { name: "More options" })); - const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); - return { user, tools }; + return { user }; } -describe("mobile header nested controls", () => { - it.each(["mouse", "touch"] as const)( - "selects a portalled gateway item with %s without dismissing More", - async (pointer) => { - const { user, tools } = await openTools(); - await user.click(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" })); - const item = await screen.findByRole("menuitem", { name: "Metrics" }); - if (pointer === "touch") { - await user.pointer([ - { keys: "[TouchA>]", target: item }, - { keys: "[/TouchA]", target: item }, - ]); - } else { - await user.click(item); - } - expect(within(tools).getByRole("button", { name: "Switch workspace, current: Metrics" })).toBeVisible(); - expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); - }, - ); - - it("lets a nested blog link receive the click before closing its menu", async () => { - const { user, tools } = await openTools(); - await user.click(within(tools).getByRole("button", { name: "Blog" })); - const link = await screen.findByRole("link", { name: /Gateway update/ }); - expect(link).toHaveAttribute("href", "https://example.com/update"); - await user.click(link); - expect(screen.queryByRole("menu", { name: "Blog" })).not.toBeInTheDocument(); - expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); +describe("dashboard header controls", () => { + it("opens search from both header controls", async () => { + const { user } = renderHeader(); + await user.click(screen.getByRole("button", { name: "Search or jump to…" })); + expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible(); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Search", exact: true })); + expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeVisible(); }); - it("allows filtering and selecting a worker from the nested combobox", async () => { + it("offers docs, community and latest posts under the bell", async () => { + const { user } = renderHeader(); + await user.click(screen.getByRole("button", { name: /Notifications/ })); + const tools = await screen.findByRole("dialog", { name: "What's new" }); + expect(await within(tools).findByRole("link", { name: /Gateway update/ })).toHaveAttribute( + "href", + "https://example.com/update", + ); + expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/", + ); + expect(within(tools).getByRole("link", { name: "Discord", exact: true })).toBeVisible(); + }); + + it("respects the saved blog and community visibility settings", async () => { + localStorage.setItem("disableBlogPosts", "true"); + localStorage.setItem("disableShowPrompts", "true"); + const { user } = renderHeader(); + expect(screen.queryByRole("link", { name: "Join Slack" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /Notifications/ })); + const tools = await screen.findByRole("dialog", { name: "What's new" }); + expect(within(tools).getByRole("link", { name: "Docs", exact: true })).toBeVisible(); + expect(within(tools).queryByRole("link", { name: "Blog", exact: true })).not.toBeInTheDocument(); + expect(within(tools).queryByRole("link", { name: "Discord", exact: true })).not.toBeInTheDocument(); + }); + + it("allows filtering and selecting a worker", async () => { localStorage.setItem("litellm_selected_worker_id", "primary"); - const { user, tools } = await openTools(); - const worker = await within(tools).findByRole("combobox", { name: "Worker" }); + const { user } = renderHeader(); + const worker = await screen.findByRole("combobox", { name: "Worker" }); await user.clear(worker); await user.type(worker, "Backup"); await user.click(await screen.findByRole("option", { name: "Backup worker" })); expect(localStorage.getItem("litellm_selected_worker_id")).toBeNull(); - expect(screen.getByRole("dialog", { name: "Gateway tools" })).toBeVisible(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 202a111ea97..acccc088e5c 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { DashboardHeader } from "./DashboardHeader"; -import { NAV_PRODUCT_LINK_CLASS } from "@/components/Navbar/navProductLinkClass"; import { CommandPaletteProvider } from "@/components/CommandPalette/CommandPaletteProvider"; const { mockUsePluginMode, mockUseUISettings, state } = vi.hoisted(() => { @@ -66,87 +65,15 @@ describe("DashboardHeader breadcrumb", () => { expect(screen.getByText("Home")).toBeInTheDocument(); }); - it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => { - state.enableChatUI = true; + it("names the Aria workspace in the header", () => { + state.pathname = "/ui/aria"; renderDashboardHeader(); - - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.queryByText("Observability")).not.toBeInTheDocument(); - - const selector = screen.getByRole("button", { name: /AI Gateway/i }); - act(() => { - fireEvent.click(selector); - }); - expect(await screen.findByText("Chat")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Aria Agent" })).toBeInTheDocument(); }); - it("keeps the AI Gateway selector at the root even when there is nothing to switch to (discovery)", () => { + it("keeps the scoped key search available", () => { + state.pathname = "/ui/api-keys"; renderDashboardHeader(); - - expect(screen.getByRole("button", { name: /AI Gateway/i })).toBeInTheDocument(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.queryByText("Observability")).not.toBeInTheDocument(); - }); - - it("styles Docs with the shared product-link class instead of a muted toolbar button", () => { - renderDashboardHeader(); - - const docs = screen.getByRole("link", { name: "Docs" }); - for (const cls of NAV_PRODUCT_LINK_CLASS.trim().split(/\s+/)) { - expect(docs).toHaveClass(cls); - } - expect(docs).not.toHaveClass("text-muted-foreground"); - }); - - it("renders the tools divider centered rather than stretched to the top of the row", () => { - const { container } = renderDashboardHeader(); - - const separators = container.querySelectorAll('[data-slot="separator"][data-orientation="vertical"]'); - expect(separators).toHaveLength(1); - expect(separators[0].className).not.toMatch(/self-stretch/); - expect(separators[0].className).toContain("data-vertical:self-center"); - }); - - it("should place Aria Agent in the header tools ahead of Docs", () => { - renderDashboardHeader(); - - const liteAdmin = within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" }); - expect(liteAdmin.compareDocumentPosition(screen.getByRole("link", { name: "Docs" }))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); - }); - - it("should keep the gateway selector and tools available from the compact header menu", async () => { - renderDashboardHeader(); - act(() => { - fireEvent.click(screen.getByRole("button", { name: "More options" })); - }); - const tools = await screen.findByRole("dialog", { name: "Gateway tools" }); - expect(within(tools).getByRole("button", { name: "Switch workspace, current: AI Gateway" })).toBeInTheDocument(); - expect(within(tools).getByRole("link", { name: "Docs" })).toBeInTheDocument(); - expect(within(screen.getByRole("banner")).getByRole("button", { name: "Aria Agent" })).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: "Aria Agent" })).toHaveLength(1); - }); - - it("closes mobile tools when switching to desktop and keeps them closed when returning", async () => { - const { rerender } = renderDashboardHeader(); - fireEvent.click(screen.getByRole("button", { name: "More options" })); - expect(await screen.findByRole("dialog", { name: "Gateway tools" })).toBeInTheDocument(); - - state.isDesktop = true; - rerender( - - - , - ); - expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); - - state.isDesktop = false; - rerender( - - - , - ); - expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Search keys…" })).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx index d9bed74c577..044f677cb28 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -1,55 +1,33 @@ "use client"; -import { useTerminology } from "@/contexts/TerminologyContext"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; -import { getBreadcrumb } from "@/components/leftnav"; -import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; -import { DocsLink } from "@/components/Navbar/DocsLink/DocsLink"; -import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; -import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; -import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; -import LiteAdmin from "@/components/liteadmin/LiteAdmin"; +import type { ReactNode } from "react"; import ThemeToggle from "@/components/ThemeToggle/ThemeToggle"; +import { usePathname } from "next/navigation"; +import { useTerminology } from "@/contexts/TerminologyContext"; +import { getBreadcrumb } from "@/components/leftnav"; +import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger"; +import { DashboardCommunityLinks } from "@/components/Navbar/DashboardResources"; +import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; import { useWorker } from "@/hooks/useWorker"; -import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl, getLoginUrl } from "@/utils/returnUrlUtils"; -import { usePathname } from "next/navigation"; -import { useState, type ReactNode } from "react"; -import { useMediaQuery } from "usehooks-ts"; -import { Ellipsis } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; -import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPaletteTrigger"; -// Top bar for the dashboard shell. Sits only over the content column (the brand -// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. export function DashboardHeader({ accessToken, navigationTrigger, deploymentHealth, + deploymentHealthLink, }: { accessToken: string | null; navigationTrigger?: ReactNode; deploymentHealth?: ReactNode; + deploymentHealthLink?: ReactNode; }) { const term = useTerminology(); const { title } = getBreadcrumb(usePathname()); - const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false }); - const [mobileToolsOpen, setMobileToolsOpen] = useState(false); - if (isDesktop && mobileToolsOpen) setMobileToolsOpen(false); const { isControlPlane, selectedWorker } = useWorker(); - const showWorkerSwitch = isControlPlane && selectedWorker !== null; - const hideCommunityLinks = useDisableShowPrompts(); - + const showWorkerSelector = isControlPlane && selectedWorker !== null; const handleWorkerSwitch = (workerId: string) => { clearTokenCookies(); clearStoredReturnUrl(); @@ -57,67 +35,34 @@ export function DashboardHeader({ localStorage.removeItem("litellm_worker_url"); window.location.href = `${getLoginUrl()}?worker=${encodeURIComponent(workerId)}`; }; - return ( -
- {navigationTrigger} - - - - - - - - {term(title)} - - - - - - -
- -
- {showWorkerSwitch && ( +
+
+
+ {navigationTrigger} +

{term(title)}

+
+ +
+ + {showWorkerSelector && ( <> - - +
+ +
+
+ +
)} - - - {!hideCommunityLinks && } - + {deploymentHealth} - -
-
- - {deploymentHealth} - - - } - > - - - - Gateway tools - - {showWorkerSwitch && } -
- - - - {!hideCommunityLinks && } -
-
-
+
); } - -export default DashboardHeader; diff --git a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx index 87c34165fd9..d2d2a7ec914 100644 --- a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.integration.test.tsx @@ -1,9 +1,15 @@ +import { NotificationsBellView } from "@/components/Navbar/NotificationsBell/NotificationsBell"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useState } from "react"; import userEvent from "@testing-library/user-event"; import type { DeploymentWarning } from "@/app/(dashboard)/hooks/deploymentWarnings/useDeploymentWarnings"; import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils"; -import { createDeploymentHealthHandle, DeploymentHealthBanner, DeploymentHealthIcon } from "./DeploymentHealth"; +import { + createDeploymentHealthHandle, + DeploymentHealthBanner, + DeploymentHealthIcon, + DeploymentHealthLink, +} from "./DeploymentHealth"; import { DeploymentHealthPanel } from "./DeploymentHealthPanel"; vi.unmock("@/app/(dashboard)/hooks/useAuthorized"); @@ -84,6 +90,11 @@ const Harness = () => { <>
+ } + />
@@ -97,7 +108,13 @@ const renderHealth = (userRole = "proxy_admin") => { }; const openPanel = async (user: ReturnType) => { - await user.click(await screen.findByRole("button", { name: /^Deployment health/ })); + await waitFor(() => expect(proxy.gets).toBeGreaterThan(0)); + const icon = screen.queryByRole("button", { name: /^Deployment health/ }); + if (icon) await user.click(icon); + else { + await user.click(screen.getByRole("button", { name: /^Notifications/ })); + await user.click(await screen.findByRole("button", { name: "Deployment health", exact: true })); + } return screen.findByRole("dialog", { name: "Deployment health" }); }; @@ -124,15 +141,17 @@ afterEach(() => { }); describe("deployment health icon", () => { - it("should show a plain shield with no count when every warning is ignored", async () => { - proxy.warnings = [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }]; - renderHealth(); + it.each([{ warnings: [] }, { warnings: [{ ...NO_REDIS, ignore: snoozedBy("admin-2", "2026-10-21T17:04:12Z") }] }])( + "hides the shield without active warnings: $warnings", + async ({ warnings }) => { + proxy.warnings = warnings; + renderHealth(); - await waitFor(() => expect(proxy.gets).toBe(1)); - const icon = await screen.findByRole("button", { name: "Deployment health" }); - expect(icon).toHaveTextContent(/^$/); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); + await waitFor(() => expect(proxy.gets).toBe(1)); + expect(screen.queryByRole("button", { name: /^Deployment health/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }, + ); it.each([ { diff --git a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx index edfe4a06494..2a16a442bb0 100644 --- a/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx +++ b/ui/litellm-dashboard/src/components/DeploymentHealth/DeploymentHealth.tsx @@ -28,6 +28,7 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl if (!canView) return null; const active = activeWarnings(data?.warnings ?? []); + if (active.length === 0) return null; const hasCritical = active.some((warning) => warning.severity === "critical"); return ( @@ -50,6 +51,26 @@ export const DeploymentHealthIcon = ({ handle }: { handle: DeploymentHealthHandl ); }; +export const DeploymentHealthLink = ({ handle }: { handle: DeploymentHealthHandle }) => { + const { canView } = useDeploymentHealthAccess(); + if (!canView) return null; + return ( + + } + > + + Deployment health + + ); +}; + export const DeploymentHealthBanner = ({ handle }: { handle: DeploymentHealthHandle }) => { const { canView } = useDeploymentHealthAccess(); const { data } = useDeploymentWarnings(); diff --git a/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx new file mode 100644 index 00000000000..d615b496f2b --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/DashboardResources.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { ReactNode } from "react"; +import { BookOpen, Github, Newspaper, ScrollText, Slack } from "lucide-react"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { useBlogPosts } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; + +function DiscordIcon() { + return ( + + ); +} + +const COMMUNITY = [ + { href: "https://www.litellm.ai/support", label: "Slack", accessibleLabel: "Join Slack", Icon: Slack }, + { href: "https://discord.gg/wuPM9dRgDw", label: "Discord", accessibleLabel: "Join Discord", Icon: DiscordIcon }, + { href: "https://github.com/BerriAI/litellm", label: "GitHub", accessibleLabel: "LiteLLM on GitHub", Icon: Github }, +]; +const DOCS = [ + { href: "https://docs.litellm.ai/docs/", label: "Docs", Icon: BookOpen }, + { href: "https://docs.litellm.ai/release_notes", label: "Releases", Icon: ScrollText }, +]; +const BLOG = { href: "https://docs.litellm.ai/blog", label: "Blog", Icon: Newspaper }; + +export function DashboardCommunityLinks({ className }: { className?: string }) { + const hidden = useDisableShowPrompts(); + if (hidden) return null; + return ( + + ); +} + +export function DashboardResources({ children }: { children?: ReactNode }) { + const hideBlog = useDisableBlogPosts(); + const hideCommunity = useDisableShowPrompts(); + const { data, isPending, isError, refetch } = useBlogPosts(); + const links = [...DOCS, ...(hideBlog ? [] : [BLOG]), ...(hideCommunity ? [] : COMMUNITY)]; + return ( + <> + {!hideBlog && ( +
+ {isPending &&

Loading posts…

} + {isError && ( + + )} + {data?.posts.slice(0, 3).map((post) => ( + + {post.title} + + + ))} +
+ )} +
+ + {children} +
+ + ); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index 4cb9f851153..409a93edfcb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -12,11 +12,12 @@ import { } from "@/app/(dashboard)/hooks/useAnnouncementRead"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { DashboardResources } from "@/components/Navbar/DashboardResources"; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/cva.config"; import { isNewerVersion } from "@/utils/versionUtils"; -import { Bell } from "lucide-react"; +import { ArrowUpRight, Bell } from "lucide-react"; import React, { Fragment } from "react"; export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing"; @@ -68,18 +69,27 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme return (

{announcement.title}

-

{announcement.description}

-
+

{announcement.description}

+
{announcement.linkLabel} + {read ? null : ( - )} @@ -89,11 +99,16 @@ const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announceme }; interface NotificationsBellViewProps { + deploymentHealthLink?: React.ReactNode; currentVersion: string | null | undefined; latestRelease: LatestReleaseInfo | null | undefined; } -export const NotificationsBellView: React.FC = ({ currentVersion, latestRelease }) => { +export const NotificationsBellView: React.FC = ({ + currentVersion, + latestRelease, + deploymentHealthLink, +}) => { const upgrade = upgradeAnnouncement(currentVersion, latestRelease); const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT]; const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey)); @@ -101,7 +116,7 @@ export const NotificationsBellView: React.FC = ({ cu return ( } aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"} > @@ -109,20 +124,39 @@ export const NotificationsBellView: React.FC = ({ cu {unreadCount > 0 ? : null} - - {announcements.map((announcement, index) => ( - - {index > 0 ? : null} - - - ))} + + What's new +
+
+ {announcements.map((announcement, index) => ( + + {index > 0 ? : null} + + + ))} +
+ {deploymentHealthLink} +
); }; -export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => { +export const NotificationsBell: React.FC<{ accessToken: string | null; deploymentHealthLink?: React.ReactNode }> = ({ + accessToken, + deploymentHealthLink, +}) => { const { data: healthData } = useHealthReadinessDetails(accessToken); const { data: latestRelease } = useLatestReleaseInfo(accessToken); - return ; + return ( + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx index 8786d7df631..64bb8f15585 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx @@ -162,7 +162,7 @@ describe("ViewSwitcher", () => { ); it.each(["/ui/moyai", "/ui/moyai/sessions/one"])( - "should show Moyai's icon at %s and let users switch to Chat", + "should identify Moyai at %s and let users switch to Chat", async (pathname) => { state.pathname = pathname; getUiSettingsMock.mockResolvedValue({ values: { enable_chat_ui: true } }); @@ -170,7 +170,7 @@ describe("ViewSwitcher", () => { renderSwitcher(); const trigger = screen.getByRole("button", { name: "Switch workspace, current: Moyai" }); - expect(within(trigger).getByAltText("")).toHaveAttribute("src", expect.stringContaining("moyai-head.svg")); + expect(trigger).toHaveTextContent("Moyai"); await user.click(trigger); await user.click(await screen.findByRole("menuitem", { name: "Chat Chat UI for using LLMs, MCPs, Agents" })); expect(assignSpy).toHaveBeenCalledWith("/ui/chat"); diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index 8469a8edc35..aa860226e3b 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -1,6 +1,5 @@ import { useTerminology } from "@/contexts/TerminologyContext"; import React from "react"; -import Image from "next/image"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -9,17 +8,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Check, ChevronsUpDown, LayoutGrid, MessageCircle } from "lucide-react"; +import { Check, ChevronsUpDown } from "lucide-react"; import { usePluginMode } from "@/contexts/PluginModeContext"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { uiHref } from "@/utils/uiHref"; import { cn } from "@/lib/cva.config"; -import moyaiHead from "../../../public/assets/moyai/moyai-head.svg"; const GATEWAY = "ai-gateway"; const CHAT = "chat"; const MOYAI = "moyai"; -type WorkspaceIconName = typeof GATEWAY | typeof CHAT | typeof MOYAI; function isWorkspaceRoute(pathname: string, workspace: string): boolean { const routes = [uiHref(workspace)]; @@ -27,45 +24,15 @@ function isWorkspaceRoute(pathname: string, workspace: string): boolean { return routes.some((route) => pathname === route || pathname.startsWith(`${route}/`)); } -function activeWorkspaceFor( +function activeWorkspaceLabel( isChatRoute: boolean, isMoyaiRoute: boolean, pluginLabel: string | undefined, gatewayLabel: string, -): { label: string; icon: WorkspaceIconName } { - if (isChatRoute) return { label: "Chat", icon: CHAT }; - if (isMoyaiRoute) return { label: "Moyai", icon: MOYAI }; - return { label: pluginLabel ?? gatewayLabel, icon: GATEWAY }; -} - -function WorkspaceIcon({ workspace }: { workspace: WorkspaceIconName }) { - if (workspace === CHAT) { - return ( - - ); - } - if (workspace === MOYAI) { - return ( - - ); - } - return ( - - ); +): string { + if (isChatRoute) return "Chat"; + if (isMoyaiRoute) return "Moyai"; + return pluginLabel ?? gatewayLabel; } interface ViewSwitcherItem { @@ -95,7 +62,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch const isStandaloneRoute = isChatRoute || isMoyaiRoute; const gatewayLabel = term("AI Gateway"); - const { label: activeLabel, icon: activeIcon } = activeWorkspaceFor( + const activeLabel = activeWorkspaceLabel( isChatRoute, isMoyaiRoute, plugins.find((p) => p.name === mode)?.display_name, @@ -119,17 +86,14 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch disabled: !chatEnabled, label: (
- - - - Chat{" "} - Chat UI for using LLMs, MCPs, Agents{" "} - {!chatEnabled && ( - - Admins can enable in Settings - - )} - + + Chat{" "} + Chat UI for using LLMs, MCPs, Agents{" "} + {!chatEnabled && ( + + Admins can enable in Settings + + )} {isChatRoute && }
@@ -142,10 +106,7 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch key: e.key, label: (
- - - {e.label} - + {e.label} {!isStandaloneRoute && e.key === mode && }
), @@ -156,12 +117,9 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch key: MOYAI, label: (
- - - - Moyai{" "} - Cloud Coding Agent - + + Moyai{" "} + Cloud Coding Agent {isMoyaiRoute && }
@@ -179,11 +137,10 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch type="button" variant="outline" size="sm" - className={cn("max-w-[220px] gap-1.5 pl-1.5 pr-2", className)} + className={cn("max-w-[220px] gap-1.5 px-3 dark:bg-transparent", className)} /> } > - {activeLabel} 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 ba21aae488c..6b30e1292a2 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.test.tsx @@ -163,4 +163,22 @@ describe("WorkerDropdown", () => { }); expect(screen.getByText("Worker 3")).toBeInTheDocument(); }); + + it("lets users search and switch workers from the compact picker", async () => { + mockUseWorker.mockReturnValue({ + isControlPlane: true, + selectedWorker: workers[1], + workers, + }); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole("combobox", { name: "Switch worker, current: Worker 2" })); + expect(await screen.findByRole("option", { name: "Worker 2" })).toHaveAttribute("aria-disabled", "true"); + await user.type(screen.getByRole("combobox", { name: "Search workers" }), "Worker 3"); + expect(screen.queryByRole("option", { name: "Worker 1" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("option", { name: "Worker 3" })); + + expect(mockOnWorkerSwitch).toHaveBeenCalledExactlyOnceWith("w3"); + }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx index 432bab8c9ef..2d2393cf7bb 100644 --- a/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Server } from "lucide-react"; +import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; +import { Button } from "@/components/ui/button"; import { Combobox, ComboboxContent, @@ -15,6 +17,7 @@ import { useWorker } from "@/hooks/useWorker"; interface WorkerDropdownProps { onWorkerSwitch: (workerId: string) => void; + compact?: boolean; } interface WorkerOption { @@ -23,7 +26,7 @@ interface WorkerOption { disabled: boolean; } -const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { +const WorkerDropdown: React.FC = ({ onWorkerSwitch, compact = false }) => { const { isControlPlane, selectedWorker, workers } = useWorker(); if (!isControlPlane || !selectedWorker) return null; @@ -45,12 +48,23 @@ const WorkerDropdown: React.FC = ({ onWorkerSwitch }) => { } }} > - - - - - - + {compact ? ( + } + aria-label={`Switch worker, current: ${selectedWorker.name}`} + title={selectedWorker.name} + > + + + ) : ( + + + + + + )} + + {compact && } No matching workers {(option: WorkerOption) => ( diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index f3bff85e2d5..1c44d4dae6d 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import { TerminologyProvider } from "@/contexts/TerminologyContext"; @@ -647,6 +648,13 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: /Projects/ })).toHaveAttribute("href", "/ui/projects"); }); + it.each([false, true])("keeps workspace and Chat discovery available with collapsed=%s", async (collapsed) => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: "Switch workspace, current: AI Gateway" })); + expect(await screen.findByRole("menuitem", { name: /^Chat / })).toBeVisible(); + }); + it("marks the nav item for the current route active", () => { navState.pathname = "/ui/logs"; renderWithProviders(); @@ -660,6 +668,12 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true"); }); + it("does not highlight Home when the Aria workspace is active", () => { + navState.pathname = "/ui/aria"; + renderWithProviders(); + expect(screen.getByRole("link", { name: "Home" })).not.toHaveAttribute("data-active"); + }); + it("expands the parent group of the current nested route and marks the child active", () => { navState.pathname = "/ui/search-tools"; renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 2cf6f8219db..488113d726e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -67,6 +67,8 @@ import { Workflow, } from "lucide-react"; import Link from "next/link"; +import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import { AriaSidebarLink } from "@/components/liteadmin/LiteAdmin"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; import { cn } from "@/lib/cva.config"; @@ -518,6 +520,7 @@ export const labelText = (item: MenuItem): string => (typeof item.label === "str // Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => { const route = routeForPathname(pathname); + if (route === "aria") return { section: null, title: "Aria Agent" }; for (const group of menuGroups) { for (const item of group.items) { const section = sectionText(group.groupLabel); @@ -552,7 +555,7 @@ const Sidebar_: React.FC = ({ const baseUrl = getProxyBaseUrl(); const version = healthData?.litellm_version; const currentRoute = routeForPathname(usePathname()); - const selectedKey = findMenuItemKey(currentRoute); + const selectedKey = currentRoute === "aria" ? null : findMenuItemKey(currentRoute); const [openGroups, setOpenGroups] = useState>(() => { const parent = findParentKey(currentRoute); @@ -730,6 +733,10 @@ const Sidebar_: React.FC = ({
+
+ + +