refactor(ui): migrate AI Hub, public hub, and MCP Toolsets tables onto shared DataTable (#33629)

* refactor(ui): migrate AI Hub, public hub, and MCP Toolsets tables onto shared DataTable

* test(ui): stub skillHubPublicCall in the public model hub networking mock
This commit is contained in:
yuneng-jiang 2026-07-16 19:32:20 -07:00 • committed by GitHub
parent 07656cf80b
commit 5daed34749
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2200 additions and 1738 deletions

View file

@ -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

View file

@ -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(
<DataTable
data={[mockToolset]}
columns={getMCPToolsetTableColumns(deps)}
getRowId={(toolset) => 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();
});
});

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open toolset actions"
data-testid={`toolset-actions-${toolset.toolset_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="toolset-action-copy-url"
onClick={() => void copyToClipboard(toolsetEndpointUrl(toolset.toolset_name), "Endpoint URL copied")}
>
<Link2 />
Copy endpoint URL
</DropdownMenuItem>
<DropdownMenuItem
data-testid="toolset-action-copy-id"
onClick={() => void copyToClipboard(toolset.toolset_id, "Toolset ID copied")}
>
<Copy />
Copy toolset ID
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem data-testid="toolset-action-edit" onClick={() => onEditClick(toolset)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-testid="toolset-action-delete"
onClick={() => onDeleteClick(toolset.toolset_id)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface MCPToolsetTableColumnsDeps {
isAdmin: boolean;
serverPrefixById: Map<string, string>;
onEditClick: (toolset: MCPToolset) => void;
onDeleteClick: (toolsetId: string) => void;
}
export const getMCPToolsetTableColumns = ({
isAdmin,
serverPrefixById,
onEditClick,
onDeleteClick,
}: MCPToolsetTableColumnsDeps): ColumnDef<MCPToolset>[] => [
{
id: "toolset_id",
accessorKey: "toolset_id",
meta: { title: "Toolset ID" },
header: "Toolset ID",
size: 140,
enableSorting: false,
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
},
{
id: "toolset_name",
accessorKey: "toolset_name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 260,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.toolset_name}
subtitle={toolsetEndpointUrl(row.original.toolset_name)}
className="max-w-80"
onClick={isAdmin ? () => onEditClick(row.original) : undefined}
/>
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 200,
enableSorting: false,
cell: ({ row }) => (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={row.original.description}>
{row.original.description || "—"}
</span>
),
},
{
id: "tools",
meta: { title: "Tools", skeleton: "chips" },
header: "Tools",
size: 260,
enableSorting: false,
cell: ({ row }) => {
const tools = row.original.tools;
return (
<div className="flex max-w-xs flex-wrap gap-1">
{tools.slice(0, 4).map((tool) => (
<span
key={`${tool.server_id}-${tool.tool_name}`}
className="inline-flex items-center rounded-md bg-muted px-1.5 py-0.5 text-xs"
>
{displayToolName(serverPrefixById.get(tool.server_id), tool.tool_name)}
</span>
))}
{tools.length > 4 && (
<span className="self-center text-xs text-muted-foreground">+{tools.length - 4} more</span>
)}
</div>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<ToolsetRowActions
toolset={row.original}
isAdmin={isAdmin}
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
/>
</div>
),
},
];

View file

@ -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<string, string>,
): ColumnDef<MCPToolset>[] {
const proxyBaseUrl = getProxyBaseUrl();
return [
{
header: "Toolset ID",
accessorKey: "toolset_id",
cell: ({ row }) => <IdCell value={row.original.toolset_id} />,
},
{
header: "Name",
accessorKey: "toolset_name",
cell: ({ row }) => {
const url = `${proxyBaseUrl}/toolset/${row.original.toolset_name}/mcp`;
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-purple-500 shrink-0" />
<span className="font-medium text-gray-900">{row.original.toolset_name}</span>
</div>
<button
type="button"
className="text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors"
onClick={() => navigator.clipboard.writeText(url)}
title="Click to copy endpoint URL"
>
{url}
</button>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => <span className="text-sm text-gray-500">{row.original.description || "—"}</span>,
},
{
header: "Tools",
accessorKey: "tools",
cell: ({ row }) => {
const tools = row.original.tools;
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{tools.slice(0, 4).map((t, i) => (
<span
key={i}
className="inline-flex items-center px-1.5 py-0.5 rounded-sm bg-purple-50 border border-purple-200 text-purple-700 text-xs"
>
{displayToolName(serverPrefixById.get(t.server_id), t.tool_name)}
</span>
))}
{tools.length > 4 && <span className="text-xs text-gray-400 self-center">+{tools.length - 4} more</span>}
</div>
);
},
},
{
header: "Created",
accessorKey: "created_at",
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
...(isAdmin
? [
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: MCPToolset } }) => (
<div className="flex items-center gap-1 justify-end">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
onClick={() => onEdit(row.original)}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
onClick={() => onDelete(row.original.toolset_id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
),
} as ColumnDef<MCPToolset>,
]
: []),
];
function ToolsetsEmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No toolsets yet</div>
<div className="text-sm text-muted-foreground">
Create a toolset to give keys and teams a curated set of MCP tools.
</div>
</div>
);
}
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<SortingState>([]);
const columns = React.useMemo(() => {
const deps = {
isAdmin,
serverPrefixById,
onEditClick: setEditToolset,
onDeleteClick: setDeleteId,
};
return getMCPToolsetTableColumns(deps);
}, [isAdmin, serverPrefixById]);
return (
<div className="mt-4">
@ -508,10 +424,14 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
<DataTable
data={toolsets}
columns={columns}
getRowId={(toolset, index) => 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={<ToolsetsEmptyState />}
size="compact"
/>
<CreateToolsetModal

View file

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { describe, expect, it, vi } from "vitest";
import { DataTable } from "@/components/shared/DataTable";
import { getAgentHubTableColumns, AgentHubData } from "./AgentHubTableColumns";
const mockAgent: AgentHubData = {
@ -22,123 +22,106 @@ const mockAgent: AgentHubData = {
is_public: true,
};
function TestTable({
data,
publicPage = false,
showModal = vi.fn(),
copyToClipboard = vi.fn(),
}: {
data: AgentHubData[];
publicPage?: boolean;
showModal?: ReturnType<typeof vi.fn>;
copyToClipboard?: ReturnType<typeof vi.fn>;
}) {
const columns = getAgentHubTableColumns(showModal, copyToClipboard, publicPage);
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
return (
<table>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
function renderTable(data: AgentHubData[], onAgentClick = vi.fn()) {
render(
<DataTable
data={data}
columns={getAgentHubTableColumns({ onAgentClick })}
getRowId={(agent, index) => agent.agent_id || String(index)}
sortingMode="client"
size="compact"
/>,
);
return onAgentClick;
}
describe("AgentHubTableColumns", () => {
describe("getAgentHubTableColumns", () => {
it("should render", () => {
render(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("Test Agent")).toBeInTheDocument();
});
it("should display the agent description", () => {
render(<TestTable data={[mockAgent]} />);
// 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(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("v2.0")).toBeInTheDocument();
});
it("should display the protocol version", () => {
render(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("1.0")).toBeInTheDocument();
});
it("should show skill count with correct pluralization", () => {
render(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("3 skills")).toBeInTheDocument();
});
it("should show first two skills and '+1' for overflow", () => {
render(<TestTable data={[mockAgent]} />);
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(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("streaming")).toBeInTheDocument();
expect(screen.queryByText("caching")).not.toBeInTheDocument();
});
it("should display I/O modes", () => {
render(<TestTable data={[mockAgent]} />);
// "In:" and "Out:" are in <span> 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(<TestTable data={[mockAgent]} />);
renderTable([mockAgent]);
expect(screen.getByText("Yes")).toBeInTheDocument();
});
it("should display 'No' badge for non-public agents", () => {
const privateAgent = { ...mockAgent, is_public: false };
render(<TestTable data={[privateAgent]} />);
renderTable([{ ...mockAgent, is_public: false }]);
expect(screen.getByText("No")).toBeInTheDocument();
});
it("should display a Details button", () => {
render(<TestTable data={[mockAgent]} />);
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(<TestTable data={[noCapAgent]} />);
// 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(<TestTable data={[oneSkillAgent]} />);
renderTable([{ ...mockAgent, skills: [{ id: "s1", name: "Only Skill", description: "One" }] }]);
expect(screen.getByText("1 skill")).toBeInTheDocument();
});
});

View file

@ -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<AgentHubData>[] => {
const allColumns: ColumnDef<AgentHubData>[] = [
{
header: "Agent Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
interface AgentHubRowActionsProps {
agent: AgentHubData;
onAgentClick: (agent: AgentHubData) => void;
}
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{agent.name}</Text>
<Tooltip title="Copy agent name">
<CopyOutlined
onClick={() => copyToClipboard(agent.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">{agent.description}</Text>
</div>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
function AgentHubRowActions({ agent, onAgentClick }: AgentHubRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open agent actions"
data-testid={`agent-hub-actions-${agent.agent_id || agent.name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="agent-hub-action-details" onClick={() => onAgentClick(agent)}>
<Info />
View details
</DropdownMenuItem>
<DropdownMenuItem
data-testid="agent-hub-action-copy"
onClick={() => void copyToClipboard(agent.name, "Agent name copied")}
>
<Copy />
Copy agent name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
return <Text className="text-xs line-clamp-2">{agent.description || "-"}</Text>;
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
interface AgentHubTableColumnsDeps {
onAgentClick: (agent: AgentHubData) => void;
}
return (
<Badge color="blue" size="sm">
v{agent.version}
</Badge>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Protocol",
accessorKey: "protocolVersion",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const agent = row.original;
return <Text className="text-xs">{agent.protocolVersion || "-"}</Text>;
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Skills",
accessorKey: "skills",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
const skills = agent.skills || [];
return (
<div className="space-y-1">
<Text className="text-xs font-medium">
{skills.length} skill{skills.length !== 1 ? "s" : ""}
</Text>
{skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{skills.slice(0, 2).map((skill) => (
<Tag key={skill.id} color="purple" className="text-xs">
{skill.name}
</Tag>
))}
{skills.length > 2 && <Text className="text-xs text-gray-500">+{skills.length - 2}</Text>}
</div>
)}
</div>
);
},
},
{
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);
return (
<div className="flex flex-wrap gap-1">
{capabilityList.length === 0 ? (
<Text className="text-gray-500 text-xs">-</Text>
) : (
capabilityList.map((capability) => (
<Badge key={capability} color="green" size="xs">
{capability}
export const getAgentHubTableColumns = ({ onAgentClick }: AgentHubTableColumnsDeps): ColumnDef<AgentHubData>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Agent Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Agent Name" />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell title={row.original.name} className="max-w-72" onClick={() => onAgentClick(row.original)} />
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Description" />,
size: 240,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="block max-w-72 truncate text-xs" title={row.original.description || undefined}>
{row.original.description || "-"}
</span>
),
},
{
id: "version",
accessorKey: "version",
meta: { title: "Version", skeleton: "badge", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Version" />,
size: 100,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<Badge variant="outline" className="font-mono font-normal">
v{row.original.version}
</Badge>
),
},
{
id: "protocolVersion",
accessorKey: "protocolVersion",
meta: { title: "Protocol", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Protocol" />,
size: 100,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => <span className="text-xs">{row.original.protocolVersion || "-"}</span>,
},
{
id: "skills",
meta: { title: "Skills", skeleton: "chips" },
header: "Skills",
size: 180,
enableSorting: false,
cell: ({ row }) => {
const skills = row.original.skills || [];
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
{skills.length} skill{skills.length !== 1 ? "s" : ""}
</span>
{skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{skills.slice(0, 2).map((skill) => (
<Badge key={skill.id} variant="secondary">
{skill.name}
</Badge>
))
)}
</div>
);
},
))}
{skills.length > 2 && <span className="text-xs text-muted-foreground">+{skills.length - 2}</span>}
</div>
)}
</div>
);
},
{
header: "I/O Modes",
accessorKey: "defaultInputModes",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
const inputModes = agent.defaultInputModes || [];
const outputModes = agent.defaultOutputModes || [];
return (
<div className="space-y-1">
<Text className="text-xs">
<span className="font-medium">In:</span> {inputModes.join(", ") || "-"}
</Text>
<Text className="text-xs">
<span className="font-medium">Out:</span> {outputModes.join(", ") || "-"}
</Text>
</div>
);
},
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 <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{capabilityList.map((capability) => (
<Badge key={capability} variant="outline">
{capability}
</Badge>
))}
</div>
);
},
{
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 <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
},
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 (
<div className="flex flex-col gap-0.5 text-xs">
<span>
<span className="font-medium">In:</span> {inputModes.join(", ") || "-"}
</span>
<span>
<span className="font-medium">Out:</span> {outputModes.join(", ") || "-"}
</span>
</div>
);
},
{
header: "Details",
id: "details",
enableSorting: false,
cell: ({ row }) => {
const agent = row.original;
return (
<Button size="xs" variant="secondary" onClick={() => showModal(agent)} icon={InfoCircleOutlined}>
<span className="hidden lg:inline">Details</span>
<span className="lg:hidden">Info</span>
</Button>
);
},
},
{
id: "is_public",
accessorKey: "is_public",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
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 <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<AgentHubRowActions agent={row.original} onAgentClick={onAgentClick} />
</div>
),
},
];

View file

@ -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(
<DataTable
data={[mockServer]}
columns={getMCPHubTableColumns({ onServerClick })}
getRowId={(server) => 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");
});
});

View file

@ -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<string, any>;
static_headers: Record<string, any>;
status: string;
last_health_check?: string | null;
health_check_error?: string | null;
command?: string | null;
args: string[];
env: Record<string, any>;
[key: string]: any;
}
const STATUS_TONES: Record<string, StatusTone> = {
active: "success",
inactive: "error",
unknown: "neutral",
healthy: "success",
unhealthy: "error",
};
interface MCPHubRowActionsProps {
server: MCPServerData;
onServerClick: (server: MCPServerData) => void;
}
function MCPHubRowActions({ server, onServerClick }: MCPHubRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open MCP server actions"
data-testid={`mcp-hub-actions-${server.server_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="mcp-hub-action-details" onClick={() => onServerClick(server)}>
<Info />
View details
</DropdownMenuItem>
<DropdownMenuItem
data-testid="mcp-hub-action-copy"
onClick={() => void copyToClipboard(server.server_name, "Server name copied")}
>
<Copy />
Copy server name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface MCPHubTableColumnsDeps {
onServerClick: (server: MCPServerData) => void;
}
export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps): ColumnDef<MCPServerData>[] => [
{
id: "server_name",
accessorKey: "server_name",
meta: { title: "Server Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Server Name" />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell title={row.original.server_name} className="max-w-72" onClick={() => onServerClick(row.original)} />
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Description" />,
size: 240,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="block max-w-72 truncate text-xs" title={row.original.description || undefined}>
{row.original.description || "-"}
</span>
),
},
{
id: "transport",
accessorKey: "transport",
meta: { title: "Transport", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Transport" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono font-normal">
{row.original.transport}
</Badge>
),
},
{
id: "auth_type",
accessorKey: "auth_type",
meta: { title: "Auth Type", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Auth Type" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<StatusBadge tone={row.original.auth_type === "none" ? "neutral" : "success"} label={row.original.auth_type} />
),
},
{
id: "status",
accessorKey: "status",
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<StatusBadge tone={STATUS_TONES[row.original.status] || "neutral"} label={row.original.status || "unknown"} />
),
},
{
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 (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
{tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"}
</span>
{tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{tools.slice(0, 2).map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))}
{tools.length > 2 && <span className="text-xs text-muted-foreground">+{tools.length - 2}</span>}
</div>
)}
</div>
);
},
},
{
id: "created_by",
accessorKey: "created_by",
meta: { title: "Created By", className: "hidden xl:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created By" />,
size: 140,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="block max-w-60 truncate text-xs" title={row.original.created_by || undefined}>
{row.original.created_by || "-"}
</span>
),
},
{
id: "is_public",
accessorFn: (row) => row.mcp_info?.is_public === true,
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
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 <StatusBadge tone={isPublic ? "success" : "neutral"} label={isPublic ? "Yes" : "No"} />;
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<MCPHubRowActions server={row.original} onServerClick={onServerClick} />
</div>
),
},
];

View file

@ -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(<ModelHubTable accessToken={null} publicPage={false} premiumUser={false} userRole={null} />);
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);

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-sm text-muted-foreground">{body}</div>
</div>
);
}
const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage, premiumUser, userRole }) => {
@ -67,12 +61,12 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
const canModify = isProxyAdminRole(userRole || "");
const [publicPageAllowed, setPublicPageAllowed] = useState<boolean>(false);
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
const [modelHubData, setModelHubData] = useState<ModelHubData[] | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [isPublicPageModalVisible, setIsPublicPageModalVisible] = useState(false);
const [selectedModel, setSelectedModel] = useState<null | ModelGroupInfo>(null);
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<null | ModelHubData>(null);
const [filteredData, setFilteredData] = useState<ModelHubData[]>([]);
const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false);
// Agent Hub state
const [agentHubData, setAgentHubData] = useState<AgentHubData[] | null>(null);
@ -153,17 +147,23 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ accessToken, publicPage,
useEffect(() => {
const fetchMcpData = async () => {
if (!accessToken) {
setMcpLoading(false);
return;
}
@ -231,20 +232,20 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ 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<ModelHubTableProps> = ({ accessToken, publicPage,
}
};
const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => {
const handleFilteredDataChange = useCallback((newFilteredData: ModelHubData[]) => {
setFilteredData(newFilteredData);
}, []);
const [modelSorting, setModelSorting] = useState<SortingState>([{ id: "model_group", desc: false }]);
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ 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 <PublicModelHub accessToken={accessToken} />;
@ -403,7 +407,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<div className="flex items-center bg-gray-200 px-2 py-1 rounded-sm">
<Text className="mr-2">{`${getProxyBaseUrl()}/ui/model_hub_table`}</Text>
<button
onClick={() => copyToClipboard(`${getProxyBaseUrl()}/ui/model_hub_table`)}
onClick={() => void copyToClipboard(`${getProxyBaseUrl()}/ui/model_hub_table`)}
className="p-1 hover:bg-gray-300 rounded-sm transition-colors"
title="Copy URL"
>
@ -445,11 +449,26 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
<ModelFilters modelHubData={modelHubData || []} onFilteredDataChange={handleFilteredDataChange} />
{/* Model Table */}
<ModelDataTable
columns={modelHubColumns(showModal, copyToClipboard, publicPage)}
<DataTable
data={filteredData}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
sorting={modelSorting}
onSortingChange={setModelSorting}
isLoading={loading}
defaultSorting={[{ id: "model_group", desc: false }]}
loadingMessage="Loading models…"
noDataMessage={
<HubEmptyState
title={modelHubData?.length ? "No matching models" : "No models yet"}
body={
modelHubData?.length
? "Adjust the filters to see more models."
: "Models added to this proxy will appear here."
}
/>
}
size="compact"
/>
</Card>
@ -471,11 +490,19 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
)}
{/* Agent Table */}
<ModelDataTable
columns={getAgentHubTableColumns(showAgentModal, copyToClipboard, publicPage)}
<DataTable
data={agentHubData || []}
columns={agentColumns}
getRowId={(agent, index) => agent.agent_id || agent.name || String(index)}
sortingMode="client"
sorting={agentSorting}
onSortingChange={setAgentSorting}
isLoading={agentLoading}
defaultSorting={[{ id: "name", desc: false }]}
loadingMessage="Loading agents…"
noDataMessage={
<HubEmptyState title="No agents yet" body="Agents added to this proxy will appear here." />
}
size="compact"
/>
</Card>
@ -497,11 +524,22 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
)}
{/* MCP Server Table */}
<ModelDataTable
columns={mcpHubColumns(showMcpModal, copyToClipboard, publicPage)}
<DataTable
data={mcpHubData || []}
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
sortingMode="client"
sorting={mcpSorting}
onSortingChange={setMcpSorting}
isLoading={mcpLoading}
defaultSorting={[{ id: "server_name", desc: false }]}
loadingMessage="Loading MCP servers…"
noDataMessage={
<HubEmptyState
title="No MCP servers yet"
body="MCP servers added to this proxy will appear here."
/>
}
size="compact"
/>
</Card>
@ -746,7 +784,7 @@ print(response.choices[0].message.content)`}
<div className="flex items-center space-x-2">
<Text className="truncate">{selectedAgent.url}</Text>
<CopyOutlined
onClick={() => copyToClipboard(selectedAgent.url)}
onClick={() => void copyToClipboard(selectedAgent.url)}
className="cursor-pointer text-gray-500 hover:text-blue-500"
/>
</div>
@ -877,7 +915,7 @@ print(response.choices[0].message.content)`}
<div className="flex items-center space-x-2">
<Text className="text-xs truncate">{selectedMcpServer.server_id}</Text>
<CopyOutlined
onClick={() => copyToClipboard(selectedMcpServer.server_id)}
onClick={() => void copyToClipboard(selectedMcpServer.server_id)}
className="cursor-pointer text-gray-500 hover:text-blue-500"
/>
</div>

View file

@ -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 { getModelHubTableColumns, ModelHubData } from "./ModelHubTableColumns";
const mockModel: ModelHubData = {
model_group: "gpt-4o",
providers: ["openai", "azure", "bedrock"],
max_input_tokens: 128000,
max_output_tokens: 16384,
input_cost_per_token: 0.0000025,
output_cost_per_token: 0.00001,
mode: "chat",
supports_parallel_function_calling: false,
supports_vision: true,
supports_function_calling: true,
is_public_model_group: true,
};
function renderTable(data: ModelHubData[], onModelClick = vi.fn()) {
render(
<DataTable
data={data}
columns={getModelHubTableColumns({ onModelClick })}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
size="compact"
/>,
);
return onModelClick;
}
describe("getModelHubTableColumns", () => {
it("renders the model row", () => {
renderTable([mockModel]);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
});
it("shows the first two providers and '+1' for overflow", () => {
renderTable([mockModel]);
expect(screen.getByText("openai")).toBeInTheDocument();
expect(screen.getByText("azure")).toBeInTheDocument();
expect(screen.queryByText("bedrock")).not.toBeInTheDocument();
expect(screen.getByText("+1")).toBeInTheDocument();
});
it("formats token limits and per-million costs", () => {
renderTable([mockModel]);
expect(screen.getByText("128.0K / 16.4K")).toBeInTheDocument();
expect(screen.getByText("$2.50")).toBeInTheDocument();
expect(screen.getByText("$10.00")).toBeInTheDocument();
});
it("shows capability badges only for supported features", () => {
renderTable([mockModel]);
expect(screen.getByText("Vision")).toBeInTheDocument();
expect(screen.getByText("Function Calling")).toBeInTheDocument();
expect(screen.queryByText("Parallel Function Calling")).not.toBeInTheDocument();
});
it("shows the public status badge", () => {
renderTable([mockModel]);
expect(screen.getByText("Yes")).toBeInTheDocument();
renderTable([{ ...mockModel, model_group: "private-model", is_public_model_group: false }]);
expect(screen.getByText("No")).toBeInTheDocument();
});
it("opens the model details when the name is clicked", async () => {
const user = userEvent.setup();
const onModelClick = renderTable([mockModel]);
await user.click(screen.getByRole("button", { name: "gpt-4o" }));
expect(onModelClick).toHaveBeenCalledWith(mockModel);
});
it("opens the model details from the actions menu", async () => {
const user = userEvent.setup();
const onModelClick = renderTable([mockModel]);
await user.click(screen.getByTestId("model-hub-actions-gpt-4o"));
await user.click(await screen.findByTestId("model-hub-action-details"));
expect(onModelClick).toHaveBeenCalledWith(mockModel);
});
it("copies the model name from the actions menu", async () => {
const user = userEvent.setup();
renderTable([mockModel]);
await user.click(screen.getByTestId("model-hub-actions-gpt-4o"));
await user.click(await screen.findByTestId("model-hub-action-copy"));
expect(await window.navigator.clipboard.readText()).toBe("gpt-4o");
});
});

View file

@ -0,0 +1,243 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Copy, 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";
export 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) =>
key
.replace(/^supports_/, "")
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
const getModelCapabilities = (model: ModelHubData) =>
Object.entries(model)
.filter(([key, value]) => key.startsWith("supports_") && value === true)
.map(([key]) => key);
const formatCost = (cost: number) => `$${(cost * 1_000_000).toFixed(2)}`;
const formatTokens = (tokens: number) => {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
return tokens.toString();
};
interface ModelHubRowActionsProps {
model: ModelHubData;
onModelClick: (model: ModelHubData) => void;
}
function ModelHubRowActions({ model, onModelClick }: ModelHubRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open model actions"
data-testid={`model-hub-actions-${model.model_group}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="model-hub-action-details" onClick={() => onModelClick(model)}>
<Info />
View details
</DropdownMenuItem>
<DropdownMenuItem
data-testid="model-hub-action-copy"
onClick={() => void copyToClipboard(model.model_group, "Model name copied")}
>
<Copy />
Copy model name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface ModelHubTableColumnsDeps {
onModelClick: (model: ModelHubData) => void;
}
export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDeps): ColumnDef<ModelHubData>[] => [
{
id: "model_group",
accessorKey: "model_group",
meta: { title: "Public Model Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public Model Name" />,
size: 220,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell title={row.original.model_group} className="max-w-72" onClick={() => onModelClick(row.original)} />
),
},
{
id: "providers",
accessorKey: "providers",
meta: { title: "Provider", skeleton: "chips", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Provider" />,
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) => rowA.original.providers.join(", ").localeCompare(rowB.original.providers.join(", ")),
cell: ({ row }) => {
const providers = row.original.providers;
return (
<div className="flex flex-wrap gap-1">
{providers.slice(0, 2).map((provider) => (
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
{providers.length > 2 && <span className="text-xs text-muted-foreground">+{providers.length - 2}</span>}
</div>
);
},
},
{
id: "mode",
accessorKey: "mode",
meta: { title: "Mode", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) =>
row.original.mode ? (
<span className="font-mono text-xs text-muted-foreground">{row.original.mode}</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
),
},
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Tokens", className: "hidden lg:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Tokens" />,
size: 110,
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 (
<span className="text-xs tabular-nums">
{model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} /{" "}
{model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"}
</span>
);
},
},
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Cost/1M", skeleton: "twoLine" },
header: ({ column }) => <DataTableSortHeader column={column} title="Cost/1M" />,
size: 110,
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 (
<div className="flex flex-col gap-0.5 text-xs tabular-nums">
<span>{model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"}</span>
<span className="text-muted-foreground">
{model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"}
</span>
</div>
);
},
},
{
id: "capabilities",
meta: { title: "Features", skeleton: "chips" },
header: "Features",
size: 220,
enableSorting: false,
cell: ({ row }) => {
const capabilities = getModelCapabilities(row.original);
if (capabilities.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{capabilities.map((capability) => (
<Badge key={capability} variant="outline">
{formatCapabilityName(capability)}
</Badge>
))}
</div>
);
},
},
{
id: "is_public_model_group",
accessorKey: "is_public_model_group",
meta: { title: "Public", skeleton: "badge", className: "hidden md:table-cell" },
header: ({ column }) => <DataTableSortHeader column={column} title="Public" />,
size: 100,
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 }) =>
row.original.is_public_model_group === true ? (
<StatusBadge tone="success" label="Yes" />
) : (
<StatusBadge tone="neutral" label="No" />
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<ModelHubRowActions model={row.original} onModelClick={onModelClick} />
</div>
),
},
];

