first commit
This commit is contained in:
348
ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
Normal file
348
ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import ClientForm from "@/app/workspace/mcp-registry/views/mcpClientForm";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { MCP_STATUS_COLORS } from "@/lib/constants/config";
|
||||
import { getErrorMessage, useDeleteMCPClientMutation, useReconnectMCPClientMutation } from "@/lib/store";
|
||||
import { MCPClient } from "@/lib/types/mcp";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { ChevronLeft, ChevronRight, Loader2, Plus, RefreshCcw, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { MCPServersEmptyState } from "./mcpServersEmptyState";
|
||||
import MCPClientSheet from "./mcpClientSheet";
|
||||
|
||||
interface MCPClientsTableProps {
|
||||
mcpClients: MCPClient[];
|
||||
totalCount: number;
|
||||
refetch?: () => void;
|
||||
search: string;
|
||||
debouncedSearch: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onOffsetChange: (offset: number) => void;
|
||||
}
|
||||
|
||||
export default function MCPClientsTable({
|
||||
mcpClients,
|
||||
totalCount,
|
||||
refetch,
|
||||
search,
|
||||
debouncedSearch,
|
||||
onSearchChange,
|
||||
offset,
|
||||
limit,
|
||||
onOffsetChange,
|
||||
}: MCPClientsTableProps) {
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const hasCreateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Create);
|
||||
const hasUpdateMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Update);
|
||||
const hasDeleteMCPClientAccess = useRbac(RbacResource.MCPGateway, RbacOperation.Delete);
|
||||
const [selectedMCPClient, setSelectedMCPClient] = useState<MCPClient | null>(null);
|
||||
const [showDetailSheet, setShowDetailSheet] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [reconnectingClients, setReconnectingClients] = useState<string[]>([]);
|
||||
|
||||
// RTK Query mutations
|
||||
const [reconnectMCPClient] = useReconnectMCPClientMutation();
|
||||
const [deleteMCPClient] = useDeleteMCPClientMutation();
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleReconnect = async (client: MCPClient) => {
|
||||
try {
|
||||
setReconnectingClients((prev) => [...prev, client.config.client_id]);
|
||||
await reconnectMCPClient(client.config.client_id).unwrap();
|
||||
setReconnectingClients((prev) => prev.filter((id) => id !== client.config.client_id));
|
||||
toast({ title: "Reconnected", description: `Client ${client.config.name} reconnected successfully.` });
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
setReconnectingClients((prev) => prev.filter((id) => id !== client.config.client_id));
|
||||
toast({ title: "Error", description: getErrorMessage(error), variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (client: MCPClient) => {
|
||||
try {
|
||||
await deleteMCPClient(client.config.client_id).unwrap();
|
||||
toast({ title: "Deleted", description: `Client ${client.config.name} removed successfully.` });
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "Error", description: getErrorMessage(error), variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaved = async () => {
|
||||
setFormOpen(false);
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionDisplay = (client: MCPClient) => {
|
||||
if (client.config.connection_type === "stdio") {
|
||||
return `${client.config.stdio_config?.command} ${client.config.stdio_config?.args.join(" ")}` || "STDIO";
|
||||
}
|
||||
// connection_string is now an EnvVar, display the value or env_var reference
|
||||
const connStr = client.config.connection_string;
|
||||
if (connStr) {
|
||||
return connStr.from_env ? connStr.env_var : connStr.value || `${client.config.connection_type.toUpperCase()}`;
|
||||
}
|
||||
return `${client.config.connection_type.toUpperCase()}`;
|
||||
};
|
||||
|
||||
const getConnectionTypeDisplay = (type: string) => {
|
||||
switch (type) {
|
||||
case "http":
|
||||
return "HTTP";
|
||||
case "sse":
|
||||
return "SSE";
|
||||
case "stdio":
|
||||
return "STDIO";
|
||||
default:
|
||||
return type.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (mcpClient: MCPClient) => {
|
||||
setSelectedMCPClient(mcpClient);
|
||||
setShowDetailSheet(true);
|
||||
};
|
||||
|
||||
const handleDetailSheetClose = () => {
|
||||
setShowDetailSheet(false);
|
||||
setSelectedMCPClient(null);
|
||||
};
|
||||
|
||||
const handleEditTools = async () => {
|
||||
setShowDetailSheet(false);
|
||||
setSelectedMCPClient(null);
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = debouncedSearch;
|
||||
|
||||
// True empty state: no servers at all (not just filtered to zero)
|
||||
if (totalCount === 0 && !hasActiveFilters) {
|
||||
return (
|
||||
<>
|
||||
{formOpen && <ClientForm open={formOpen} onClose={() => setFormOpen(false)} onSaved={handleSaved} />}
|
||||
<MCPServersEmptyState onAddClick={handleCreate} canCreate={hasCreateMCPClientAccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showDetailSheet && selectedMCPClient && (
|
||||
<MCPClientSheet mcpClient={selectedMCPClient} onClose={handleDetailSheetClose} onSubmitSuccess={handleEditTools} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">MCP Server Catalog</h2>
|
||||
<p className="text-muted-foreground text-sm">Manage servers that can connect to the MCP Tools endpoint.</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={!hasCreateMCPClientAccess} data-testid="create-mcp-client-btn" aria-label="New MCP Server" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New MCP Server</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
aria-label="Search MCP servers by name"
|
||||
placeholder="Search by name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
data-testid="mcp-clients-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-sm border">
|
||||
<Table data-testid="mcp-clients-table">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="font-semibold">Name</TableHead>
|
||||
<TableHead className="font-semibold">Connection Type</TableHead>
|
||||
<TableHead className="font-semibold">Code Mode</TableHead>
|
||||
<TableHead className="font-semibold">Connection Info</TableHead>
|
||||
<TableHead className="font-semibold">Enabled Tools</TableHead>
|
||||
<TableHead className="font-semibold">Auto-execute Tools</TableHead>
|
||||
<TableHead className="font-semibold">State</TableHead>
|
||||
<TableHead className="w-20 text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mcpClients.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center">
|
||||
<span className="text-muted-foreground text-sm">No matching MCP servers found.</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
mcpClients.map((c: MCPClient) => {
|
||||
const enabledToolsCount =
|
||||
c.state == "connected"
|
||||
? c.config.tools_to_execute?.includes("*")
|
||||
? c.tools?.length
|
||||
: (c.config.tools_to_execute?.length ?? 0)
|
||||
: 0;
|
||||
const autoExecuteToolsCount =
|
||||
c.state == "connected"
|
||||
? c.config.tools_to_auto_execute?.includes("*")
|
||||
? c.tools?.length
|
||||
: (c.config.tools_to_auto_execute?.length ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<TableRow
|
||||
key={c.config.client_id}
|
||||
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => handleRowClick(c)}
|
||||
>
|
||||
<TableCell className="font-medium">{c.config.name}</TableCell>
|
||||
<TableCell data-testid="mcp-client-connection-type">{getConnectionTypeDisplay(c.config.connection_type)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
c.state == "connected" ? MCP_STATUS_COLORS[c.config.is_code_mode_client ? "connected" : "disconnected"] : ""
|
||||
}
|
||||
>
|
||||
{c.state == "connected" ? <>{c.config.is_code_mode_client ? "Enabled" : "Disabled"}</> : "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-72 overflow-hidden text-ellipsis whitespace-nowrap">{getConnectionDisplay(c)}</TableCell>
|
||||
<TableCell>
|
||||
{c.state == "connected" ? (
|
||||
<>
|
||||
{enabledToolsCount}/{c.tools?.length}
|
||||
</>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.state == "connected" ? (
|
||||
<>
|
||||
{autoExecuteToolsCount}/{c.tools?.length}
|
||||
</>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={MCP_STATUS_COLORS[c.state]}>{c.state}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleReconnect(c)}
|
||||
disabled={reconnectingClients.includes(c.config.client_id) || !hasUpdateMCPClientAccess}
|
||||
title="Reconnect"
|
||||
>
|
||||
{reconnectingClients.includes(c.config.client_id) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/30"
|
||||
disabled={!hasDeleteMCPClientAccess}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove MCP Server</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove MCP server {c.config.name}? You will need to reconnect the server to continue
|
||||
using it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(c)} className="bg-destructive hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||||
data-testid="mcp-clients-pagination-prev-btn"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + limit >= totalCount}
|
||||
onClick={() => onOffsetChange(offset + limit)}
|
||||
data-testid="mcp-clients-pagination-next-btn"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && <ClientForm open={formOpen} onClose={() => setFormOpen(false)} onSaved={handleSaved} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user