mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
07656cf80b
commit
5daed34749
24 changed files with 2200 additions and 1738 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
227
ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx
Normal file
227
ui/litellm-dashboard/src/components/AIHub/MCPHubTableColumns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue