diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index cad2874c1e60..32e9a03da95e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -520,9 +520,6 @@ }, "react-hooks/set-state-in-effect": { "count": 1 - }, - "unused-imports/no-unused-imports": { - "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { @@ -1333,16 +1330,6 @@ "count": 1 } }, - "src/components/AIHub/AgentHubTableColumns.test.tsx": { - "unused-imports/no-unused-imports": { - "count": 1 - } - }, - "src/components/AIHub/AgentHubTableColumns.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 @@ -1356,11 +1343,6 @@ "count": 1 } }, - "src/components/AIHub/SkillHubDashboard.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/AIHub/UsefulLinksManagement.tsx": { "no-restricted-imports": { "count": 1 @@ -1885,11 +1867,6 @@ "count": 1 } }, - "src/components/mcp_hub_table_columns.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/mcp_server_management/MCPToolPermissions.tsx": { "no-restricted-imports": { "count": 1 @@ -1982,11 +1959,6 @@ "count": 1 } }, - "src/components/model_hub_table_columns.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/model_info_view.tsx": { "no-nested-ternary": { "count": 14 @@ -2119,9 +2091,6 @@ } }, "src/components/public_model_hub.tsx": { - "no-nested-ternary": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -2172,11 +2141,6 @@ "count": 1 } }, - "src/components/skill_hub_table_columns.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/team/EditMembership.tsx": { "no-nested-ternary": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx new file mode 100644 index 000000000000..b29320cb5357 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx @@ -0,0 +1,110 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DataTable } from "@/components/shared/DataTable"; +import { MCPToolset } from "@/components/mcp_tools/types"; +import { getMCPToolsetTableColumns } from "./MCPToolsetTableColumns"; + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://localhost:4000", +})); + +const mockToolset: MCPToolset = { + toolset_id: "ts-1", + toolset_name: "github-tools", + description: "GitHub helpers", + tools: [ + { server_id: "srv-1", tool_name: "create_issue" }, + { server_id: "srv-1", tool_name: "list_issues" }, + { server_id: "srv-2", tool_name: "search" }, + { server_id: "srv-2", tool_name: "fetch" }, + { server_id: "srv-2", tool_name: "crawl" }, + ], + created_at: "2026-01-01T00:00:00Z", +}; + +const serverPrefixById = new Map([ + ["srv-1", "github"], + ["srv-2", "exa"], +]); + +function renderTable({ isAdmin = true, onEditClick = vi.fn(), onDeleteClick = vi.fn() } = {}) { + const deps = { isAdmin, serverPrefixById, onEditClick, onDeleteClick }; + render( + toolset.toolset_id} + sortingMode="client" + size="compact" + />, + ); + return { onEditClick, onDeleteClick }; +} + +describe("getMCPToolsetTableColumns", () => { + it("renders the toolset with its endpoint url as subtitle", () => { + renderTable(); + expect(screen.getByText("github-tools")).toBeInTheDocument(); + expect(screen.getByText("http://localhost:4000/toolset/github-tools/mcp")).toBeInTheDocument(); + }); + + it("renders server-prefixed tool chips capped at four with an overflow count", () => { + renderTable(); + expect(screen.getByText("github-create_issue")).toBeInTheDocument(); + expect(screen.getByText("github-list_issues")).toBeInTheDocument(); + expect(screen.getByText("exa-search")).toBeInTheDocument(); + expect(screen.getByText("exa-fetch")).toBeInTheDocument(); + expect(screen.queryByText("exa-crawl")).not.toBeInTheDocument(); + expect(screen.getByText("+1 more")).toBeInTheDocument(); + }); + + it("opens the edit modal when an admin clicks the toolset name", async () => { + const user = userEvent.setup(); + const { onEditClick } = renderTable(); + await user.click(screen.getByRole("button", { name: /github-tools/ })); + expect(onEditClick).toHaveBeenCalledWith(mockToolset); + }); + + it("does not make the name clickable for non-admins", () => { + renderTable({ isAdmin: false }); + expect(screen.queryByRole("button", { name: /github-tools/ })).not.toBeInTheDocument(); + }); + + it("copies the endpoint url and toolset id from the actions menu", async () => { + const user = userEvent.setup(); + renderTable({ isAdmin: false }); + + await user.click(screen.getByTestId("toolset-actions-ts-1")); + await user.click(await screen.findByTestId("toolset-action-copy-url")); + expect(await window.navigator.clipboard.readText()).toBe("http://localhost:4000/toolset/github-tools/mcp"); + + await user.click(screen.getByTestId("toolset-actions-ts-1")); + await user.click(await screen.findByTestId("toolset-action-copy-id")); + expect(await window.navigator.clipboard.readText()).toBe("ts-1"); + }); + + it("edits and deletes through the actions menu as admin", async () => { + const user = userEvent.setup(); + const { onEditClick, onDeleteClick } = renderTable(); + + await user.click(screen.getByTestId("toolset-actions-ts-1")); + await user.click(await screen.findByTestId("toolset-action-edit")); + expect(onEditClick).toHaveBeenCalledWith(mockToolset); + + await user.click(screen.getByTestId("toolset-actions-ts-1")); + await user.click(await screen.findByTestId("toolset-action-delete")); + expect(onDeleteClick).toHaveBeenCalledWith("ts-1"); + }); + + it("hides edit and delete from non-admins but keeps the copy actions", async () => { + const user = userEvent.setup(); + renderTable({ isAdmin: false }); + + await user.click(screen.getByTestId("toolset-actions-ts-1")); + expect(await screen.findByTestId("toolset-action-copy-url")).toBeInTheDocument(); + expect(screen.getByTestId("toolset-action-copy-id")).toBeInTheDocument(); + expect(screen.queryByTestId("toolset-action-edit")).not.toBeInTheDocument(); + expect(screen.queryByTestId("toolset-action-delete")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx new file mode 100644 index 000000000000..525700d3c492 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, Link2, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { getProxyBaseUrl } from "@/components/networking"; +import { MCPToolset } from "@/components/mcp_tools/types"; +import { copyToClipboard } from "@/utils/dataUtils"; + +// Display-only. Toolsets persist {server_id, bare tool_name}; the gateway serves +// each tool prefixed as "{server-prefix}-{tool}". Render that qualified form so +// the same tool name on different servers stays distinguishable. This mirrors the +// backend default MCP_TOOL_PREFIX_SEPARATOR; overriding that env var only changes +// this cosmetic label, never what is stored or how tools are matched. +const MCP_TOOL_PREFIX_SEPARATOR = "-"; + +export function displayToolName(serverPrefix: string | undefined, toolName: string): string { + return serverPrefix ? `${serverPrefix}${MCP_TOOL_PREFIX_SEPARATOR}${toolName}` : toolName; +} + +export function toolsetEndpointUrl(toolsetName: string): string { + return `${getProxyBaseUrl()}/toolset/${toolsetName}/mcp`; +} + +interface ToolsetRowActionsProps { + toolset: MCPToolset; + isAdmin: boolean; + onEditClick: (toolset: MCPToolset) => void; + onDeleteClick: (toolsetId: string) => void; +} + +function ToolsetRowActions({ toolset, isAdmin, onEditClick, onDeleteClick }: ToolsetRowActionsProps) { + return ( + + + + + + void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")} + > + + Copy endpoint URL + + void copyToClipboard(toolset.toolset_id, "Toolset ID copied")} + > + + Copy toolset ID + + {isAdmin && ( + <> + + onEditClick(toolset)}> + + Edit + + onDeleteClick(toolset.toolset_id)} + > + + Delete + + + )} + + + ); +} + +interface MCPToolsetTableColumnsDeps { + isAdmin: boolean; + serverPrefixById: Map; + onEditClick: (toolset: MCPToolset) => void; + onDeleteClick: (toolsetId: string) => void; +} + +export const getMCPToolsetTableColumns = ({ + isAdmin, + serverPrefixById, + onEditClick, + onDeleteClick, +}: MCPToolsetTableColumnsDeps): ColumnDef[] => [ + { + id: "toolset_id", + accessorKey: "toolset_id", + meta: { title: "Toolset ID" }, + header: "Toolset ID", + size: 140, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "toolset_name", + accessorKey: "toolset_name", + meta: { title: "Name" }, + header: ({ column }) => , + size: 260, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onEditClick(row.original) : undefined} + /> + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 200, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.description || "—"} + + ), + }, + { + id: "tools", + meta: { title: "Tools", skeleton: "chips" }, + header: "Tools", + size: 260, + enableSorting: false, + cell: ({ row }) => { + const tools = row.original.tools; + return ( +
+ {tools.slice(0, 4).map((tool) => ( + + {displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name)} + + ))} + {tools.length > 4 && ( + +{tools.length - 4} more + )} +
+ ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index fa44694887b8..0bced76e24eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -1,13 +1,13 @@ import React, { useState, useCallback } from "react"; import { Button, Text, Title } from "@tremor/react"; -import { Modal, Form, Input, message, Spin, Card, Typography, Space } from "antd"; -import { PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/outline"; -import { ColumnDef } from "@tanstack/react-table"; +import { Modal, Form, Input, message, Spin } from "antd"; +import { PlusIcon } from "@heroicons/react/outline"; +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useQueryClient } from "@tanstack/react-query"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import { DataTable } from "@/components/view_logs/table"; +import { DataTable } from "@/components/shared/DataTable"; import { createMCPToolset, updateMCPToolset, @@ -16,19 +16,7 @@ import { getProxyBaseUrl, } from "@/components/networking"; import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types"; - -const { Text: AntdText } = Typography; - -// Display-only. Toolsets persist {server_id, bare tool_name}; the gateway serves -// each tool prefixed as "{server-prefix}-{tool}". Render that qualified form so -// the same tool name on different servers stays distinguishable. This mirrors the -// backend default MCP_TOOL_PREFIX_SEPARATOR; overriding that env var only changes -// this cosmetic label, never what is stored or how tools are matched. -const MCP_TOOL_PREFIX_SEPARATOR = "-"; - -function displayToolName(serverPrefix: string | undefined, toolName: string): string { - return serverPrefix ? `${serverPrefix}${MCP_TOOL_PREFIX_SEPARATOR}${toolName}` : toolName; -} +import { displayToolName, getMCPToolsetTableColumns } from "./MCPToolsetTableColumns"; interface MCPToolsetsTabProps { accessToken: string | null; @@ -298,99 +286,18 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset ); } -function toolsetColumns( - isAdmin: boolean, - onEdit: (t: MCPToolset) => void, - onDelete: (id: string) => void, - serverPrefixById: Map, -): ColumnDef[] { - const proxyBaseUrl = getProxyBaseUrl(); - return [ - { - header: "Toolset ID", - accessorKey: "toolset_id", - cell: ({ row }) => , - }, - { - header: "Name", - accessorKey: "toolset_name", - cell: ({ row }) => { - const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`; - return ( -
-
- - {row.original.toolset_name} -
- -
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - cell: ({ row }) => {row.original.description || "—"}, - }, - { - header: "Tools", - accessorKey: "tools", - cell: ({ row }) => { - const tools = row.original.tools; - return ( -
- {tools.slice(0, 4).map((t, i) => ( - - {displayToolName(serverPrefixById.get(t.server_id), t.tool_name)} - - ))} - {tools.length > 4 && +{tools.length - 4} more} -
- ); - }, - }, - { - header: "Created", - accessorKey: "created_at", - cell: ({ row }) => , - }, - ...(isAdmin - ? [ - { - header: "", - id: "actions", - cell: ({ row }: { row: { original: MCPToolset } }) => ( -
- - -
- ), - } as ColumnDef, - ] - : []), - ]; +function ToolsetsEmptyState() { + return ( +
+
+ +
+
No toolsets yet
+
+ Create a toolset to give keys and teams a curated set of MCP tools. +
+
+ ); } function ToolsetUsageGuide() { @@ -484,7 +391,16 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { () => new Map(mcpServers.map((s) => [s.server_id, s.alias || s.server_name || s.server_id])), [mcpServers], ); - const columns = toolsetColumns(isAdmin, setEditToolset, setDeleteId, serverPrefixById); + const [sorting, setSorting] = useState([]); + const columns = React.useMemo(() => { + const deps = { + isAdmin, + serverPrefixById, + onEditClick: setEditToolset, + onDeleteClick: setDeleteId, + }; + return getMCPToolsetTableColumns(deps); + }, [isAdmin, serverPrefixById]); return (
@@ -508,10 +424,14 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { toolset.toolset_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} isLoading={isLoading} - noDataMessage="No toolsets yet. Click 'New Toolset' to create one." - loadingMessage="Loading toolsets..." - enableSorting={true} + loadingMessage="Loading toolsets…" + noDataMessage={} + size="compact" /> ; - copyToClipboard?: ReturnType; -}) { - const columns = getAgentHubTableColumns(showModal, copyToClipboard, publicPage); - const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); - - return ( - - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((h) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
{flexRender(h.column.columnDef.header, h.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+function renderTable(data: AgentHubData[], onAgentClick = vi.fn()) { + render( + agent.agent_id || String(index)} + sortingMode="client" + size="compact" + />, ); + return onAgentClick; } -describe("AgentHubTableColumns", () => { +describe("getAgentHubTableColumns", () => { it("should render", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("Test Agent")).toBeInTheDocument(); }); it("should display the agent description", () => { - render(); - // Description appears in both the description column and the mobile view within agent name column - expect(screen.getAllByText("A test agent for unit testing").length).toBeGreaterThanOrEqual(1); + renderTable([mockAgent]); + expect(screen.getByText("A test agent for unit testing")).toBeInTheDocument(); }); it("should display the version with a 'v' prefix", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("v2.0")).toBeInTheDocument(); }); it("should display the protocol version", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("1.0")).toBeInTheDocument(); }); it("should show skill count with correct pluralization", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("3 skills")).toBeInTheDocument(); }); it("should show first two skills and '+1' for overflow", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("Skill One")).toBeInTheDocument(); expect(screen.getByText("Skill Two")).toBeInTheDocument(); expect(screen.getByText("+1")).toBeInTheDocument(); }); it("should show only true capabilities as badges", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("streaming")).toBeInTheDocument(); expect(screen.queryByText("caching")).not.toBeInTheDocument(); }); it("should display I/O modes", () => { - render(); - // "In:" and "Out:" are in children; getByText with exact:false - // matches against the element's full textContent across child nodes - expect(screen.getByText((_, el) => el?.tagName === "P" && el.textContent === "In: text")).toBeInTheDocument(); - expect( - screen.getByText((_, el) => el?.tagName === "P" && el.textContent === "Out: text, image"), - ).toBeInTheDocument(); + renderTable([mockAgent]); + const inLabel = screen.getByText("In:"); + expect(inLabel.parentElement?.textContent).toBe("In: text"); + const outLabel = screen.getByText("Out:"); + expect(outLabel.parentElement?.textContent).toBe("Out: text, image"); }); it("should display 'Yes' badge for public agents", () => { - render(); + renderTable([mockAgent]); expect(screen.getByText("Yes")).toBeInTheDocument(); }); it("should display 'No' badge for non-public agents", () => { - const privateAgent = { ...mockAgent, is_public: false }; - render(); + renderTable([{ ...mockAgent, is_public: false }]); expect(screen.getByText("No")).toBeInTheDocument(); }); - it("should display a Details button", () => { - render(); - expect(screen.getByRole("button", { name: /details|info/i })).toBeInTheDocument(); + it("should open the agent details when the name is clicked", async () => { + const user = userEvent.setup(); + const onAgentClick = renderTable([mockAgent]); + await user.click(screen.getByRole("button", { name: "Test Agent" })); + expect(onAgentClick).toHaveBeenCalledWith(mockAgent); + }); + + it("should open the agent details from the actions menu", async () => { + const user = userEvent.setup(); + const onAgentClick = renderTable([mockAgent]); + await user.click(screen.getByTestId("agent-hub-actions-agent-1")); + await user.click(await screen.findByTestId("agent-hub-action-details")); + expect(onAgentClick).toHaveBeenCalledWith(mockAgent); + }); + + it("should copy the agent name from the actions menu", async () => { + const user = userEvent.setup(); + renderTable([mockAgent]); + await user.click(screen.getByTestId("agent-hub-actions-agent-1")); + await user.click(await screen.findByTestId("agent-hub-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("Test Agent"); }); it("should show '-' when agent has no capabilities", () => { - const noCapAgent = { ...mockAgent, capabilities: {} }; - render(); - // The dash is rendered in the capabilities column - expect(screen.getByText("-")).toBeInTheDocument(); + renderTable([{ ...mockAgent, capabilities: {} }]); + expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(1); }); it("should show singular 'skill' for one skill", () => { - const oneSkillAgent = { - ...mockAgent, - skills: [{ id: "s1", name: "Only Skill", description: "One" }], - }; - render(); + renderTable([{ ...mockAgent, skills: [{ id: "s1", name: "Only Skill", description: "One" }] }]); expect(screen.getByText("1 skill")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx index ae1a19ff95de..643f2628e735 100644 --- a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx @@ -1,8 +1,21 @@ +"use client"; + import { ColumnDef } from "@tanstack/react-table"; -import { Button, Badge, Text } from "@tremor/react"; -import { Tooltip, Tag } from "antd"; -import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { Copy, Info, MoreHorizontal } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; import { StatusBadge } from "@/components/shared/table_cells"; +import { IdentityCell } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; export interface AgentHubData { agent_id?: string; @@ -29,196 +42,193 @@ export interface AgentHubData { [key: string]: any; } -export const getAgentHubTableColumns = ( - showModal: (agent: AgentHubData) => void, - copyToClipboard: (text: string) => void, - publicPage: boolean = false, -): ColumnDef[] => { - const allColumns: ColumnDef[] = [ - { - header: "Agent Name", - accessorKey: "name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const agent = row.original; - - return ( -
-
- {agent.name} - - copyToClipboard(agent.name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- {/* Show description on mobile */} -
- {agent.description} -
-
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const agent = row.original; - - return {agent.description || "-"}; - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Version", - accessorKey: "version", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const agent = row.original; - - return ( - - v{agent.version} - - ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Protocol", - accessorKey: "protocolVersion", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const agent = row.original; +interface AgentHubRowActionsProps { + agent: AgentHubData; + onAgentClick: (agent: AgentHubData) => void; +} - return {agent.protocolVersion || "-"}; - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Skills", - accessorKey: "skills", - enableSorting: false, - cell: ({ row }) => { - const agent = row.original; - const skills = agent.skills || []; +function AgentHubRowActions({ agent, onAgentClick }: AgentHubRowActionsProps) { + return ( + + + + + + onAgentClick(agent)}> + + View details + + void copyToClipboard(agent.name, "Agent name copied")} + > + + Copy agent name + + + + ); +} - return ( -
- - {skills.length} skill{skills.length !== 1 ? "s" : ""} - - {skills.length > 0 && ( -
- {skills.slice(0, 2).map((skill) => ( - - {skill.name} - - ))} - {skills.length > 2 && +{skills.length - 2}} -
- )} -
- ); - }, - }, - { - header: "Capabilities", - accessorKey: "capabilities", - enableSorting: false, - cell: ({ row }) => { - const agent = row.original; - const capabilities = agent.capabilities || {}; - const capabilityList = Object.entries(capabilities) - .filter(([_, value]) => value === true) - .map(([key]) => key); +interface AgentHubTableColumnsDeps { + onAgentClick: (agent: AgentHubData) => void; +} - return ( -
- {capabilityList.length === 0 ? ( - - - ) : ( - capabilityList.map((capability) => ( - - {capability} +export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Agent Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onAgentClick(row.original)} /> + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 240, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {row.original.description || "-"} + + ), + }, + { + id: "version", + accessorKey: "version", + meta: { title: "Version", skeleton: "badge", className: "hidden lg:table-cell" }, + header: ({ column }) => , + size: 100, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + v{row.original.version} + + ), + }, + { + id: "protocolVersion", + accessorKey: "protocolVersion", + meta: { title: "Protocol", className: "hidden lg:table-cell" }, + header: ({ column }) => , + size: 100, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => {row.original.protocolVersion || "-"}, + }, + { + id: "skills", + meta: { title: "Skills", skeleton: "chips" }, + header: "Skills", + size: 180, + enableSorting: false, + cell: ({ row }) => { + const skills = row.original.skills || []; + return ( +
+ + {skills.length} skill{skills.length !== 1 ? "s" : ""} + + {skills.length > 0 && ( +
+ {skills.slice(0, 2).map((skill) => ( + + {skill.name} - )) - )} -
- ); - }, + ))} + {skills.length > 2 && +{skills.length - 2}} +
+ )} +
+ ); }, - { - header: "I/O Modes", - accessorKey: "defaultInputModes", - enableSorting: false, - cell: ({ row }) => { - const agent = row.original; - const inputModes = agent.defaultInputModes || []; - const outputModes = agent.defaultOutputModes || []; - - return ( -
- - In: {inputModes.join(", ") || "-"} - - - Out: {outputModes.join(", ") || "-"} - -
- ); - }, - meta: { - className: "hidden xl:table-cell", - }, + }, + { + id: "capabilities", + meta: { title: "Capabilities", skeleton: "chips" }, + header: "Capabilities", + size: 160, + enableSorting: false, + cell: ({ row }) => { + const capabilityList = Object.entries(row.original.capabilities || {}) + .filter(([, value]) => value === true) + .map(([key]) => key); + if (capabilityList.length === 0) { + return -; + } + return ( +
+ {capabilityList.map((capability) => ( + + {capability} + + ))} +
+ ); }, - { - header: "Public", - accessorKey: "is_public", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const publicA = rowA.original.is_public === true ? 1 : 0; - const publicB = rowB.original.is_public === true ? 1 : 0; - return publicA - publicB; - }, - cell: ({ row }) => { - const isPublic = row.original.is_public === true; - - return ; - }, - meta: { - className: "hidden md:table-cell", - }, + }, + { + id: "io_modes", + meta: { title: "I/O Modes", skeleton: "twoLine", className: "hidden xl:table-cell" }, + header: "I/O Modes", + size: 150, + enableSorting: false, + cell: ({ row }) => { + const inputModes = row.original.defaultInputModes || []; + const outputModes = row.original.defaultOutputModes || []; + return ( +
+ + In: {inputModes.join(", ") || "-"} + + + Out: {outputModes.join(", ") || "-"} + +
+ ); }, - { - header: "Details", - id: "details", - enableSorting: false, - cell: ({ row }) => { - const agent = row.original; - - return ( - - ); - }, + }, + { + id: "is_public", + accessorKey: "is_public", + meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 100, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const publicA = rowA.original.is_public === true ? 1 : 0; + const publicB = rowB.original.is_public === true ? 1 : 0; + return publicA - publicB; }, - ]; - - return allColumns; -}; + cell: ({ row }) => { + const isPublic = row.original.is_public === true; + return ; + }, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx new file mode 100644 index 000000000000..e32c861f13a1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DataTable } from "@/components/shared/DataTable"; +import { getMCPHubTableColumns, MCPServerData } from "./MCPHubTableColumns"; + +const SERVER_URL = "https://mcp.exa.ai/mcp"; + +const mockServer: MCPServerData = { + server_id: "server-1", + server_name: "exa_test", + description: "Fast, intelligent web search and web crawling", + url: SERVER_URL, + transport: "http", + auth_type: "none", + created_at: "2026-01-01T00:00:00Z", + created_by: "admin", + updated_at: "2026-01-01T00:00:00Z", + updated_by: "admin", + teams: [], + mcp_access_groups: [], + allowed_tools: [], + extra_headers: [], + mcp_info: {}, + static_headers: {}, + status: "active", + args: [], + env: {}, +}; + +function renderTable(onServerClick = vi.fn()) { + render( + server.server_id} + sortingMode="client" + size="compact" + />, + ); + return onServerClick; +} + +describe("getMCPHubTableColumns", () => { + it("renders the server row", () => { + renderTable(); + expect(screen.getByText("exa_test")).toBeInTheDocument(); + }); + + it("keeps the non-sensitive columns", () => { + renderTable(); + expect(screen.getByText("Server Name")).toBeInTheDocument(); + expect(screen.getByText("Transport")).toBeInTheDocument(); + expect(screen.getByText("Auth Type")).toBeInTheDocument(); + }); + + it("does not expose a URL column", () => { + renderTable(); + expect(screen.queryByText("URL")).not.toBeInTheDocument(); + const columns = getMCPHubTableColumns({ onServerClick: vi.fn() }); + expect(columns.some((c) => c.header === "URL" || c.meta?.title === "URL")).toBe(false); + }); + + it("does not render the server url anywhere in the table", () => { + renderTable(); + expect(screen.queryByText(SERVER_URL)).not.toBeInTheDocument(); + }); + + it("opens the server details when the name is clicked", async () => { + const user = userEvent.setup(); + const onServerClick = renderTable(); + await user.click(screen.getByRole("button", { name: "exa_test" })); + expect(onServerClick).toHaveBeenCalledWith(mockServer); + }); + + it("opens the server details from the actions menu", async () => { + const user = userEvent.setup(); + const onServerClick = renderTable(); + await user.click(screen.getByTestId("mcp-hub-actions-server-1")); + await user.click(await screen.findByTestId("mcp-hub-action-details")); + expect(onServerClick).toHaveBeenCalledWith(mockServer); + }); + + it("copies the server name from the actions menu", async () => { + const user = userEvent.setup(); + renderTable(); + await user.click(screen.getByTestId("mcp-hub-actions-server-1")); + await user.click(await screen.findByTestId("mcp-hub-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("exa_test"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx new file mode 100644 index 000000000000..6a1ede112016 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, Info, MoreHorizontal } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +export interface MCPServerData { + server_id: string; + server_name: string; + alias?: string | null; + description?: string | null; + url: string; + transport: string; + auth_type: string; + credentials?: any; + created_at: string; + created_by: string; + updated_at: string; + updated_by: string; + teams: string[]; + mcp_access_groups: string[]; + allowed_tools: string[]; + extra_headers: any[]; + mcp_info: Record; + static_headers: Record; + status: string; + last_health_check?: string | null; + health_check_error?: string | null; + command?: string | null; + args: string[]; + env: Record; + [key: string]: any; +} + +const STATUS_TONES: Record = { + active: "success", + inactive: "error", + unknown: "neutral", + healthy: "success", + unhealthy: "error", +}; + +interface MCPHubRowActionsProps { + server: MCPServerData; + onServerClick: (server: MCPServerData) => void; +} + +function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) { + return ( + + + + + + onServerClick(server)}> + + View details + + void copyToClipboard(server.server_name, "Server name copied")} + > + + Copy server name + + + + ); +} + +interface MCPHubTableColumnsDeps { + onServerClick: (server: MCPServerData) => void; +} + +export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps): ColumnDef[] => [ + { + id: "server_name", + accessorKey: "server_name", + meta: { title: "Server Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onServerClick(row.original)} /> + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 240, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {row.original.description || "-"} + + ), + }, + { + id: "transport", + accessorKey: "transport", + meta: { title: "Transport", skeleton: "badge", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {row.original.transport} + + ), + }, + { + id: "auth_type", + accessorKey: "auth_type", + meta: { title: "Auth Type", skeleton: "badge", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + ), + }, + { + id: "status", + accessorKey: "status", + meta: { title: "Status", skeleton: "badge" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + ), + }, + { + id: "allowed_tools", + meta: { title: "Tools", skeleton: "chips", className: "hidden lg:table-cell" }, + header: "Tools", + size: 180, + enableSorting: false, + cell: ({ row }) => { + const tools = row.original.allowed_tools || []; + return ( +
+ + {tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"} + + {tools.length > 0 && ( +
+ {tools.slice(0, 2).map((tool) => ( + + {tool} + + ))} + {tools.length > 2 && +{tools.length - 2}} +
+ )} +
+ ); + }, + }, + { + id: "created_by", + accessorKey: "created_by", + meta: { title: "Created By", className: "hidden xl:table-cell" }, + header: ({ column }) => , + size: 140, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {row.original.created_by || "-"} + + ), + }, + { + id: "is_public", + accessorFn: (row) => row.mcp_info?.is_public === true, + meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" }, + header: ({ column }) => , + size: 100, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const publicA = rowA.original.mcp_info?.is_public === true ? 1 : 0; + const publicB = rowB.original.mcp_info?.is_public === true ? 1 : 0; + return publicA - publicB; + }, + cell: ({ row }) => { + const isPublic = row.original.mcp_info?.is_public === true; + return ; + }, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index dfe307c7edf8..5c4fa4eed43d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -19,6 +19,7 @@ vi.mock("@/components/networking", () => ({ fetchMCPServers: vi.fn(), getUiSettings: vi.fn(), getClaudeCodeMarketplace: vi.fn(), + getClaudeCodePluginsList: vi.fn(() => Promise.resolve({ plugins: [] })), })); vi.mock("next/navigation", () => ({ @@ -152,6 +153,21 @@ describe("ModelHubTable", () => { }); }); + it("should resolve loading to the empty state when there is no access token on the admin page", async () => { + vi.mocked(networking.getUiSettings).mockResolvedValue({ + values: {}, + }); + mockUseUISettings.mockReturnValue({ + data: { values: {} }, + isLoading: false, + }); + + renderWithProviders(); + + expect(await screen.findByText("No models yet")).toBeInTheDocument(); + expect(networking.modelHubCall).not.toHaveBeenCalled(); + }); + it("should call getUiConfig before modelHubPublicModelsCall when publicPage is true", async () => { const getUiConfigMock = vi.mocked(networking.getUiConfig); const modelHubPublicModelsCallMock = vi.mocked(networking.modelHubPublicModelsCall); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 1f64d175052d..0e4de3f244c2 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -2,16 +2,15 @@ import { AgentHubData, getAgentHubTableColumns } from "@/components/AIHub/AgentH import MakeAgentPublicForm from "@/components/AIHub/forms/MakeAgentPublicForm"; import MakeMCPPublicForm from "@/components/AIHub/forms/MakeMCPPublicForm"; import MakeModelPublicForm from "@/components/AIHub/forms/MakeModelPublicForm"; -import { mcpHubColumns, MCPServerData } from "@/components/mcp_hub_table_columns"; -import { modelHubColumns } from "@/components/model_hub_table_columns"; +import { getMCPHubTableColumns, MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; +import { getModelHubTableColumns, ModelHubData } from "@/components/AIHub/ModelHubTableColumns"; import UsefulLinksManagement from "@/components/AIHub/UsefulLinksManagement"; import { getClaudeCodePluginsList } from "@/components/networking"; import { Plugin } from "@/components/claude_code_plugins/types"; import SkillHubDashboard from "@/components/AIHub/SkillHubDashboard"; import MakeSkillPublicForm from "@/components/claude_code_plugins/MakeSkillPublicForm"; -import { ModelDataTable } from "@/components/model_dashboard/table"; +import { DataTable } from "@/components/shared/DataTable"; import ModelFilters from "@/components/model_filters"; -import NotificationsManager from "@/components/molecules/notifications_manager"; import { fetchMCPServers, getAgentsList, @@ -22,13 +21,15 @@ import { modelHubPublicModelsCall, } from "@/components/networking"; import PublicModelHub from "@/components/public_model_hub"; +import { copyToClipboard } from "@/utils/dataUtils"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { CopyOutlined } from "@ant-design/icons"; +import { SortingState } from "@tanstack/react-table"; import { Badge, Button, Card, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import { Modal } from "antd"; -import { Copy } from "lucide-react"; +import { Copy, Inbox } from "lucide-react"; import { useRouter } from "next/navigation"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { checkTokenValidity } from "@/utils/jwtUtils"; @@ -42,23 +43,16 @@ interface ModelHubTableProps { userRole: string | null; } -interface ModelGroupInfo { - model_group: string; - providers: string[]; - max_input_tokens?: number; - max_output_tokens?: number; - input_cost_per_token?: number; - output_cost_per_token?: number; - mode?: string; - tpm?: number; - rpm?: number; - supports_parallel_function_calling: boolean; - supports_vision: boolean; - supports_function_calling: boolean; - supported_openai_params?: string[]; - is_public_model_group: boolean; - // Allow any additional properties for flexibility - [key: string]: any; +function HubEmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
+ +
+
{title}
+
{body}
+
+ ); } const ModelHubTable: React.FC = ({ accessToken, publicPage, premiumUser, userRole }) => { @@ -67,12 +61,12 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const canModify = isProxyAdminRole(userRole || ""); const [publicPageAllowed, setPublicPageAllowed] = useState(false); - const [modelHubData, setModelHubData] = useState(null); + const [modelHubData, setModelHubData] = useState(null); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [isPublicPageModalVisible, setIsPublicPageModalVisible] = useState(false); - const [selectedModel, setSelectedModel] = useState(null); - const [filteredData, setFilteredData] = useState([]); + const [selectedModel, setSelectedModel] = useState(null); + const [filteredData, setFilteredData] = useState([]); const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false); // Agent Hub state const [agentHubData, setAgentHubData] = useState(null); @@ -153,17 +147,23 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }; - if (accessToken) { - fetchData(accessToken); - } else if (publicPage) { - fetchPublicData(); - } + const fetchModelData = async () => { + if (accessToken) { + await fetchData(accessToken); + } else if (publicPage) { + await fetchPublicData(); + } else { + setLoading(false); + } + }; + fetchModelData(); }, [accessToken, publicPage]); // Fetch Agent Hub data useEffect(() => { const fetchAgentData = async () => { if (!accessToken) { + setAgentLoading(false); return; } @@ -193,6 +193,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, useEffect(() => { const fetchMcpData = async () => { if (!accessToken) { + setMcpLoading(false); return; } @@ -231,20 +232,20 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, fetchSkillData(); }, [accessToken, publicPage]); - const showModal = (model: ModelGroupInfo) => { + const showModal = useCallback((model: ModelHubData) => { setSelectedModel(model); setIsModalVisible(true); - }; + }, []); - const showAgentModal = (agent: AgentHubData) => { + const showAgentModal = useCallback((agent: AgentHubData) => { setSelectedAgent(agent); setIsAgentModalVisible(true); - }; + }, []); - const showMcpModal = (server: MCPServerData) => { + const showMcpModal = useCallback((server: MCPServerData) => { setSelectedMcpServer(server); setIsMcpModalVisible(true); - }; + }, []); const goToPublicModelPage = () => { router.replace(`/model_hub_table?key=${accessToken}`); @@ -297,11 +298,6 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setSelectedMcpServer(null); }; - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - NotificationsManager.success("Copied to clipboard!"); - }; - const formatCapabilityName = (key: string) => { // Remove 'supports_' prefix and convert snake_case to Title Case return key @@ -311,7 +307,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, .join(" "); }; - const getModelCapabilities = (model: ModelGroupInfo) => { + const getModelCapabilities = (model: ModelHubData) => { // Find all properties that start with 'supports_' and are true return Object.entries(model) .filter(([key, value]) => key.startsWith("supports_") && value === true) @@ -373,10 +369,18 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }; - const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => { + const handleFilteredDataChange = useCallback((newFilteredData: ModelHubData[]) => { setFilteredData(newFilteredData); }, []); + const [modelSorting, setModelSorting] = useState([{ id: "model_group", desc: false }]); + const [agentSorting, setAgentSorting] = useState([{ id: "name", desc: false }]); + const [mcpSorting, setMcpSorting] = useState([{ id: "server_name", desc: false }]); + + const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]); + const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]); + const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]); + // If this is a public page, use the dedicated PublicModelHub component if (publicPage && publicPageAllowed) { return ; @@ -403,7 +407,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage,
{`${getProxyBaseUrl()}/ui/model_hub_table`}
- setSelectedSkill(skill), copyToClipboard, publicPage)} + skill.id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading skills…" + noDataMessage={} + size="compact" />
- +

Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""} - +

diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.test.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.test.tsx new file mode 100644 index 000000000000..5a625d384f11 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DataTable } from "@/components/shared/DataTable"; +import { Plugin } from "@/components/claude_code_plugins/types"; +import { getSkillHubTableColumns } from "./SkillHubTableColumns"; + +const mockSkill: Plugin = { + id: "skill-1", + name: "pdf-tools", + description: "Work with PDF files", + source: { source: "github", repo: "org/pdf-tools" }, + category: "documents", + domain: "Productivity", + enabled: true, +}; + +function renderTable(data: Plugin[], onSkillClick = vi.fn()) { + render( + skill.id || String(index)} + sortingMode="client" + size="compact" + />, + ); + return onSkillClick; +} + +describe("getSkillHubTableColumns", () => { + it("renders the skill row with category and domain", () => { + renderTable([mockSkill]); + expect(screen.getByText("pdf-tools")).toBeInTheDocument(); + expect(screen.getByText("documents")).toBeInTheDocument(); + expect(screen.getByText("Productivity")).toBeInTheDocument(); + }); + + it("links to the github source", () => { + renderTable([mockSkill]); + const link = screen.getByRole("link", { name: /org\/pdf-tools/ }); + expect(link).toHaveAttribute("href", "https://github.com/org/pdf-tools"); + }); + + it("shows Public for enabled skills and Draft for disabled ones", () => { + renderTable([mockSkill, { ...mockSkill, id: "skill-2", name: "draft-skill", enabled: false }]); + expect(screen.getByText("Public")).toBeInTheDocument(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + }); + + it("opens the skill detail when the name is clicked", async () => { + const user = userEvent.setup(); + const onSkillClick = renderTable([mockSkill]); + await user.click(screen.getByRole("button", { name: "pdf-tools" })); + expect(onSkillClick).toHaveBeenCalledWith(mockSkill); + }); + + it("opens the skill detail from the actions menu", async () => { + const user = userEvent.setup(); + const onSkillClick = renderTable([mockSkill]); + await user.click(screen.getByTestId("skill-hub-actions-skill-1")); + await user.click(await screen.findByTestId("skill-hub-action-details")); + expect(onSkillClick).toHaveBeenCalledWith(mockSkill); + }); + + it("copies the skill name from the actions menu", async () => { + const user = userEvent.setup(); + renderTable([mockSkill]); + await user.click(screen.getByTestId("skill-hub-actions-skill-1")); + await user.click(await screen.findByTestId("skill-hub-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("pdf-tools"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx new file mode 100644 index 000000000000..2a1530cc3525 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubTableColumns.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, ExternalLink, Info, MoreHorizontal } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; +import { Plugin } from "@/components/claude_code_plugins/types"; + +function getSkillSourceLink(skill: Plugin): { url: string; label: string } | null { + const src = skill.source; + if (src?.source === "github" && src.repo) { + return { url: `https://github.com/${src.repo}`, label: src.repo }; + } + if (src?.source === "git-subdir" && src.url) { + const url = src.path ? `${src.url}/tree/main/${src.path}` : src.url; + return { url, label: url.replace("https://github.com/", "") }; + } + if (src?.source === "url" && src.url) { + return { url: src.url, label: src.url.replace(/^https?:\/\//, "") }; + } + return null; +} + +interface SkillHubRowActionsProps { + skill: Plugin; + onSkillClick: (skill: Plugin) => void; +} + +function SkillHubRowActions({ skill, onSkillClick }: SkillHubRowActionsProps) { + return ( + + + + + + onSkillClick(skill)}> + + View details + + void copyToClipboard(skill.name, "Skill name copied")} + > + + Copy skill name + + + + ); +} + +interface SkillHubTableColumnsDeps { + onSkillClick: (skill: Plugin) => void; +} + +export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Skill Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onSkillClick(row.original)} /> + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 260, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.description || "-"} + + ), + }, + { + id: "category", + accessorKey: "category", + meta: { title: "Category", skeleton: "badge" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => + row.original.category ? ( + {row.original.category} + ) : ( + - + ), + }, + { + id: "domain", + accessorKey: "domain", + meta: { title: "Domain" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => {row.original.domain || "-"}, + }, + { + id: "source", + meta: { title: "Source" }, + header: "Source", + size: 200, + enableSorting: false, + cell: ({ row }) => { + const link = getSkillSourceLink(row.original); + if (!link) return -; + return ( + + {link.label} + + + ); + }, + }, + { + id: "enabled", + accessorKey: "enabled", + meta: { title: "Status", skeleton: "badge" }, + header: ({ column }) => , + size: 100, + enableSorting: true, + cell: ({ row }) => ( + + ), + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx index 08dc64767ffc..994a920b2e4d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import MakeMCPPublicForm from "./MakeMCPPublicForm"; -import { MCPServerData } from "../../mcp_hub_table_columns"; +import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; // Mock the networking function vi.mock("../../networking", () => ({ diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx index cc194775faaa..b590c3cc1dde 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeMCPPublicForm.tsx @@ -3,7 +3,7 @@ import { Modal, Form, Steps, Button, Checkbox } from "antd"; import { Text, Title, Badge } from "@tremor/react"; import { makeMCPPublicCall } from "../../networking"; import NotificationsManager from "../../molecules/notifications_manager"; -import { MCPServerData } from "@/components/mcp_hub_table_columns"; +import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns"; const { Step } = Steps; diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx new file mode 100644 index 000000000000..ab0ed976149f --- /dev/null +++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, IdentityCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; +import { Badge } from "@/components/ui/badge"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; + +export interface ModelGroupInfo { + model_group: string; + providers: string[]; + max_input_tokens?: number; + max_output_tokens?: number; + input_cost_per_token?: number; + output_cost_per_token?: number; + mode?: string; + tpm?: number; + rpm?: number; + supports_parallel_function_calling: boolean; + supports_vision: boolean; + supports_function_calling: boolean; + supported_openai_params?: string[]; + health_status?: string; + health_response_time?: number; + health_checked_at?: string; + [key: string]: any; +} + +export interface AgentCard { + protocolVersion: string; + name: string; + description: string; + url: string; + version: string; + capabilities?: { + streaming?: boolean; + pushNotifications?: boolean; + stateTransitionHistory?: boolean; + }; + defaultInputModes: string[]; + defaultOutputModes: string[]; + skills: Array<{ + id: string; + name: string; + description: string; + tags: string[]; + }>; + iconUrl?: string; + provider?: { + organization: string; + url: string; + }; + documentationUrl?: string; + [key: string]: any; +} + +export interface MCPServerData { + server_id: string; + name: string; + alias?: string | null; + server_name: string; + transport: string; + spec_path?: string | null; + auth_type: string; + mcp_info: { + server_name: string; + description?: string; + mcp_server_cost_info?: any; + }; + [key: string]: any; +} + +const formatCapabilityName = (key: string) => + key + .replace(/^supports_/, "") + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +const formatCost = (cost: number) => `$${(cost * 1_000_000).toFixed(4)}`; + +const formatTokens = (tokens: number | undefined) => { + if (!tokens) return "N/A"; + if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}K`; + return tokens.toString(); +}; + +const formatLimits = (rpm?: number, tpm?: number) => { + const limits = [...(rpm ? [`RPM: ${rpm.toLocaleString()}`] : []), ...(tpm ? [`TPM: ${tpm.toLocaleString()}`] : [])]; + return limits.length > 0 ? limits.join(", ") : "N/A"; +}; + +const getModeIcon = (mode: string) => { + switch (mode?.toLowerCase()) { + case "chat": + return "💬"; + case "rerank": + return "🔄"; + case "embedding": + return "📄"; + default: + return "🤖"; + } +}; + +const HEALTH_TONES: Record = { + healthy: "success", + unhealthy: "error", +}; + +function ProviderChips({ providers }: { providers: string[] }) { + return ( +
+ {providers.map((provider) => { + const { logo } = getProviderLogoAndName(provider); + return ( + + {logo && ( + {provider} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {provider} + + ); + })} +
+ ); +} + +function OverflowChips({ items }: { items: string[] }) { + if (items.length === 0) { + return -; + } + return ( +
+ {items[0]} + {items.length > 1 && ( + + {items.map((item) => ( +
+ • {item} +
+ ))} +
+ } + trigger={+{items.length - 1}} + /> + )} + + ); +} + +interface PublicModelHubColumnsDeps { + onModelClick: (model: ModelGroupInfo) => void; +} + +export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef[] => [ + { + id: "model_group", + accessorKey: "model_group", + meta: { title: "Model Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onModelClick(row.original)} + /> + ), + }, + { + id: "providers", + accessorKey: "providers", + meta: { title: "Providers", skeleton: "chips" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + sortingFn: (rowA, rowB) => + (rowA.original.providers ?? []).join(", ").localeCompare((rowB.original.providers ?? []).join(", ")), + cell: ({ row }) => , + }, + { + id: "mode", + accessorKey: "mode", + meta: { title: "Mode" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {getModeIcon(row.original.mode || "")} + {row.original.mode || "Chat"} + + ), + }, + { + id: "max_input_tokens", + accessorKey: "max_input_tokens", + meta: { title: "Max Input", numeric: true }, + header: ({ column }) => , + size: 100, + enableSorting: true, + cell: ({ row }) => {formatTokens(row.original.max_input_tokens)}, + }, + { + id: "max_output_tokens", + accessorKey: "max_output_tokens", + meta: { title: "Max Output", numeric: true }, + header: ({ column }) => , + size: 100, + enableSorting: true, + cell: ({ row }) => {formatTokens(row.original.max_output_tokens)}, + }, + { + id: "input_cost_per_token", + accessorKey: "input_cost_per_token", + meta: { title: "Input $/1M", numeric: true }, + header: ({ column }) => , + size: 110, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"} + + ), + }, + { + id: "output_cost_per_token", + accessorKey: "output_cost_per_token", + meta: { title: "Output $/1M", numeric: true }, + header: ({ column }) => , + size: 110, + enableSorting: true, + cell: ({ row }) => ( + + {row.original.output_cost_per_token ? formatCost(row.original.output_cost_per_token) : "Free"} + + ), + }, + { + id: "features", + meta: { title: "Features", skeleton: "chips" }, + header: "Features", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const features = Object.entries(row.original) + .filter(([key, value]) => key.startsWith("supports_") && value === true) + .map(([key]) => formatCapabilityName(key)); + return ; + }, + }, + { + id: "health_status", + accessorKey: "health_status", + meta: { title: "Health Status", skeleton: "badge" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => { + const model = row.original; + const responseTimeLabel = model.health_response_time + ? `Response Time: ${Number(model.health_response_time).toFixed(2)}ms` + : "N/A"; + const lastCheckedLabel = model.health_checked_at + ? `Last Checked: ${new Date(model.health_checked_at).toLocaleString()}` + : "N/A"; + return ( + +
{responseTimeLabel}
+
{lastCheckedLabel}
+ + } + trigger={ + + + + } + /> + ); + }, + }, + { + id: "rpm", + accessorKey: "rpm", + meta: { title: "Limits" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => ( + {formatLimits(row.original.rpm, row.original.tpm)} + ), + }, +]; + +interface PublicAgentHubColumnsDeps { + onAgentClick: (agent: AgentCard) => void; +} + +export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Agent Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onAgentClick(row.original)} + /> + ), + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 260, + enableSorting: false, + cell: ({ row }) => ( + + {row.original.description || "-"} + + ), + }, + { + id: "version", + accessorKey: "version", + meta: { title: "Version" }, + header: ({ column }) => , + size: 90, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => {row.original.version}, + }, + { + id: "provider", + meta: { title: "Provider" }, + header: "Provider", + size: 130, + enableSorting: false, + cell: ({ row }) => + row.original.provider ? ( + {row.original.provider.organization} + ) : ( + - + ), + }, + { + id: "skills", + meta: { title: "Skills", skeleton: "chips" }, + header: "Skills", + size: 160, + enableSorting: false, + cell: ({ row }) => skill.name)} />, + }, + { + id: "capabilities", + meta: { title: "Capabilities", skeleton: "chips" }, + header: "Capabilities", + size: 160, + enableSorting: false, + cell: ({ row }) => { + const capabilityList = Object.entries(row.original.capabilities || {}) + .filter(([, value]) => value === true) + .map(([key]) => key); + if (capabilityList.length === 0) { + return -; + } + return ( +
+ {capabilityList.map((capability) => ( + + {capability} + + ))} +
+ ); + }, + }, +]; + +interface PublicMCPHubColumnsDeps { + onServerClick: (server: MCPServerData) => void; +} + +export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDeps): ColumnDef[] => [ + { + id: "server_name", + accessorKey: "server_name", + meta: { title: "Server Name" }, + header: ({ column }) => , + size: 180, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + onServerClick(row.original)} + /> + ), + }, + { + id: "description", + meta: { title: "Description" }, + header: "Description", + size: 260, + enableSorting: false, + cell: ({ row }) => { + const description = String(row.original.mcp_info?.description ?? "-"); + return ( + + {description} + + ); + }, + }, + { + id: "transport", + accessorKey: "transport", + meta: { title: "Transport", skeleton: "badge" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + {row.original.transport} + + ), + }, + { + id: "auth_type", + accessorKey: "auth_type", + meta: { title: "Auth Type", skeleton: "badge" }, + header: ({ column }) => , + size: 110, + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => ( + + ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx deleted file mode 100644 index ae48f140abdf..000000000000 --- a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { vi } from "vitest"; -import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { mcpHubColumns, MCPServerData } from "./mcp_hub_table_columns"; - -const SERVER_URL = "https://mcp.exa.ai/mcp"; - -const mockServer: MCPServerData = { - server_id: "server-1", - server_name: "exa_test", - description: "Fast, intelligent web search and web crawling", - url: SERVER_URL, - transport: "http", - auth_type: "none", - created_at: "2026-01-01T00:00:00Z", - created_by: "admin", - updated_at: "2026-01-01T00:00:00Z", - updated_by: "admin", - teams: [], - mcp_access_groups: [], - allowed_tools: [], - extra_headers: [], - mcp_info: {}, - static_headers: {}, - status: "active", - args: [], - env: {}, -}; - -function TestTable({ data }: { data: MCPServerData[] }) { - const columns = mcpHubColumns(vi.fn(), vi.fn(), false); - const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); - - return ( - - - {table.getHeaderGroups().map((hg) => ( - - {hg.headers.map((h) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
{flexRender(h.column.columnDef.header, h.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
- ); -} - -describe("mcpHubColumns", () => { - it("renders the server row", () => { - render(); - expect(screen.getByText("exa_test")).toBeInTheDocument(); - }); - - it("keeps the non-sensitive columns", () => { - render(); - expect(screen.getByText("Server Name")).toBeInTheDocument(); - expect(screen.getByText("Transport")).toBeInTheDocument(); - expect(screen.getByText("Auth Type")).toBeInTheDocument(); - }); - - it("does not expose a URL column header", () => { - render(); - expect(screen.queryByText("URL")).not.toBeInTheDocument(); - expect(mcpHubColumns(vi.fn(), vi.fn(), false).some((c) => c.header === "URL")).toBe(false); - }); - - it("does not render the server url anywhere in the table", () => { - render(); - expect(screen.queryByText(SERVER_URL)).not.toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx deleted file mode 100644 index 1e25f87d262d..000000000000 --- a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button, Badge, Text } from "@tremor/react"; -import { Tooltip, Tag } from "antd"; -import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; -import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; - -export interface MCPServerData { - server_id: string; - server_name: string; - alias?: string | null; - description?: string | null; - url: string; - transport: string; - auth_type: string; - credentials?: any; - created_at: string; - created_by: string; - updated_at: string; - updated_by: string; - teams: string[]; - mcp_access_groups: string[]; - allowed_tools: string[]; - extra_headers: any[]; - mcp_info: Record; - static_headers: Record; - status: string; - last_health_check?: string | null; - health_check_error?: string | null; - command?: string | null; - args: string[]; - env: Record; - [key: string]: any; -} - -export const mcpHubColumns = ( - showModal: (server: MCPServerData) => void, - copyToClipboard: (text: string) => void, - publicPage: boolean = false, -): ColumnDef[] => { - const allColumns: ColumnDef[] = [ - { - header: "Server Name", - accessorKey: "server_name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - return ( -
-
- {server.server_name} - - copyToClipboard(server.server_name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- {/* Show description on mobile */} -
- {server.description || "-"} -
-
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - return {server.description || "-"}; - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Transport", - accessorKey: "transport", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - return ( - - {server.transport} - - ); - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Auth Type", - accessorKey: "auth_type", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - const authColor = server.auth_type === "none" ? "gray" : "green"; - - return ( - - {server.auth_type} - - ); - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Status", - accessorKey: "status", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - const statusTones: Record = { - active: "success", - inactive: "error", - unknown: "neutral", - healthy: "success", - unhealthy: "error", - }; - - const tone = statusTones[server.status] || "neutral"; - - return ; - }, - }, - { - header: "Tools", - accessorKey: "allowed_tools", - enableSorting: false, - cell: ({ row }) => { - const server = row.original; - const tools = server.allowed_tools || []; - - return ( -
- - {tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"} - - {tools.length > 0 && ( -
- {tools.slice(0, 2).map((tool, idx) => ( - - {tool} - - ))} - {tools.length > 2 && +{tools.length - 2}} -
- )} -
- ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Created By", - accessorKey: "created_by", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const server = row.original; - - return {server.created_by || "-"}; - }, - meta: { - className: "hidden xl:table-cell", - }, - }, - { - header: "Public", - accessorKey: "mcp_info.is_public", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const publicA = rowA.original.mcp_info?.is_public === true ? 1 : 0; - const publicB = rowB.original.mcp_info?.is_public === true ? 1 : 0; - return publicA - publicB; - }, - cell: ({ row }) => { - const server = row.original; - - return server.mcp_info?.is_public === true ? ( - - Yes - - ) : ( - - No - - ); - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Details", - id: "details", - enableSorting: false, - cell: ({ row }) => { - const server = row.original; - - return ( - - ); - }, - }, - ]; - - return allColumns; -}; diff --git a/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx deleted file mode 100644 index 4ea77cb8a5fe..000000000000 --- a/ui/litellm-dashboard/src/components/model_hub_table_columns.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button, Badge, Text } from "@tremor/react"; -import { Tooltip, Tag } from "antd"; -import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; -import { StatusBadge } from "@/components/shared/table_cells"; - -interface ModelHubData { - model_group: string; - providers: string[]; - max_input_tokens?: number; - max_output_tokens?: number; - input_cost_per_token?: number; - output_cost_per_token?: number; - mode?: string; - tpm?: number; - rpm?: number; - supports_parallel_function_calling: boolean; - supports_vision: boolean; - supports_function_calling: boolean; - supported_openai_params?: string[]; - is_public_model_group: boolean; - [key: string]: any; -} - -const formatCapabilityName = (key: string) => { - return key - .replace(/^supports_/, "") - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -}; - -const getModelCapabilities = (model: ModelHubData) => { - return Object.entries(model) - .filter(([key, value]) => key.startsWith("supports_") && value === true) - .map(([key]) => key); -}; - -const formatCost = (cost: number) => { - return `$${(cost * 1_000_000).toFixed(2)}`; -}; - -const formatTokens = (tokens: number) => { - if (tokens >= 1_000_000) { - return `${(tokens / 1_000_000).toFixed(1)}M`; - } else if (tokens >= 1_000) { - return `${(tokens / 1_000).toFixed(1)}K`; - } - return tokens.toString(); -}; - -export const modelHubColumns = ( - showModal: (model: ModelHubData) => void, - copyToClipboard: (text: string) => void, - publicPage: boolean = false, -): ColumnDef[] => { - const allColumns: ColumnDef[] = [ - { - header: "Public Model Name", - accessorKey: "model_group", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - - return ( -
-
- {model.model_group} - - copyToClipboard(model.model_group)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- {/* Show provider on mobile when provider column is hidden */} -
- {model.providers.join(", ")} -
-
- ); - }, - }, - { - header: "Provider", - accessorKey: "providers", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const providersA = rowA.original.providers.join(", "); - const providersB = rowB.original.providers.join(", "); - return providersA.localeCompare(providersB); - }, - cell: ({ row }) => { - const model = row.original; - - return ( -
- {model.providers.slice(0, 2).map((provider) => ( - - {provider} - - ))} - {model.providers.length > 2 && +{model.providers.length - 2}} -
- ); - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Mode", - accessorKey: "mode", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const model = row.original; - - return model.mode ? ( - - {model.mode} - - ) : ( - - - ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Tokens", - accessorKey: "max_input_tokens", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const tokensA = (rowA.original.max_input_tokens || 0) + (rowA.original.max_output_tokens || 0); - const tokensB = (rowB.original.max_input_tokens || 0) + (rowB.original.max_output_tokens || 0); - return tokensA - tokensB; - }, - cell: ({ row }) => { - const model = row.original; - - return ( -
- - {model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} /{" "} - {model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"} - -
- ); - }, - meta: { - className: "hidden lg:table-cell", - }, - }, - { - header: "Cost/1M", - accessorKey: "input_cost_per_token", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const costA = (rowA.original.input_cost_per_token || 0) + (rowA.original.output_cost_per_token || 0); - const costB = (rowB.original.input_cost_per_token || 0) + (rowB.original.output_cost_per_token || 0); - return costA - costB; - }, - cell: ({ row }) => { - const model = row.original; - - return ( -
- {model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"} - - {model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"} - -
- ); - }, - }, - { - header: "Features", - accessorKey: "capabilities", - enableSorting: false, - cell: ({ row }) => { - const model = row.original; - const capabilities = getModelCapabilities(model); - const colors = ["green", "blue", "purple", "orange", "red", "yellow"]; - - return ( -
- {capabilities.length === 0 ? ( - - - ) : ( - capabilities.map((capability, index) => ( - - {formatCapabilityName(capability)} - - )) - )} -
- ); - }, - }, - { - header: "Public", - accessorKey: "is_public_model_group", - enableSorting: true, - sortingFn: (rowA, rowB) => { - const publicA = rowA.original.is_public_model_group === true ? 1 : 0; - const publicB = rowB.original.is_public_model_group === true ? 1 : 0; - return publicA - publicB; - }, - cell: ({ row }) => { - const model = row.original; - - return model.is_public_model_group === true ? ( - - ) : ( - - ); - }, - meta: { - className: "hidden md:table-cell", - }, - }, - { - header: "Details", - id: "details", - enableSorting: false, - cell: ({ row }) => { - const model = row.original; - - return ( - - ); - }, - }, - ]; - - // Filter out columns based on publicPage setting - if (publicPage) { - return allColumns.filter((column) => { - // Remove the public column - if ("accessorKey" in column && column.accessorKey === "is_public_model_group") return false; - - return true; - }); - } - - return allColumns; -}; diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx index 9d1e31804ef9..43788c1dfd86 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"; import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import PublicModelHub, { publicMCPHubColumns, MCPServerData } from "./public_model_hub"; +import PublicModelHub from "./public_model_hub"; +import { getPublicMCPHubColumns, MCPServerData } from "./PublicModelHubTableColumns"; vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ @@ -24,6 +25,7 @@ vi.mock("./networking", async (importOriginal) => { }), agentHubPublicModelsCall: vi.fn().mockResolvedValue([]), mcpHubPublicServersCall: vi.fn().mockResolvedValue([]), + skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }), getUiConfig: vi.fn().mockResolvedValue({}), }; }); @@ -113,63 +115,23 @@ describe("PublicModelHub", () => { expect(screen.getByText("gpt-4")).toBeInTheDocument(); }); - // Check that health status is displayed for healthy model (gpt-4) - // Find the row containing "gpt-4" and verify it has "healthy" status + // Check the health status badge in each model's row await waitFor(() => { - const gpt4Cell = screen.getByText("gpt-4"); - const gpt4Row = gpt4Cell.closest("tr"); + const gpt4Row = screen.getByText("gpt-4").closest("tr"); expect(gpt4Row).toBeInTheDocument(); - - // Find all cells in the row - const cells = gpt4Row?.querySelectorAll("td"); - expect(cells).toBeTruthy(); - - // Find the cell containing "healthy" text (health status column) - // The health status is in a Tag component, so look for a Tag containing "healthy" - const healthyStatus = Array.from(cells || []).find((cell) => { - const tag = cell.querySelector('[class*="ant-tag"]'); - const text = tag?.textContent?.toLowerCase(); - return text === "healthy"; - }); - expect(healthyStatus).toBeInTheDocument(); + expect(within(gpt4Row as HTMLElement).getByText("healthy")).toBeInTheDocument(); }); - // Check that health status is displayed for unhealthy model (claude-3) await waitFor(() => { - const claude3Cell = screen.getByText("claude-3"); - const claude3Row = claude3Cell.closest("tr"); + const claude3Row = screen.getByText("claude-3").closest("tr"); expect(claude3Row).toBeInTheDocument(); - - // Find all cells in the row - const cells = claude3Row?.querySelectorAll("td"); - expect(cells).toBeTruthy(); - - // Find the cell containing "unhealthy" text (health status column) - const unhealthyStatus = Array.from(cells || []).find((cell) => { - const tag = cell.querySelector('[class*="ant-tag"]'); - const text = tag?.textContent?.toLowerCase(); - return text === "unhealthy"; - }); - expect(unhealthyStatus).toBeInTheDocument(); + expect(within(claude3Row as HTMLElement).getByText("unhealthy")).toBeInTheDocument(); }); - // Check that "Unknown" is displayed for model without health status (gpt-3.5-turbo) await waitFor(() => { - const gpt35Cell = screen.getByText("gpt-3.5-turbo"); - const gpt35Row = gpt35Cell.closest("tr"); + const gpt35Row = screen.getByText("gpt-3.5-turbo").closest("tr"); expect(gpt35Row).toBeInTheDocument(); - - // Find all cells in the row - const cells = gpt35Row?.querySelectorAll("td"); - expect(cells).toBeTruthy(); - - // Find the cell containing "Unknown" text (health status column) - const unknownStatus = Array.from(cells || []).find((cell) => { - const tag = cell.querySelector('[class*="ant-tag"]'); - const text = tag?.textContent; - return text === "Unknown"; - }); - expect(unknownStatus).toBeInTheDocument(); + expect(within(gpt35Row as HTMLElement).getByText("Unknown")).toBeInTheDocument(); }); }); it("handles non-array response gracefully (regression test for e.filter crash)", async () => { @@ -201,7 +163,7 @@ const mockMcpServer: MCPServerData = { }; function PublicMcpTestTable({ data }: { data: MCPServerData[] }) { - const columns = publicMCPHubColumns(vi.fn()); + const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() }); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); return ( @@ -239,7 +201,8 @@ describe("publicMCPHubColumns", () => { it("does not expose a URL column header", () => { render(); expect(screen.queryByText("URL")).not.toBeInTheDocument(); - expect(publicMCPHubColumns(vi.fn()).some((c) => c.header === "URL")).toBe(false); + const columns = getPublicMCPHubColumns({ onServerClick: vi.fn() }); + expect(columns.some((c) => c.header === "URL" || c.meta?.title === "URL")).toBe(false); }); it("does not render the server url anywhere in the table", () => { diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index c28a31a3003f..2bdb30558359 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -1,11 +1,11 @@ import { ThemeProvider } from "@/contexts/ThemeContext"; import { ExternalLinkIcon, SearchIcon } from "@heroicons/react/outline"; -import { ColumnDef } from "@tanstack/react-table"; -import { Button, Card, Text, Title } from "@tremor/react"; +import { SortingState } from "@tanstack/react-table"; +import { Card, Text, Title } from "@tremor/react"; import { Modal, Select, Tabs, Tag, Tooltip } from "antd"; -import { Copy, Info } from "lucide-react"; -import React, { useEffect, useMemo, useState } from "react"; -import { ModelDataTable } from "./model_dashboard/table"; +import { Copy, Inbox, Info } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { DataTable } from "./shared/DataTable"; import NotificationsManager from "./molecules/notifications_manager"; import Navbar from "./navbar"; import { @@ -19,6 +19,14 @@ import { } from "./networking"; import { Plugin } from "./claude_code_plugins/types"; import SkillHubDashboard from "./AIHub/SkillHubDashboard"; +import { + AgentCard, + MCPServerData, + ModelGroupInfo, + getPublicAgentHubColumns, + getPublicMCPHubColumns, + getPublicModelHubColumns, +} from "./PublicModelHubTableColumns"; import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets"; import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping"; import { MessageType } from "@/components/chat_ui/types"; @@ -26,141 +34,22 @@ import { getProviderLogoAndName } from "./provider_info_helpers"; const { TabPane } = Tabs; -interface ModelGroupInfo { - model_group: string; - providers: string[]; - max_input_tokens?: number; - max_output_tokens?: number; - input_cost_per_token?: number; - output_cost_per_token?: number; - mode?: string; - tpm?: number; - rpm?: number; - supports_parallel_function_calling: boolean; - supports_vision: boolean; - supports_function_calling: boolean; - supported_openai_params?: string[]; - health_status?: string; - health_response_time?: number; - health_checked_at?: string; - [key: string]: any; -} - -interface AgentCard { - protocolVersion: string; - name: string; - description: string; - url: string; - version: string; - capabilities?: { - streaming?: boolean; - pushNotifications?: boolean; - stateTransitionHistory?: boolean; - }; - defaultInputModes: string[]; - defaultOutputModes: string[]; - skills: Array<{ - id: string; - name: string; - description: string; - tags: string[]; - }>; - iconUrl?: string; - provider?: { - organization: string; - url: string; - }; - documentationUrl?: string; - [key: string]: any; -} - -export interface MCPServerData { - server_id: string; - name: string; - alias?: string | null; - server_name: string; - transport: string; - spec_path?: string | null; - auth_type: string; - mcp_info: { - server_name: string; - description?: string; - mcp_server_cost_info?: any; - }; - [key: string]: any; -} - interface PublicModelHubProps { accessToken?: string | null; isEmbedded?: boolean; // When true, hides navbar and adjusts layout for embedding in dashboard } -export const publicMCPHubColumns = (showMcpModal: (server: MCPServerData) => void): ColumnDef[] => [ - { - header: "Server Name", - accessorKey: "server_name", - enableSorting: true, - cell: ({ row }) => ( -
- - - +function PublicHubEmptyState({ title, body }: { title: string; body: string }) { + return ( +
+
+
- ), - size: 150, - }, - { - header: "Description", - accessorKey: "mcp_info.description", - enableSorting: false, - cell: ({ row }) => { - const description = String(row.original.mcp_info?.description ?? "-"); - const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; - return ( - - {truncated} - - ); - }, - size: 250, - }, - { - header: "Transport", - accessorKey: "transport", - enableSorting: true, - cell: ({ row }) => { - const transport = row.original.transport; - return ( - - {transport} - - ); - }, - size: 100, - }, - { - header: "Auth Type", - accessorKey: "auth_type", - enableSorting: true, - cell: ({ row }) => { - const authType = row.original.auth_type; - const color = authType === "none" ? "gray" : "green"; - return ( - - {authType} - - ); - }, - size: 100, - }, -]; +
{title}
+
{body}
+
+ ); +} const PublicModelHub: React.FC = ({ accessToken, isEmbedded = false }) => { const [modelHubData, setModelHubData] = useState(null); @@ -503,10 +392,10 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded }); }, [mcpHubData, mcpSearchTerm, selectedMcpTransports]); - const showModal = (model: ModelGroupInfo) => { + const showModal = useCallback((model: ModelGroupInfo) => { setSelectedModel(model); setIsModalVisible(true); - }; + }, []); const handleModalOk = () => { setIsModalVisible(false); @@ -518,10 +407,10 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded setSelectedModel(null); }; - const showAgentModal = (agent: AgentCard) => { + const showAgentModal = useCallback((agent: AgentCard) => { setSelectedAgent(agent); setIsAgentModalVisible(true); - }; + }, []); const handleAgentModalOk = () => { setIsAgentModalVisible(false); @@ -533,10 +422,10 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded setSelectedAgent(null); }; - const showMcpModal = (server: MCPServerData) => { + const showMcpModal = useCallback((server: MCPServerData) => { setSelectedMcpServer(server); setIsMcpModalVisible(true); - }; + }, []); const handleMcpModalOk = () => { setIsMcpModalVisible(false); @@ -571,385 +460,13 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded return `$${(cost * 1_000_000).toFixed(4)}`; }; - const formatTokens = (tokens: number | undefined) => { - if (!tokens) return "N/A"; - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(0)}K`; - } - return tokens.toString(); - }; + const [modelSorting, setModelSorting] = useState([{ id: "model_group", desc: false }]); + const [agentSorting, setAgentSorting] = useState([{ id: "name", desc: false }]); + const [mcpSorting, setMcpSorting] = useState([{ id: "server_name", desc: false }]); - const formatLimits = (rpm?: number, tpm?: number) => { - const limits = []; - if (rpm) limits.push(`RPM: ${rpm.toLocaleString()}`); - if (tpm) limits.push(`TPM: ${tpm.toLocaleString()}`); - return limits.length > 0 ? limits.join(", ") : "N/A"; - }; - - const publicModelHubColumns = (): ColumnDef[] => [ - { - header: "Model Name", - accessorKey: "model_group", - enableSorting: true, - cell: ({ row }) => ( -
- - - -
- ), - size: 150, - }, - { - header: "Providers", - accessorKey: "providers", - enableSorting: true, - cell: ({ row }) => { - const providers = row.original.providers ?? []; - - return ( -
- {providers.map((provider) => { - const { logo } = getProviderLogoAndName(provider); - return ( -
- {logo && ( - {provider} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {provider} -
- ); - })} -
- ); - }, - size: 120, - }, - { - header: "Mode", - accessorKey: "mode", - enableSorting: true, - cell: ({ row }) => { - const mode = row.original.mode; - const getModeIcon = (mode: string) => { - switch (mode?.toLowerCase()) { - case "chat": - return "💬"; - case "rerank": - return "🔄"; - case "embedding": - return "📄"; - default: - return "🤖"; - } - }; - - return ( -
- {getModeIcon(mode || "")} - {mode || "Chat"} -
- ); - }, - size: 100, - }, - { - header: "Max Input", - accessorKey: "max_input_tokens", - enableSorting: true, - cell: ({ row }) => {formatTokens(row.original.max_input_tokens)}, - size: 100, - meta: { - className: "text-center", - }, - }, - { - header: "Max Output", - accessorKey: "max_output_tokens", - enableSorting: true, - cell: ({ row }) => {formatTokens(row.original.max_output_tokens)}, - size: 100, - meta: { - className: "text-center", - }, - }, - { - header: "Input $/1M", - accessorKey: "input_cost_per_token", - enableSorting: true, - cell: ({ row }) => { - const cost = row.original.input_cost_per_token; - return {cost ? formatCost(cost) : "Free"}; - }, - size: 100, - meta: { - className: "text-center", - }, - }, - { - header: "Output $/1M", - accessorKey: "output_cost_per_token", - enableSorting: true, - cell: ({ row }) => { - const cost = row.original.output_cost_per_token; - return {cost ? formatCost(cost) : "Free"}; - }, - size: 100, - meta: { - className: "text-center", - }, - }, - { - header: "Features", - accessorKey: "supports_vision", - enableSorting: false, - cell: ({ row }) => { - const model = row.original; - - // Dynamically get all features that start with 'supports_' and are true - const features = Object.entries(model) - .filter(([key, value]) => key.startsWith("supports_") && value === true) - .map(([key]) => formatCapabilityName(key)); - - if (features.length === 0) { - return -; - } - - if (features.length === 1) { - return ( -
- - {features[0]} - -
- ); - } - - return ( -
- - {features[0]} - - -
All Features:
- {features.map((feature, index) => ( -
- • {feature} -
- ))} -
- } - trigger="click" - placement="topLeft" - > - e.stopPropagation()} - > - +{features.length - 1} - - -
- ); - }, - size: 120, - }, - { - header: "Health Status", - accessorKey: "health_status", - enableSorting: true, - cell: ({ row }) => { - const original = row.original; - const tagColor = - original.health_status === "healthy" ? "green" : original.health_status === "unhealthy" ? "red" : "default"; - const responseTimeLabel = original.health_response_time - ? `Response Time: ${Number(original.health_response_time).toFixed(2)}ms` - : "N/A"; - const lastCheckedLabel = original.health_checked_at - ? `Last Checked: ${new Date(original.health_checked_at).toLocaleString()}` - : "N/A"; - - return ( - -
{responseTimeLabel}
-
{lastCheckedLabel}
- - } - > - - {original.health_status ?? "Unknown"} - -
- ); - }, - size: 100, - }, - { - header: "Limits", - accessorKey: "rpm", - enableSorting: true, - cell: ({ row }) => { - const model = row.original; - return {formatLimits(model.rpm, model.tpm)}; - }, - size: 150, - }, - ]; - - const publicAgentHubColumns = (): ColumnDef[] => [ - { - header: "Agent Name", - accessorKey: "name", - enableSorting: true, - cell: ({ row }) => ( -
- - - -
- ), - size: 150, - }, - { - header: "Description", - accessorKey: "description", - enableSorting: false, - cell: ({ row }) => { - const description = row.original.description ?? ""; - const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; - return ( - - {truncated} - - ); - }, - size: 250, - }, - { - header: "Version", - accessorKey: "version", - enableSorting: true, - cell: ({ row }) => {row.original.version}, - size: 80, - }, - { - header: "Provider", - accessorKey: "provider", - enableSorting: false, - cell: ({ row }) => { - const provider = row.original.provider; - if (!provider) return -; - return ( -
- {provider.organization} -
- ); - }, - size: 120, - }, - { - header: "Skills", - accessorKey: "skills", - enableSorting: false, - cell: ({ row }) => { - const skills = row.original.skills || []; - if (skills.length === 0) { - return -; - } - - if (skills.length === 1) { - return ( -
- - {skills[0].name} - -
- ); - } - - return ( -
- - {skills[0].name} - - -
All Skills:
- {skills.map((skill, index) => ( -
- • {skill.name} -
- ))} -
- } - trigger="click" - placement="topLeft" - > - e.stopPropagation()} - > - +{skills.length - 1} - - - - ); - }, - size: 150, - }, - { - header: "Capabilities", - accessorKey: "capabilities", - enableSorting: false, - cell: ({ row }) => { - const capabilities = row.original.capabilities || {}; - const capList = Object.entries(capabilities) - .filter(([_, value]) => value === true) - .map(([key]) => key); - - if (capList.length === 0) { - return -; - } - - return ( -
- {capList.map((cap) => ( - - {cap} - - ))} -
- ); - }, - size: 150, - }, - ]; + const modelColumns = useMemo(() => getPublicModelHubColumns({ onModelClick: showModal }), [showModal]); + const agentColumns = useMemo(() => getPublicAgentHubColumns({ onAgentClick: showAgentModal }), [showAgentModal]); + const mcpColumns = useMemo(() => getPublicMCPHubColumns({ onServerClick: showMcpModal }), [showMcpModal]); return ( @@ -1132,11 +649,26 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded - model.model_group || String(index)} + sortingMode="client" + sorting={modelSorting} + onSortingChange={setModelSorting} isLoading={loading} - defaultSorting={[{ id: "model_group", desc: false }]} + loadingMessage="Loading models…" + noDataMessage={ + + } + size="compact" />
@@ -1195,11 +727,22 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
- agent.name || String(index)} + sortingMode="client" + sorting={agentSorting} + onSortingChange={setAgentSorting} isLoading={agentLoading} - defaultSorting={[{ id: "name", desc: false }]} + loadingMessage="Loading agents…" + noDataMessage={ + + } + size="compact" />
@@ -1259,11 +802,22 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
- server.server_id || String(index)} + sortingMode="client" + sorting={mcpSorting} + onSortingChange={setMcpSorting} isLoading={mcpLoading} - defaultSorting={[{ id: "server_name", desc: false }]} + loadingMessage="Loading MCP servers…" + noDataMessage={ + + } + size="compact" />
diff --git a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx deleted file mode 100644 index 8fc9adc75a25..000000000000 --- a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Badge, Text } from "@tremor/react"; -import { Tooltip } from "antd"; -import { CopyOutlined, LinkOutlined } from "@ant-design/icons"; -import { Plugin } from "./claude_code_plugins/types"; -import { StatusBadge } from "@/components/shared/table_cells"; - -export const skillHubColumns = ( - showModal: (skill: Plugin) => void, - copyToClipboard: (text: string) => void, - publicPage: boolean = false, -): ColumnDef[] => [ - { - header: "Skill Name", - accessorKey: "name", - enableSorting: true, - sortingFn: "alphanumeric", - cell: ({ row }) => { - const skill = row.original; - return ( -
-
- - - copyToClipboard(skill.name)} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- {skill.description && ( - {skill.description} - )} -
- ); - }, - }, - { - header: "Description", - accessorKey: "description", - enableSorting: false, - cell: ({ row }) => {row.original.description || "-"}, - }, - { - header: "Category", - accessorKey: "category", - enableSorting: true, - cell: ({ row }) => { - const cat = row.original.category; - if (!cat) return -; - return ( - - {cat} - - ); - }, - }, - { - header: "Domain", - accessorKey: "domain", - enableSorting: true, - cell: ({ row }) => {row.original.domain || "-"}, - }, - { - header: "Source", - accessorKey: "source", - enableSorting: false, - cell: ({ row }) => { - const src = row.original.source; - let url: string | null = null; - let label = "-"; - if (src?.source === "github" && src.repo) { - url = `https://github.com/${src.repo}`; - label = src.repo; - } else if (src?.source === "git-subdir" && src.url) { - url = src.path ? `${src.url}/tree/main/${src.path}` : src.url; - label = url.replace("https://github.com/", ""); - } else if (src?.source === "url" && src.url) { - url = src.url; - label = src.url.replace(/^https?:\/\//, ""); - } - if (!url) return -; - return ( - - {label} - - - ); - }, - }, - { - header: "Status", - accessorKey: "enabled", - enableSorting: true, - cell: ({ row }) => ( - - ), - }, -];