Files
bifrost/ui/app/workspace/mcp-registry/views/mcpClientsTable.tsx
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

348 lines
12 KiB
TypeScript

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>
);
}