Skip to main content
Glama
ClientDetailPage.jsx31.5 kB
import { useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { clients, apiKeys, tools, resources } from '../services/api' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/hooks/use-toast' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' import { ArrowLeft, Plus, Copy, Settings, Key, Shield, Pickaxe, BookOpenText, BarChart3, Trash2, MessageSquare } from 'lucide-react' import ToolConfigurationDialog from '@/components/ToolConfigurationDialog' import ResourceConfigurationDialog from '@/components/ResourceConfigurationDialog' import ToolCallsStats from '@/components/ToolCallsStats' import ToolCallsTable from '@/components/ToolCallsTable' import SystemPromptPanel from '@/components/SystemPromptPanel' import config from '@/config' export default function ClientDetailPage() { const { clientId } = useParams() const navigate = useNavigate() const queryClient = useQueryClient() const { toast } = useToast() const [showKeyDialog, setShowKeyDialog] = useState(false) const [newKeyName, setNewKeyName] = useState('') const [selectedKey, setSelectedKey] = useState(null) const [showKeyRemoveDialog, setShowKeyRemoveDialog] = useState(false) const [selectedTool, setSelectedTool] = useState(null) const [showToolDialog, setShowToolDialog] = useState(false) const [showToolRemoveDialog, setShowToolRemoveDialog] = useState(false) const [selectedResource, setSelectedResource] = useState(null) const [showResourceDialog, setShowResourceDialog] = useState(false) const [showResourceRemoveDialog, setShowResourceRemoveDialog] = useState(false) const [selectedToolToAdd, setSelectedToolToAdd] = useState(null) const [selectedResourceToAdd, setSelectedResourceToAdd] = useState(null) const { data: client, isLoading } = useQuery({ queryKey: ['client', clientId], queryFn: () => clients.get(clientId), }) const createKeyMutation = useMutation({ mutationFn: (data) => apiKeys.create(clientId, data), onSuccess: (data) => { queryClient.invalidateQueries(['client', clientId]) setShowKeyDialog(false) setNewKeyName('') toast({ title: "API key created", description: `Successfully created "${data.name}".`, }) }, onError: (error) => { toast({ title: "Error creating API key", description: error.message, variant: "destructive", }) }, }) const deleteKeyMutation = useMutation({ mutationFn: (keyValue) => apiKeys.delete(keyValue), onSuccess: () => { queryClient.invalidateQueries(['client', clientId]) setShowKeyRemoveDialog(false) setSelectedKey(null) toast({ title: "API key deleted", description: `Successfully deleted "${selectedKey?.name}".`, }) }, onError: (error) => { toast({ title: "Error deleting API key", description: error.message, variant: "destructive", }) }, }) const removeToolMutation = useMutation({ mutationFn: (toolName) => tools.delete(clientId, toolName), onSuccess: () => { queryClient.invalidateQueries(['client', clientId]) setShowToolRemoveDialog(false) setSelectedTool(null) toast({ title: "Tool removed", description: `Successfully removed ${selectedTool?.name}.`, }) }, onError: (error) => { toast({ title: "Error removing tool", description: error.message, variant: "destructive", }) }, }) const addResourceMutation = useMutation({ mutationFn: ({ resourceName, config }) => resources.configure(clientId, resourceName, config), onSuccess: (data, variables) => { queryClient.invalidateQueries(['client', clientId]) setSelectedResourceToAdd(null) toast({ title: "Resource added", description: `Successfully added ${variables.resourceName}.`, }) }, onError: (error) => { toast({ title: "Error adding resource", description: error.message, variant: "destructive", }) }, }) const removeResourceMutation = useMutation({ mutationFn: (resourceName) => resources.delete(clientId, resourceName), onSuccess: () => { queryClient.invalidateQueries(['client', clientId]) setShowResourceRemoveDialog(false) setSelectedResource(null) toast({ title: "Resource removed", description: `Successfully removed ${selectedResource?.name}.`, }) }, onError: (error) => { toast({ title: "Error removing resource", description: error.message, variant: "destructive", }) }, }) const handleCreateKey = (e) => { e.preventDefault() createKeyMutation.mutate({ name: newKeyName }) } const handleKeyRemove = (key) => { setSelectedKey(key) setShowKeyRemoveDialog(true) } const addToolMutation = useMutation({ mutationFn: ({ toolName, config }) => tools.configure(clientId, toolName, config), onSuccess: (data, variables) => { queryClient.invalidateQueries(['client', clientId]) setSelectedToolToAdd(null) toast({ title: "Tool added", description: `Successfully added ${variables.toolName}.`, }) }, onError: (error) => { toast({ title: "Error adding tool", description: error.message, variant: "destructive", }) }, }) const handleToolAdd = (tool) => { // For tools that don't require config, add them directly addToolMutation.mutate({ toolName: tool.name, config: null }) } const handleToolConfigure = (tool) => { setSelectedTool(tool) setShowToolDialog(true) } const handleToolRemove = (tool) => { setSelectedTool(tool) setShowToolRemoveDialog(true) } const handleResourceAdd = (resource) => { // For resources that don't require config, add them directly addResourceMutation.mutate({ resourceName: resource.name, config: null }) } const handleResourceConfigure = (resource) => { setSelectedResource(resource) setShowResourceDialog(true) } const handleResourceRemove = (resource) => { setSelectedResource(resource) setShowResourceRemoveDialog(true) } const copyToClipboard = async (text, description) => { try { await navigator.clipboard.writeText(text) toast({ title: "Copied to clipboard", description: description, }) } catch (error) { toast({ title: "Failed to copy", description: "Could not copy to clipboard.", variant: "destructive", }) } } if (isLoading) { return ( <div className="min-h-screen flex items-center justify-center"> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> </div> ) } return ( <div className="min-h-screen"> {/* Header */} <div className="border-b"> <div className="container mx-auto px-4"> <div className="flex items-center py-4 sm:py-6"> <Button variant="ghost" onClick={() => navigate('/')} className="mr-3 sm:mr-4" > <ArrowLeft className="mr-2 h-4 w-4" /> <span className="hidden sm:inline">Back</span> </Button> <div> <h1 className="text-2xl sm:text-3xl font-bold">{client?.client?.name}</h1> {client?.client?.description && ( <p className="text-muted-foreground text-sm sm:text-base">{client.client.description}</p> )} </div> </div> </div> </div> <div className="container mx-auto px-4 py-4 sm:py-8"> <Tabs defaultValue="configuration" className="space-y-6"> <TabsList className="grid w-full grid-cols-3"> <TabsTrigger value="configuration">Configuration</TabsTrigger> <TabsTrigger value="analytics">Analytics</TabsTrigger> <TabsTrigger value="logs">Tool Logs</TabsTrigger> </TabsList> <TabsContent value="configuration" className="space-y-6"> {/* API Keys and System Prompt - Side by side */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 lg:gap-8"> {/* API Keys */} <Card> <CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div> <CardTitle className="flex items-center text-lg sm:text-xl"> <Key className="mr-2 h-4 sm:h-5 w-4 sm:w-5" /> API Keys </CardTitle> <CardDescription className="text-sm">Manage API keys for this client</CardDescription> </div> <Button onClick={() => setShowKeyDialog(true)} className="w-full sm:w-auto"> <Plus className="mr-2 h-4 w-4" /> Add Key </Button> </CardHeader> <CardContent> <div className="space-y-4"> {client?.api_keys?.map((key) => ( <div key={key.id} className="p-3 sm:p-4 border rounded-lg"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3 sm:gap-2 mb-2"> <div className="flex-1 min-w-0"> <h3 className="font-medium text-sm sm:text-base">{key.name}</h3> <p className="text-xs text-muted-foreground font-mono break-all mt-1"> {key.key_value} </p> <p className="text-xs text-muted-foreground mt-1"> Created: {new Date(key.created_at).toLocaleDateString()} </p> </div> <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={() => copyToClipboard( config.getMcpUrl(key.key_value), "MCP URL copied to clipboard" )} className="flex-shrink-0" > <Copy className="h-4 w-4 mr-2 sm:mr-0" /> <span className="sm:hidden">Copy MCP URL</span> </Button> <Button variant="outline" size="sm" onClick={() => handleKeyRemove(key)} className="flex-shrink-0 text-destructive hover:text-destructive" > <Trash2 className="h-4 w-4 mr-1 sm:mr-0" /> <span className="sm:hidden">Delete</span> </Button> </div> </div> </div> ))} </div> </CardContent> </Card> {/* System Prompt */} {client?.client && <SystemPromptPanel client={client.client} copyToClipboard={copyToClipboard} />} </div> {/* Tools and Resources - Side by side */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 lg:gap-8"> {/* Tools */} <Card> <CardHeader className="space-y-4"> <div> <CardTitle className="flex items-center text-lg sm:text-xl"> <Settings className="mr-2 h-4 sm:h-5 w-4 sm:w-5" /> Tools </CardTitle> <CardDescription className="text-sm">Configure available tools for this client</CardDescription> </div> {/* Add tool dropdown - only show if there are available tools to add */} {client?.tools?.filter(t => !t.is_configured).length > 0 && ( <div className="flex gap-2"> <Select value={selectedToolToAdd?.name || ''} onValueChange={(toolName) => { const tool = client.tools.find(t => t.name === toolName) setSelectedToolToAdd(tool) }} > <SelectTrigger className="flex-1"> <SelectValue placeholder="Select a tool to add..." /> </SelectTrigger> <SelectContent> {client?.tools ?.filter(tool => !tool.is_configured) .map(tool => ( <SelectItem key={tool.name} value={tool.name}> <div className="flex items-center gap-2"> <span>{tool.name}</span> <span className={`px-2 py-0.5 text-xs rounded-full font-medium ${ tool.name.includes('core/') ? 'bg-purple-100 text-blue-700' : 'bg-blue-100 text-purple-700' }`}> {tool.name.includes('core/') ? 'CORE' : 'CUSTOM'} </span> {tool.requires_config && ( <span className="text-xs text-blue-600">Config required</span> )} </div> </SelectItem> ))} </SelectContent> </Select> <Button onClick={() => { if (selectedToolToAdd) { if (selectedToolToAdd.requires_config) { handleToolConfigure(selectedToolToAdd) } else { handleToolAdd(selectedToolToAdd) } setSelectedToolToAdd(null) } }} disabled={!selectedToolToAdd || addToolMutation.isPending} > <Plus className="mr-2 h-4 w-4" /> Add </Button> </div> )} </CardHeader> <CardContent> <div className="space-y-3"> {/* Only show enabled tools */} {client?.tools?.filter(tool => tool.is_configured).length === 0 ? ( <p className="text-sm text-muted-foreground text-center py-4"> No tools enabled yet. Use the dropdown above to add tools. </p> ) : ( client?.tools?.filter(tool => tool.is_configured).map((tool) => ( <div key={tool.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 p-3 border rounded-lg"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2 mb-1"> <h3 className="font-medium text-sm sm:text-base">{tool.name}</h3> <span className={`px-2 py-0.5 text-xs rounded-full font-medium ${ tool.name.includes('core/') ? 'bg-blue-100 text-purple-700' : 'bg-purple-100 text-blue-700' }`}> {tool.name.includes('core/') ? 'CORE' : 'CUSTOM'} </span> </div> {tool.description && ( <p className="text-xs text-muted-foreground mt-1">{tool.description}</p> )} {tool.requires_config && ( <p className="text-xs text-blue-600 mt-1">Requires configuration</p> )} </div> <div className="flex items-center justify-between sm:justify-end gap-2 sm:space-x-2"> <div className="flex gap-2"> {tool.requires_config && ( <Button variant="outline" size="sm" onClick={() => handleToolConfigure(tool)} className="flex-shrink-0" > Configure </Button> )} <Button variant="outline" size="sm" onClick={() => handleToolRemove(tool)} className="flex-shrink-0 text-destructive hover:text-destructive" > <Trash2 className="h-4 w-4 mr-1 sm:mr-0" /> <span className="sm:hidden">Remove</span> </Button> </div> </div> </div> )) )} </div> </CardContent> </Card> {/* Resources */} <Card> <CardHeader className="space-y-4"> <div> <CardTitle className="flex items-center text-lg sm:text-xl"> <BookOpenText className="mr-2 h-4 sm:h-5 w-4 sm:w-5" /> Resources </CardTitle> <CardDescription className="text-sm">Configure available resources for this client</CardDescription> </div> {/* Add resource dropdown - only show if there are available resources to add */} {client?.resources?.filter(r => !r.is_configured).length > 0 && ( <div className="flex gap-2"> <Select value={selectedResourceToAdd?.name || ''} onValueChange={(resourceName) => { const resource = client.resources.find(r => r.name === resourceName) setSelectedResourceToAdd(resource) }} > <SelectTrigger className="flex-1"> <SelectValue placeholder="Select a resource to add..." /> </SelectTrigger> <SelectContent> {client?.resources ?.filter(resource => !resource.is_configured) .map(resource => ( <SelectItem key={resource.name} value={resource.name}> <div className="flex items-center gap-2"> <span>{resource.name}</span> <span className={`px-2 py-0.5 text-xs rounded-full font-medium ${ resource.name.includes('/') ? 'bg-purple-100 text-blue-700' : 'bg-blue-100 text-purple-700' }`}> {resource.name.includes('/') ? 'CUSTOM' : 'CORE'} </span> {resource.requires_config && ( <span className="text-xs text-blue-600">Config required</span> )} </div> </SelectItem> ))} </SelectContent> </Select> <Button onClick={() => { if (selectedResourceToAdd) { if (selectedResourceToAdd.requires_config) { handleResourceConfigure(selectedResourceToAdd) } else { handleResourceAdd(selectedResourceToAdd) } setSelectedResourceToAdd(null) } }} disabled={!selectedResourceToAdd || addResourceMutation.isPending} > <Plus className="mr-2 h-4 w-4" /> Add </Button> </div> )} </CardHeader> <CardContent> <div className="space-y-3"> {/* Only show enabled resources */} {client?.resources?.filter(resource => resource.is_configured).length === 0 ? ( <p className="text-sm text-muted-foreground text-center py-4"> No resources enabled yet. Use the dropdown above to add resources. </p> ) : ( client?.resources?.filter(resource => resource.is_configured).map((resource) => ( <div key={resource.name} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 p-3 border rounded-lg"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2 mb-1"> <h3 className="font-medium text-sm sm:text-base">{resource.name}</h3> <span className={`px-2 py-0.5 text-xs rounded-full font-medium ${ resource.name.includes('/') ? 'bg-purple-100 text-blue-700' : 'bg-blue-100 text-purple-700' }`}> {resource.name.includes('/') ? 'CUSTOM' : 'CORE'} </span> </div> {resource.description && ( <p className="text-xs text-muted-foreground mt-1">{resource.description}</p> )} {resource.requires_config && ( <p className="text-xs text-blue-600 mt-1">Requires configuration</p> )} </div> <div className="flex items-center justify-between sm:justify-end gap-2 sm:space-x-2"> <div className="flex gap-2"> {resource.requires_config && ( <Button variant="outline" size="sm" onClick={() => handleResourceConfigure(resource)} className="flex-shrink-0" > Configure </Button> )} <Button variant="outline" size="sm" onClick={() => handleResourceRemove(resource)} className="flex-shrink-0 text-destructive hover:text-destructive" > <Trash2 className="h-4 w-4 mr-1 sm:mr-0" /> <span className="sm:hidden">Remove</span> </Button> </div> </div> </div> )) )} </div> </CardContent> </Card> </div> </TabsContent> <TabsContent value="analytics" className="space-y-6"> <ToolCallsStats clientId={clientId} title={`Tool Statistics for ${client?.client?.name}`} /> </TabsContent> <TabsContent value="logs" className="space-y-6"> <ToolCallsTable clientId={clientId} title={`Tool Call History for ${client?.client?.name}`} /> </TabsContent> </Tabs> </div> {/* Tool Configuration Dialog */} <ToolConfigurationDialog tool={selectedTool} clientId={clientId} isOpen={showToolDialog} onOpenChange={(open) => { setShowToolDialog(open) // Clear the selected tool to add when dialog closes if (!open) { setSelectedToolToAdd(null) } }} /> {/* Resource Configuration Dialog */} <ResourceConfigurationDialog resource={selectedResource} clientId={clientId} isOpen={showResourceDialog} onOpenChange={(open) => { setShowResourceDialog(open) // Clear the selected resource to add when dialog closes if (!open) { setSelectedResourceToAdd(null) } }} /> {/* Create API Key Dialog */} <Dialog open={showKeyDialog} onOpenChange={setShowKeyDialog}> <DialogContent className="w-[95vw] max-w-[425px]"> <DialogHeader> <DialogTitle>Create API Key</DialogTitle> <DialogDescription> Add a new API key for this client. </DialogDescription> </DialogHeader> <form onSubmit={handleCreateKey} className="space-y-4"> <div className="space-y-2"> <Label htmlFor="create-key-name">Key name</Label> <Input id="create-key-name" placeholder="Enter key name" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} required /> </div> <DialogFooter className="flex-col sm:flex-row gap-2"> <Button type="button" variant="outline" onClick={() => setShowKeyDialog(false)} className="w-full sm:w-auto" > Cancel </Button> <Button type="submit" disabled={createKeyMutation.isPending} className="w-full sm:w-auto" > {createKeyMutation.isPending ? 'Creating...' : 'Create'} </Button> </DialogFooter> </form> </DialogContent> </Dialog> {/* API Key Remove Confirmation Dialog */} <AlertDialog open={showKeyRemoveDialog} onOpenChange={setShowKeyRemoveDialog}> <AlertDialogContent className="w-[95vw] max-w-[425px]"> <AlertDialogHeader> <AlertDialogTitle>Delete API Key</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to delete the API key "{selectedKey?.name}"? This action cannot be undone and any applications using this key will lose access. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className="flex-col sm:flex-row gap-2"> <AlertDialogCancel onClick={() => setShowKeyRemoveDialog(false)} className="w-full sm:w-auto" > Cancel </AlertDialogCancel> <AlertDialogAction onClick={() => { if (selectedKey) { deleteKeyMutation.mutate(selectedKey.key_value) } }} disabled={deleteKeyMutation.isPending} className="w-full sm:w-auto bg-destructive hover:bg-destructive/90" > {deleteKeyMutation.isPending ? 'Deleting...' : 'Delete'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> {/* Tool Remove Confirmation Dialog */} <AlertDialog open={showToolRemoveDialog} onOpenChange={setShowToolRemoveDialog}> <AlertDialogContent className="w-[95vw] max-w-[425px]"> <AlertDialogHeader> <AlertDialogTitle>Remove Tool</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to remove "{selectedTool?.name}" from this client? This will disable the tool and remove any configuration. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className="flex-col sm:flex-row gap-2"> <AlertDialogCancel onClick={() => setShowToolRemoveDialog(false)} className="w-full sm:w-auto" > Cancel </AlertDialogCancel> <AlertDialogAction onClick={() => { if (selectedTool) { removeToolMutation.mutate(selectedTool.name) } }} disabled={removeToolMutation.isPending} className="w-full sm:w-auto bg-destructive hover:bg-destructive/90" > {removeToolMutation.isPending ? 'Removing...' : 'Remove'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> {/* Resource Remove Confirmation Dialog */} <AlertDialog open={showResourceRemoveDialog} onOpenChange={setShowResourceRemoveDialog}> <AlertDialogContent className="w-[95vw] max-w-[425px]"> <AlertDialogHeader> <AlertDialogTitle>Remove Resource</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to remove "{selectedResource?.name}" from this client? This will disable the resource and remove any configuration. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className="flex-col sm:flex-row gap-2"> <AlertDialogCancel onClick={() => setShowResourceRemoveDialog(false)} className="w-full sm:w-auto" > Cancel </AlertDialogCancel> <AlertDialogAction onClick={() => { if (selectedResource) { removeResourceMutation.mutate(selectedResource.name) } }} disabled={removeResourceMutation.isPending} className="w-full sm:w-auto bg-destructive hover:bg-destructive/90" > {removeResourceMutation.isPending ? 'Removing...' : 'Remove'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> </div> ) }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GeorgeStrakhov/mcpeasy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server