View file

@ -1,10 +1,11 @@
import React, { useMemo, useState } from "react";
import { Text } from "@tremor/react";
import { SearchOutlined } from "@ant-design/icons";
import { SortingState } from "@tanstack/react-table";
import { Input, Select } from "antd";
import { Inbox } from "lucide-react";
import { Plugin } from "@/components/claude_code_plugins/types";
import { ModelDataTable } from "@/components/model_dashboard/table";
import { skillHubColumns } from "@/components/skill_hub_table_columns";
import { DataTable } from "@/components/shared/DataTable";
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
interface SkillHubDashboardProps {
@ -16,6 +17,22 @@ interface SkillHubDashboardProps {
onPublishSuccess?: () => void;
}
function SkillsEmptyState({ filtered }: { filtered: boolean }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">{filtered ? "No matching skills" : "No skills yet"}</div>
<div className="text-sm text-muted-foreground">
{filtered
? "Adjust the search or domain filter to see more skills."
: "Skills added here will appear for developers."}
</div>
</div>
);
}
const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
skills,
isLoading,
@ -27,10 +44,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
const [search, setSearch] = useState("");
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }]);
// Derived stats
const totalSkills = skills.length;
@ -57,6 +71,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
return result;
}, [skills, search, domainFilter]);
const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []);
const hasActiveFilter = search.trim().length > 0 || domainFilter != null;
if (selectedSkill) {
return (
<SkillDetail
@ -69,10 +87,6 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
);
}
if (isLoading) {
return <div className="text-center py-16 text-gray-400">Loading skills...</div>;
}
return (
<div className="space-y-6">
{/* Stats row */}
@ -114,16 +128,22 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
/>
</div>
</div>
<ModelDataTable
columns={skillHubColumns((skill) => setSelectedSkill(skill), copyToClipboard, publicPage)}
<DataTable
data={filteredSkills}
isLoading={false}
defaultSorting={[{ id: "name", desc: false }]}
columns={columns}
getRowId={(skill, index) => skill.id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading skills…"
noDataMessage={<SkillsEmptyState filtered={hasActiveFilter} />}
size="compact"
/>
<div className="mt-3 text-center">
<Text className="text-sm text-gray-500">
<p className="text-sm text-gray-500">
Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""}
</Text>
</p>
</div>
</div>
</div>

View file

@ -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(
<DataTable
data={data}
columns={getSkillHubTableColumns({ onSkillClick })}
getRowId={(skill, index) => 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");
});
});

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open skill actions"
data-testid={`skill-hub-actions-${skill.id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="skill-hub-action-details" onClick={() => onSkillClick(skill)}>
<Info />
View details
</DropdownMenuItem>
<DropdownMenuItem
data-testid="skill-hub-action-copy"
onClick={() => void copyToClipboard(skill.name, "Skill name copied")}
>
<Copy />
Copy skill name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface SkillHubTableColumnsDeps {
onSkillClick: (skill: Plugin) => void;
}
export const getSkillHubTableColumns = ({ onSkillClick }: SkillHubTableColumnsDeps): ColumnDef<Plugin>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Skill Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Skill Name" />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell title={row.original.name} className="max-w-72" onClick={() => onSkillClick(row.original)} />
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 260,
enableSorting: false,
cell: ({ row }) => (
<span className="block max-w-72 truncate text-xs" title={row.original.description || undefined}>
{row.original.description || "-"}
</span>
),
},
{
id: "category",
accessorKey: "category",
meta: { title: "Category", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Category" />,
size: 130,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) =>
row.original.category ? (
<Badge variant="secondary">{row.original.category}</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
),
},
{
id: "domain",
accessorKey: "domain",
meta: { title: "Domain" },
header: ({ column }) => <DataTableSortHeader column={column} title="Domain" />,
size: 130,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => <span className="text-xs">{row.original.domain || "-"}</span>,
},
{
id: "source",
meta: { title: "Source" },
header: "Source",
size: 200,
enableSorting: false,
cell: ({ row }) => {
const link = getSkillSourceLink(row.original);
if (!link) return <span className="text-xs text-muted-foreground">-</span>;
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex max-w-60 items-center gap-1 text-xs text-primary hover:underline"
title={link.label}
>
<span className="truncate">{link.label}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
},
},
{
id: "enabled",
accessorKey: "enabled",
meta: { title: "Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Status" />,
size: 100,
enableSorting: true,
cell: ({ row }) => (
<StatusBadge
tone={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? "Public" : "Draft"}
/>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<SkillHubRowActions skill={row.original} onSkillClick={onSkillClick} />
</div>
),
},
];

View file

@ -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", () => ({

View file

@ -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;

View file

@ -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<string, StatusTone> = {
healthy: "success",
unhealthy: "error",
};
function ProviderChips({ providers }: { providers: string[] }) {
return (
<div className="flex flex-wrap gap-1">
{providers.map((provider) => {
const { logo } = getProviderLogoAndName(provider);
return (
<span key={provider} className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs">
{logo && (
<img
src={logo}
alt={provider}
className="size-3 shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize">{provider}</span>
</span>
);
})}
</div>
);
}
function OverflowChips({ items }: { items: string[] }) {
if (items.length === 0) {
return <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex items-center gap-1">
<Badge variant="secondary">{items[0]}</Badge>
{items.length > 1 && (
<CellTooltip
content={
<div className="space-y-1">
{items.map((item) => (
<div key={item} className="text-xs">
• {item}
</div>
))}
</div>
}
trigger={<span className="cursor-default text-xs text-muted-foreground">+{items.length - 1}</span>}
/>
)}
</div>
);
}
interface PublicModelHubColumnsDeps {
onModelClick: (model: ModelGroupInfo) => void;
}
export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumnsDeps): ColumnDef<ModelGroupInfo>[] => [
{
id: "model_group",
accessorKey: "model_group",
meta: { title: "Model Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Model Name" />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.model_group}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onModelClick(row.original)}
/>
),
},
{
id: "providers",
accessorKey: "providers",
meta: { title: "Providers", skeleton: "chips" },
header: ({ column }) => <DataTableSortHeader column={column} title="Providers" />,
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) =>
(rowA.original.providers ?? []).join(", ").localeCompare((rowB.original.providers ?? []).join(", ")),
cell: ({ row }) => <ProviderChips providers={row.original.providers ?? []} />,
},
{
id: "mode",
accessorKey: "mode",
meta: { title: "Mode" },
header: ({ column }) => <DataTableSortHeader column={column} title="Mode" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<span className="flex items-center gap-2 text-sm">
<span>{getModeIcon(row.original.mode || "")}</span>
<span>{row.original.mode || "Chat"}</span>
</span>
),
},
{
id: "max_input_tokens",
accessorKey: "max_input_tokens",
meta: { title: "Max Input", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Max Input" />,
size: 100,
enableSorting: true,
cell: ({ row }) => <span className="text-sm">{formatTokens(row.original.max_input_tokens)}</span>,
},
{
id: "max_output_tokens",
accessorKey: "max_output_tokens",
meta: { title: "Max Output", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Max Output" />,
size: 100,
enableSorting: true,
cell: ({ row }) => <span className="text-sm">{formatTokens(row.original.max_output_tokens)}</span>,
},
{
id: "input_cost_per_token",
accessorKey: "input_cost_per_token",
meta: { title: "Input $/1M", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Input $/1M" />,
size: 110,
enableSorting: true,
cell: ({ row }) => (
<span className="text-sm">
{row.original.input_cost_per_token ? formatCost(row.original.input_cost_per_token) : "Free"}
</span>
),
},
{
id: "output_cost_per_token",
accessorKey: "output_cost_per_token",
meta: { title: "Output $/1M", numeric: true },
header: ({ column }) => <DataTableSortHeader column={column} title="Output $/1M" />,
size: 110,
enableSorting: true,
cell: ({ row }) => (
<span className="text-sm">
{row.original.output_cost_per_token ? formatCost(row.original.output_cost_per_token) : "Free"}
</span>
),
},
{
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 <OverflowChips items={features} />;
},
},
{
id: "health_status",
accessorKey: "health_status",
meta: { title: "Health Status", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Health Status" />,
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 (
<CellTooltip
content={
<>
<div>{responseTimeLabel}</div>
<div>{lastCheckedLabel}</div>
</>
}
trigger={
<span className="capitalize">
<StatusBadge
tone={HEALTH_TONES[model.health_status ?? ""] || "neutral"}
label={model.health_status ?? "Unknown"}
/>
</span>
}
/>
);
},
},
{
id: "rpm",
accessorKey: "rpm",
meta: { title: "Limits" },
header: ({ column }) => <DataTableSortHeader column={column} title="Limits" />,
size: 150,
enableSorting: true,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{formatLimits(row.original.rpm, row.original.tpm)}</span>
),
},
];
interface PublicAgentHubColumnsDeps {
onAgentClick: (agent: AgentCard) => void;
}
export const getPublicAgentHubColumns = ({ onAgentClick }: PublicAgentHubColumnsDeps): ColumnDef<AgentCard>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Agent Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Agent Name" />,
size: 200,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.name}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => onAgentClick(row.original)}
/>
),
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 260,
enableSorting: false,
cell: ({ row }) => (
<span className="block max-w-72 truncate text-sm" title={row.original.description || undefined}>
{row.original.description || "-"}
</span>
),
},
{
id: "version",
accessorKey: "version",
meta: { title: "Version" },
header: ({ column }) => <DataTableSortHeader column={column} title="Version" />,
size: 90,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => <span className="text-sm">{row.original.version}</span>,
},
{
id: "provider",
meta: { title: "Provider" },
header: "Provider",
size: 130,
enableSorting: false,
cell: ({ row }) =>
row.original.provider ? (
<span className="text-sm font-medium">{row.original.provider.organization}</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
),
},
{
id: "skills",
meta: { title: "Skills", skeleton: "chips" },
header: "Skills",
size: 160,
enableSorting: false,
cell: ({ row }) => <OverflowChips items={(row.original.skills || []).map((skill) => 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 <span className="text-xs text-muted-foreground">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{capabilityList.map((capability) => (
<Badge key={capability} variant="outline" className="capitalize">
{capability}
</Badge>
))}
</div>
);
},
},
];
interface PublicMCPHubColumnsDeps {
onServerClick: (server: MCPServerData) => void;
}
export const getPublicMCPHubColumns = ({ onServerClick }: PublicMCPHubColumnsDeps): ColumnDef<MCPServerData>[] => [
{
id: "server_name",
accessorKey: "server_name",
meta: { title: "Server Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Server Name" />,
size: 180,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<IdentityCell
title={row.original.server_name}
titleClassName="font-mono text-xs font-normal"
className="max-w-72"
onClick={() => 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 (
<span className="block max-w-72 truncate text-sm" title={description}>
{description}
</span>
);
},
},
{
id: "transport",
accessorKey: "transport",
meta: { title: "Transport", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Transport" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<Badge variant="secondary" className="font-mono font-normal uppercase">
{row.original.transport}
</Badge>
),
},
{
id: "auth_type",
accessorKey: "auth_type",
meta: { title: "Auth Type", skeleton: "badge" },
header: ({ column }) => <DataTableSortHeader column={column} title="Auth Type" />,
size: 110,
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => (
<StatusBadge tone={row.original.auth_type === "none" ? "neutral" : "success"} label={row.original.auth_type} />
),
},
];

View file

@ -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>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((h) => (
<th key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
describe("mcpHubColumns", () => {
it("renders the server row", () => {
render(<TestTable data={[mockServer]} />);
expect(screen.getByText("exa_test")).toBeInTheDocument();
});
it("keeps the non-sensitive columns", () => {
render(<TestTable data={[mockServer]} />);
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(<TestTable data={[mockServer]} />);
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(<TestTable data={[mockServer]} />);
expect(screen.queryByText(SERVER_URL)).not.toBeInTheDocument();
});
});

View file

@ -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<string, any>;
static_headers: Record<string, any>;
status: string;
last_health_check?: string | null;
health_check_error?: string | null;
command?: string | null;
args: string[];
env: Record<string, any>;
[key: string]: any;
}
export const mcpHubColumns = (
showModal: (server: MCPServerData) => void,
copyToClipboard: (text: string) => void,
publicPage: boolean = false,
): ColumnDef<MCPServerData>[] => {
const allColumns: ColumnDef<MCPServerData>[] = [
{
header: "Server Name",
accessorKey: "server_name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const server = row.original;
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{server.server_name}</Text>
<Tooltip title="Copy server name">
<CopyOutlined
onClick={() => copyToClipboard(server.server_name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show description on mobile */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">{server.description || "-"}</Text>
</div>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const server = row.original;
return <Text className="text-xs line-clamp-2">{server.description || "-"}</Text>;
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Transport",
accessorKey: "transport",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const server = row.original;
return (
<Badge color="blue" size="sm">
{server.transport}
</Badge>
);
},
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 (
<Badge color={authColor} size="sm">
{server.auth_type}
</Badge>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Status",
accessorKey: "status",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const server = row.original;
const statusTones: Record<string, StatusTone> = {
active: "success",
inactive: "error",
unknown: "neutral",
healthy: "success",
unhealthy: "error",
};
const tone = statusTones[server.status] || "neutral";
return <StatusBadge tone={tone} label={server.status || "unknown"} />;
},
},
{
header: "Tools",
accessorKey: "allowed_tools",
enableSorting: false,
cell: ({ row }) => {
const server = row.original;
const tools = server.allowed_tools || [];
return (
<div className="space-y-1">
<Text className="text-xs font-medium">
{tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"}
</Text>
{tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{tools.slice(0, 2).map((tool, idx) => (
<Tag key={idx} color="purple" className="text-xs">
{tool}
</Tag>
))}
{tools.length > 2 && <Text className="text-xs text-gray-500">+{tools.length - 2}</Text>}
</div>
)}
</div>
);
},
meta: {
className: "hidden lg:table-cell",
},
},
{
header: "Created By",
accessorKey: "created_by",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const server = row.original;
return <Text className="text-xs">{server.created_by || "-"}</Text>;
},
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 ? (
<Badge color="green" size="xs">
Yes
</Badge>
) : (
<Badge color="gray" size="xs">
No
</Badge>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Details",
id: "details",
enableSorting: false,
cell: ({ row }) => {
const server = row.original;
return (
<Button size="xs" variant="secondary" onClick={() => showModal(server)} icon={InfoCircleOutlined}>
<span className="hidden lg:inline">Details</span>
<span className="lg:hidden">Info</span>
</Button>
);
},
},
];
return allColumns;
};

View file

@ -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<ModelHubData>[] => {
const allColumns: ColumnDef<ModelHubData>[] = [
{
header: "Public Model Name",
accessorKey: "model_group",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const model = row.original;
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Text className="font-medium text-sm">{model.model_group}</Text>
<Tooltip title="Copy model name">
<CopyOutlined
onClick={() => copyToClipboard(model.model_group)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{/* Show provider on mobile when provider column is hidden */}
<div className="md:hidden">
<Text className="text-xs text-gray-600">{model.providers.join(", ")}</Text>
</div>
</div>
);
},
},
{
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 (
<div className="flex flex-wrap gap-1">
{model.providers.slice(0, 2).map((provider) => (
<Tag key={provider} color="blue" className="text-xs">
{provider}
</Tag>
))}
{model.providers.length > 2 && <Text className="text-xs text-gray-500">+{model.providers.length - 2}</Text>}
</div>
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Mode",
accessorKey: "mode",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const model = row.original;
return model.mode ? (
<Badge color="green" size="sm">
{model.mode}
</Badge>
) : (
<Text className="text-gray-500">-</Text>
);
},
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 (
<div className="space-y-1">
<Text className="text-xs">
{model.max_input_tokens ? formatTokens(model.max_input_tokens) : "-"} /{" "}
{model.max_output_tokens ? formatTokens(model.max_output_tokens) : "-"}
</Text>
</div>
);
},
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 (
<div className="space-y-1">
<Text className="text-xs">{model.input_cost_per_token ? formatCost(model.input_cost_per_token) : "-"}</Text>
<Text className="text-xs text-gray-500">
{model.output_cost_per_token ? formatCost(model.output_cost_per_token) : "-"}
</Text>
</div>
);
},
},
{
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 (
<div className="flex flex-wrap gap-1">
{capabilities.length === 0 ? (
<Text className="text-gray-500 text-xs">-</Text>
) : (
capabilities.map((capability, index) => (
<Badge key={capability} color={colors[index % colors.length]} size="xs">
{formatCapabilityName(capability)}
</Badge>
))
)}
</div>
);
},
},
{
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 ? (
<StatusBadge tone="success" label="Yes" />
) : (
<StatusBadge tone="neutral" label="No" />
);
},
meta: {
className: "hidden md:table-cell",
},
},
{
header: "Details",
id: "details",
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
return (
<Button size="xs" variant="secondary" onClick={() => showModal(model)} icon={InfoCircleOutlined}>
<span className="hidden lg:inline">Details</span>
<span className="lg:hidden">Info</span>
</Button>
);
},
},
];
// 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;
};

View file

@ -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(<PublicMcpTestTable data={[mockMcpServer]} />);
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", () => {

View file

@ -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<MCPServerData>[] => [
{
header: "Server Name",
accessorKey: "server_name",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.server_name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showMcpModal(row.original)}
>
{row.original.server_name}
</Button>
</Tooltip>
function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
),
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 (
<Tooltip title={description}>
<Text className="text-sm text-gray-700">{truncated}</Text>
</Tooltip>
);
},
size: 250,
},
{
header: "Transport",
accessorKey: "transport",
enableSorting: true,
cell: ({ row }) => {
const transport = row.original.transport;
return (
<Tag color="blue" className="text-xs uppercase">
{transport}
</Tag>
);
},
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 (
<Tag color={color} className="text-xs capitalize">
{authType}
</Tag>
);
},
size: 100,
},
];
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-sm text-muted-foreground">{body}</div>
</div>
);
}
const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded = false }) => {
const [modelHubData, setModelHubData] = useState<ModelGroupInfo[] | null>(null);
@ -503,10 +392,10 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ 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<PublicModelHubProps> = ({ 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<SortingState>([{ id: "model_group", desc: false }]);
const [agentSorting, setAgentSorting] = useState<SortingState>([{ id: "name", desc: false }]);
const [mcpSorting, setMcpSorting] = useState<SortingState>([{ 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<ModelGroupInfo>[] => [
{
header: "Model Name",
accessorKey: "model_group",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.model_group}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showModal(row.original)}
>
{row.original.model_group}
</Button>
</Tooltip>
</div>
),
size: 150,
},
{
header: "Providers",
accessorKey: "providers",
enableSorting: true,
cell: ({ row }) => {
const providers = row.original.providers ?? [];
return (
<div className="flex flex-wrap gap-1">
{providers.map((provider) => {
const { logo } = getProviderLogoAndName(provider);
return (
<div key={provider} className="flex items-center space-x-1 px-2 py-1 bg-gray-100 rounded-sm text-xs">
{logo && (
<img
src={logo}
alt={provider}
className="w-3 h-3 shrink-0 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="capitalize">{provider}</span>
</div>
);
})}
</div>
);
},
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 (
<div className="flex items-center space-x-2">
<span>{getModeIcon(mode || "")}</span>
<Text>{mode || "Chat"}</Text>
</div>
);
},
size: 100,
},
{
header: "Max Input",
accessorKey: "max_input_tokens",
enableSorting: true,
cell: ({ row }) => <Text className="text-center">{formatTokens(row.original.max_input_tokens)}</Text>,
size: 100,
meta: {
className: "text-center",
},
},
{
header: "Max Output",
accessorKey: "max_output_tokens",
enableSorting: true,
cell: ({ row }) => <Text className="text-center">{formatTokens(row.original.max_output_tokens)}</Text>,
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 <Text className="text-center">{cost ? formatCost(cost) : "Free"}</Text>;
},
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 <Text className="text-center">{cost ? formatCost(cost) : "Free"}</Text>;
},
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 <Text className="text-gray-400">-</Text>;
}
if (features.length === 1) {
return (
<div className="h-6 flex items-center">
<Tag color="blue" className="text-xs">
{features[0]}
</Tag>
</div>
);
}
return (
<div className="h-6 flex items-center space-x-1">
<Tag color="blue" className="text-xs">
{features[0]}
</Tag>
<Tooltip
title={
<div className="space-y-1">
<div className="font-medium">All Features:</div>
{features.map((feature, index) => (
<div key={index} className="text-xs">
• {feature}
</div>
))}
</div>
}
trigger="click"
placement="topLeft"
>
<span
className="text-xs text-blue-600 cursor-pointer hover:text-blue-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
+{features.length - 1}
</span>
</Tooltip>
</div>
);
},
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 (
<Tooltip
title={
<>
<div>{responseTimeLabel}</div>
<div>{lastCheckedLabel}</div>
</>
}
>
<Tag key={original.model_group} color={tagColor}>
<span className="capitalize">{original.health_status ?? "Unknown"}</span>
</Tag>
</Tooltip>
);
},
size: 100,
},
{
header: "Limits",
accessorKey: "rpm",
enableSorting: true,
cell: ({ row }) => {
const model = row.original;
return <Text className="text-xs text-gray-600">{formatLimits(model.rpm, model.tpm)}</Text>;
},
size: 150,
},
];
const publicAgentHubColumns = (): ColumnDef<AgentCard>[] => [
{
header: "Agent Name",
accessorKey: "name",
enableSorting: true,
cell: ({ row }) => (
<div className="overflow-hidden">
<Tooltip title={row.original.name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
onClick={() => showAgentModal(row.original)}
>
{row.original.name}
</Button>
</Tooltip>
</div>
),
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 (
<Tooltip title={description}>
<Text className="text-sm text-gray-700">{truncated}</Text>
</Tooltip>
);
},
size: 250,
},
{
header: "Version",
accessorKey: "version",
enableSorting: true,
cell: ({ row }) => <Text className="text-sm">{row.original.version}</Text>,
size: 80,
},
{
header: "Provider",
accessorKey: "provider",
enableSorting: false,
cell: ({ row }) => {
const provider = row.original.provider;
if (!provider) return <Text className="text-gray-400">-</Text>;
return (
<div className="text-sm">
<Text className="font-medium">{provider.organization}</Text>
</div>
);
},
size: 120,
},
{
header: "Skills",
accessorKey: "skills",
enableSorting: false,
cell: ({ row }) => {
const skills = row.original.skills || [];
if (skills.length === 0) {
return <Text className="text-gray-400">-</Text>;
}
if (skills.length === 1) {
return (
<div className="h-6 flex items-center">
<Tag color="purple" className="text-xs">
{skills[0].name}
</Tag>
</div>
);
}
return (
<div className="h-6 flex items-center space-x-1">
<Tag color="purple" className="text-xs">
{skills[0].name}
</Tag>
<Tooltip
title={
<div className="space-y-1">
<div className="font-medium">All Skills:</div>
{skills.map((skill, index) => (
<div key={index} className="text-xs">
• {skill.name}
</div>
))}
</div>
}
trigger="click"
placement="topLeft"
>
<span
className="text-xs text-purple-600 cursor-pointer hover:text-purple-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
+{skills.length - 1}
</span>
</Tooltip>
</div>
);
},
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 <Text className="text-gray-400">-</Text>;
}
return (
<div className="flex flex-wrap gap-1">
{capList.map((cap) => (
<Tag key={cap} color="green" className="text-xs capitalize">
{cap}
</Tag>
))}
</div>
);
},
size: 150,
},
];
const modelColumns = useMemo(() => getPublicModelHubColumns({ onModelClick: showModal }), [showModal]);
const agentColumns = useMemo(() => getPublicAgentHubColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
const mcpColumns = useMemo(() => getPublicMCPHubColumns({ onServerClick: showMcpModal }), [showMcpModal]);
return (
<ThemeProvider accessToken={accessToken}>
@ -1132,11 +649,26 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<ModelDataTable
columns={publicModelHubColumns()}
<DataTable
data={filteredData}
columns={modelColumns}
getRowId={(model, index) => model.model_group || String(index)}
sortingMode="client"
sorting={modelSorting}
onSortingChange={setModelSorting}
isLoading={loading}
defaultSorting={[{ id: "model_group", desc: false }]}
loadingMessage="Loading models…"
noDataMessage={
<PublicHubEmptyState
title={modelHubData?.length ? "No matching models" : "No models available"}
body={
modelHubData?.length
? "Adjust the search or filters to see more models."
: "Models made public by the proxy admin will appear here."
}
/>
}
size="compact"
/>
<div className="mt-8 text-center">
@ -1195,11 +727,22 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<ModelDataTable
columns={publicAgentHubColumns()}
<DataTable
data={filteredAgentData}
columns={agentColumns}
getRowId={(agent, index) => agent.name || String(index)}
sortingMode="client"
sorting={agentSorting}
onSortingChange={setAgentSorting}
isLoading={agentLoading}
defaultSorting={[{ id: "name", desc: false }]}
loadingMessage="Loading agents…"
noDataMessage={
<PublicHubEmptyState
title="No matching agents"
body="Adjust the search or skill filter to see more agents."
/>
}
size="compact"
/>
<div className="mt-8 text-center">
@ -1259,11 +802,22 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
</div>
<ModelDataTable
columns={publicMCPHubColumns(showMcpModal)}
<DataTable
data={filteredMcpData}
columns={mcpColumns}
getRowId={(server, index) => server.server_id || String(index)}
sortingMode="client"
sorting={mcpSorting}
onSortingChange={setMcpSorting}
isLoading={mcpLoading}
defaultSorting={[{ id: "server_name", desc: false }]}
loadingMessage="Loading MCP servers…"
noDataMessage={
<PublicHubEmptyState
title="No matching MCP servers"
body="Adjust the search or transport filter to see more servers."
/>
}
size="compact"
/>
<div className="mt-8 text-center">

View file

@ -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<Plugin>[] => [
{
header: "Skill Name",
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const skill = row.original;
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<button
type="button"
className="font-medium text-sm cursor-pointer text-blue-600 hover:underline bg-transparent border-none p-0"
onClick={() => showModal(skill)}
>
{skill.name}
</button>
<Tooltip title="Copy skill name">
<CopyOutlined
onClick={() => copyToClipboard(skill.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
{skill.description && (
<Text className="text-xs text-gray-500 line-clamp-1 md:hidden">{skill.description}</Text>
)}
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
enableSorting: false,
cell: ({ row }) => <Text className="text-xs line-clamp-2">{row.original.description || "-"}</Text>,
},
{
header: "Category",
accessorKey: "category",
enableSorting: true,
cell: ({ row }) => {
const cat = row.original.category;
if (!cat) return <Text className="text-xs text-gray-400">-</Text>;
return (
<Badge color="blue" size="xs">
{cat}
</Badge>
);
},
},
{
header: "Domain",
accessorKey: "domain",
enableSorting: true,
cell: ({ row }) => <Text className="text-xs">{row.original.domain || "-"}</Text>,
},
{
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 <Text className="text-xs text-gray-400">-</Text>;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:underline truncate max-w-[180px]"
title={label}
>
<span className="truncate">{label}</span>
<LinkOutlined className="shrink-0" style={{ fontSize: 10 }} />
</a>
);
},
},
{
header: "Status",
accessorKey: "enabled",
enableSorting: true,
cell: ({ row }) => (
<StatusBadge
tone={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? "Public" : "Draft"}
/>
),
},
];