Checkpoint: Full Development Complete: All 4 Phases

## Phase 1 (Fixed): Agent Management UI
- Исправлена авторизация: agents переведены на publicProcedure
- AgentDetailModal: 5 вкладок (General, LLM Params, Tools, History, Stats)
- Полное редактирование: model, provider, temperature, topP, maxTokens, frequencyPenalty, presencePenalty, systemPrompt
- Управление allowedTools и allowedDomains через теги
- AgentCreateModal: создание агентов с выбором модели из Ollama API
- Кнопка Metrics на каждой карточке агента

## Phase 2+3: Tool Binding System
- server/tools.ts: реестр из 10 инструментов (http_get, http_post, shell_exec, file_read, file_write, docker_list, docker_exec, docker_logs, browser_navigate, browser_screenshot)
- Безопасное выполнение: проверка allowedTools агента, accessControl из БД
- tools.execute tRPC endpoint
- Tools.tsx: страница управления инструментами с тест-выполнением
- Добавлен пункт "Инструменты" в sidebar навигацию

## Phase 4: Metrics & History
- AgentMetrics.tsx: детальная страница метрик по агенту
- Request Timeline: bar chart по часам (success/error)
- Conversation Log: история диалогов с пагинацией
- Raw Metrics Table: все метрики с токенами и временем
- Time range selector: 6h/24h/48h/7d
- Маршрут /agents/:id/metrics

## Tests: 24/24 passed
- server/auth.logout.test.ts (1)
- server/agents.test.ts (7)
- server/tools.test.ts (13)
- server/ollama.test.ts (3)
This commit is contained in:
Manus
2026-03-20 16:52:27 -04:00
parent 159a89a156
commit 86a1ee9062
11 changed files with 1786 additions and 311 deletions

View File

@@ -7,9 +7,12 @@ import { ThemeProvider } from "./contexts/ThemeContext";
import DashboardLayout from "./components/DashboardLayout";
import Dashboard from "./pages/Dashboard";
import Agents from "./pages/Agents";
import AgentMetrics from "./pages/AgentMetrics";
import Chat from "./pages/Chat";
import Settings from "./pages/Settings";
import Nodes from "./pages/Nodes";
import Tools from "./pages/Tools";
function Router() {
// make sure to consider if you need authentication for certain routes
return (
@@ -17,8 +20,10 @@ function Router() {
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/agents" component={Agents} />
<Route path="/agents/:id/metrics" component={AgentMetrics} />
<Route path="/nodes" component={Nodes} />
<Route path="/chat" component={Chat} />
<Route path="/tools" component={Tools} />
<Route path="/settings" component={Settings} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -7,9 +7,17 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
import { Loader2, Save, X } from "lucide-react";
import { Loader2, Save, Plus, X, Clock, CheckCircle, XCircle, Zap } from "lucide-react";
interface Agent {
id: number;
@@ -40,343 +48,447 @@ interface AgentDetailModalProps {
onSave?: () => void;
}
const PROVIDERS = ["Ollama", "OpenAI", "Anthropic", "Mistral", "Groq"];
const TOOL_OPTIONS = ["http_get", "http_post", "shell_exec", "file_read", "file_write", "docker_list", "docker_exec", "docker_logs", "browser_navigate"];
function toNum(v: string | number | null | undefined, fallback = 0): number {
if (v === null || v === undefined) return fallback;
const n = typeof v === "string" ? parseFloat(v) : v;
return isNaN(n) ? fallback : n;
}
export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDetailModalProps) {
const [formData, setFormData] = useState<Partial<Agent>>(agent ? { ...agent } : {});
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState<any>({});
const [newTool, setNewTool] = useState("");
const [newDomain, setNewDomain] = useState("");
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (agent) setForm({ ...agent });
}, [agent]);
const { data: modelsData } = trpc.ollama.models.useQuery();
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
);
const { data: stats } = trpc.agents.stats.useQuery(
{ id: agent?.id ?? 0, hoursBack: 24 },
{ enabled: !!agent && open }
);
const updateMutation = trpc.agents.update.useMutation({
onSuccess: () => {
toast.success("Agent configuration updated");
toast.success("Agent configuration saved");
onOpenChange(false);
onSave?.();
},
onError: (error) => {
toast.error(`Failed to update agent: ${error.message}`);
},
onError: (err) => toast.error(`Save failed: ${err.message}`),
});
const handleSave = async () => {
if (!agent) return;
setIsLoading(true);
setIsSaving(true);
try {
await updateMutation.mutateAsync({
id: agent.id,
name: formData.name || agent.name,
description: formData.description || undefined,
temperature: typeof formData.temperature === "string" ? parseFloat(formData.temperature) : (formData.temperature || 0.7),
maxTokens: formData.maxTokens || 2048,
systemPrompt: formData.systemPrompt || undefined,
isActive: formData.isActive !== undefined ? formData.isActive : true,
name: form.name || agent.name,
description: form.description || undefined,
model: form.model,
provider: form.provider,
temperature: toNum(form.temperature, 0.7),
maxTokens: form.maxTokens || 2048,
topP: toNum(form.topP, 1.0),
frequencyPenalty: toNum(form.frequencyPenalty, 0),
presencePenalty: toNum(form.presencePenalty, 0),
systemPrompt: form.systemPrompt || undefined,
allowedTools: form.allowedTools || [],
allowedDomains: form.allowedDomains || [],
isActive: form.isActive ?? true,
tags: form.tags || [],
});
} finally {
setIsLoading(false);
setIsSaving(false);
}
};
const addTool = () => {
if (!newTool) return;
const tools: string[] = form.allowedTools || [];
if (!tools.includes(newTool)) {
setForm({ ...form, allowedTools: [...tools, newTool] });
}
setNewTool("");
};
const removeTool = (tool: string) => {
setForm({ ...form, allowedTools: (form.allowedTools || []).filter((t: string) => t !== tool) });
};
const addDomain = () => {
if (!newDomain.trim()) return;
const domains: string[] = form.allowedDomains || [];
if (!domains.includes(newDomain.trim())) {
setForm({ ...form, allowedDomains: [...domains, newDomain.trim()] });
}
setNewDomain("");
};
const removeDomain = (d: string) => {
setForm({ ...form, allowedDomains: (form.allowedDomains || []).filter((x: string) => x !== d) });
};
const availableModels: string[] = modelsData?.models?.map((m: any) => m.id || m) ?? [];
if (!agent) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Agent Configuration: {agent.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
className="h-6 w-6 p-0"
>
<X className="w-4 h-4" />
</Button>
<DialogTitle className="font-mono text-primary flex items-center gap-2">
<span className="text-muted-foreground text-xs">AGENT</span>
{agent.name}
<Badge variant="outline" className={`text-[10px] ml-2 ${form.isActive ? "border-neon-green/40 text-neon-green" : "border-muted text-muted-foreground"}`}>
{form.isActive ? "ACTIVE" : "INACTIVE"}
</Badge>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-5 bg-secondary/30">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="llm">LLM</TabsTrigger>
<TabsTrigger value="llm">LLM Params</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
</TabsList>
{/* General Tab */}
<TabsContent value="general" className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Agent Name</Label>
<Input
id="name"
value={formData.name ?? ""}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="font-mono"
/>
{/* ── GENERAL ─────────────────────────────────── */}
<TabsContent value="general" className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">NAME</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} className="font-mono" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">ROLE</Label>
<Input value={form.role ?? ""} disabled className="font-mono bg-secondary/30 text-muted-foreground" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description ?? ""}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="font-mono text-sm"
/>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">DESCRIPTION</Label>
<Textarea value={form.description ?? ""} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} className="font-mono text-sm resize-none" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Input
id="role"
value={formData.role || ""}
disabled
className="font-mono bg-muted"
/>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">PROVIDER</Label>
<Select value={form.provider ?? ""} onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}>
<SelectTrigger className="font-mono"><SelectValue /></SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<div className="flex items-center gap-2">
<Badge variant={formData.isActive ? "default" : "secondary"}>
{formData.isActive ? "Active" : "Inactive"}
</Badge>
<Button
size="sm"
variant="outline"
onClick={() => setFormData({ ...formData, isActive: !formData.isActive })}
>
Toggle
</Button>
</div>
</div>
</div>
</TabsContent>
{/* LLM Tab */}
<TabsContent value="llm" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Input
id="model"
value={formData.model || ""}
disabled
className="font-mono bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Input
id="provider"
value={formData.provider || ""}
disabled
className="font-mono bg-muted"
/>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">MODEL</Label>
<Select value={form.model ?? ""} onValueChange={(v) => setForm({ ...form, model: v })}>
<SelectTrigger className="font-mono"><SelectValue placeholder="Select model..." /></SelectTrigger>
<SelectContent>
{availableModels.length > 0
? availableModels.map((m: string) => <SelectItem key={m} value={m}>{m}</SelectItem>)
: <SelectItem value={form.model ?? ""}>{form.model ?? "No models"}</SelectItem>
}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="temperature">
Temperature: {typeof formData.temperature === "string" ? parseFloat(formData.temperature).toFixed(2) : formData.temperature?.toFixed(2)}
</Label>
<Input
id="temperature"
type="range"
min="0"
max="2"
step="0.01"
value={typeof formData.temperature === "string" ? parseFloat(formData.temperature) : formData.temperature || 0.7}
onChange={(e) => setFormData({ ...formData, temperature: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxTokens">Max Tokens</Label>
<Input
id="maxTokens"
type="number"
value={formData.maxTokens || 2048}
onChange={(e) => setFormData({ ...formData, maxTokens: parseInt(e.target.value) })}
className="font-mono"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="topP">
Top P: {typeof formData.topP === "string" ? parseFloat(formData.topP).toFixed(2) : formData.topP?.toFixed(2)}
</Label>
<Input
id="topP"
type="range"
min="0"
max="1"
step="0.01"
value={typeof formData.topP === "string" ? parseFloat(formData.topP) : formData.topP || 1.0}
onChange={(e) => setFormData({ ...formData, topP: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="frequencyPenalty">
Freq Penalty: {typeof formData.frequencyPenalty === "string" ? parseFloat(formData.frequencyPenalty).toFixed(2) : formData.frequencyPenalty?.toFixed(2)}
</Label>
<Input
id="frequencyPenalty"
type="range"
min="-2"
max="2"
step="0.01"
value={typeof formData.frequencyPenalty === "string" ? parseFloat(formData.frequencyPenalty) : formData.frequencyPenalty || 0}
onChange={(e) => setFormData({ ...formData, frequencyPenalty: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="presencePenalty">
Pres Penalty: {typeof formData.presencePenalty === "string" ? parseFloat(formData.presencePenalty).toFixed(2) : formData.presencePenalty?.toFixed(2)}
</Label>
<Input
id="presencePenalty"
type="range"
min="-2"
max="2"
step="0.01"
value={typeof formData.presencePenalty === "string" ? parseFloat(formData.presencePenalty) : formData.presencePenalty || 0}
onChange={(e) => setFormData({ ...formData, presencePenalty: parseFloat(e.target.value) })}
className="cursor-pointer"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="systemPrompt">System Prompt</Label>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">SYSTEM PROMPT</Label>
<Textarea
id="systemPrompt"
value={formData.systemPrompt || ""}
onChange={(e) => setFormData({ ...formData, systemPrompt: e.target.value })}
value={form.systemPrompt ?? ""}
onChange={(e) => setForm({ ...form, systemPrompt: e.target.value })}
rows={5}
className="font-mono text-sm"
placeholder="Enter the system prompt that defines agent behavior..."
className="font-mono text-sm resize-none"
placeholder="Define agent behavior and instructions..."
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-secondary/20 border border-border/30">
<div>
<div className="text-sm font-medium">Agent Active</div>
<div className="text-xs text-muted-foreground">Enable/disable this agent</div>
</div>
<Switch checked={form.isActive ?? true} onCheckedChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</TabsContent>
{/* Tools Tab */}
<TabsContent value="tools" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Allowed Tools</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.allowedTools && formData.allowedTools.length > 0 ? (
formData.allowedTools.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No tools assigned</p>
)}
{/* ── LLM PARAMS ──────────────────────────────── */}
<TabsContent value="llm" className="space-y-5 pt-2">
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TEMPERATURE</Label>
<span className="text-xs font-mono text-primary">{toNum(form.temperature, 0.7).toFixed(2)}</span>
</div>
</CardContent>
</Card>
<input type="range" min="0" max="2" step="0.01"
value={toNum(form.temperature, 0.7)}
onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0 (precise)</span><span>2.0 (creative)</span>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">Allowed Domains</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.allowedDomains && formData.allowedDomains.length > 0 ? (
formData.allowedDomains.map((domain) => (
<Badge key={domain} variant="outline">
{domain}
</Badge>
))
) : (
<p className="text-sm text-muted-foreground">No domain restrictions</p>
)}
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">TOP P</Label>
<span className="text-xs font-mono text-primary">{toNum(form.topP, 1.0).toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.01"
value={toNum(form.topP, 1.0)}
onChange={(e) => setForm({ ...form, topP: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>0.0</span><span>1.0</span>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">MAX TOKENS</Label>
<Input type="number" value={form.maxTokens ?? 2048}
onChange={(e) => setForm({ ...form, maxTokens: parseInt(e.target.value) || 2048 })}
className="font-mono" min={1} max={128000} />
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">FREQUENCY PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.frequencyPenalty, 0).toFixed(2)}</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
value={toNum(form.frequencyPenalty, 0)}
onChange={(e) => setForm({ ...form, frequencyPenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>-2.0</span><span>+2.0</span>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground font-mono">PRESENCE PENALTY</Label>
<span className="text-xs font-mono text-primary">{toNum(form.presencePenalty, 0).toFixed(2)}</span>
</div>
<input type="range" min="-2" max="2" step="0.01"
value={toNum(form.presencePenalty, 0)}
onChange={(e) => setForm({ ...form, presencePenalty: parseFloat(e.target.value) })}
className="w-full accent-cyan-400 cursor-pointer" />
<div className="flex justify-between text-[10px] text-muted-foreground font-mono">
<span>-2.0</span><span>+2.0</span>
</div>
</div>
</div>
<Card className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="text-xs text-muted-foreground font-mono mb-2">PARAMETER GUIDE</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div><span className="text-primary font-mono">temperature</span> randomness (0=deterministic, 2=very random)</div>
<div><span className="text-primary font-mono">top_p</span> nucleus sampling (0.9=top 90% tokens)</div>
<div><span className="text-primary font-mono">freq_penalty</span> reduce word repetition</div>
<div><span className="text-primary font-mono">pres_penalty</span> encourage topic diversity</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── TOOLS ───────────────────────────────────── */}
<TabsContent value="tools" className="space-y-4 pt-2">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground font-mono">ALLOWED TOOLS</Label>
<div className="flex gap-2">
<Select value={newTool} onValueChange={setNewTool}>
<SelectTrigger className="font-mono flex-1"><SelectValue placeholder="Select tool..." /></SelectTrigger>
<SelectContent>
{TOOL_OPTIONS.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={addTool} disabled={!newTool}>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedTools || []).length === 0
? <span className="text-xs text-muted-foreground">No tools assigned all tools blocked</span>
: (form.allowedTools || []).map((tool: string) => (
<Badge key={tool} variant="outline" className="font-mono text-[11px] gap-1 border-primary/30 text-primary">
{tool}
<button onClick={() => removeTool(tool)} className="hover:text-neon-red transition-colors">
<X className="w-3 h-3" />
</button>
</Badge>
))
}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="maxRequestsPerHour">Max Requests Per Hour</Label>
<Input
id="maxRequestsPerHour"
type="number"
value={formData.maxRequestsPerHour || 100}
disabled
className="font-mono bg-muted"
/>
<Label className="text-xs text-muted-foreground font-mono">ALLOWED DOMAINS (HTTP whitelist)</Label>
<div className="flex gap-2">
<Input
placeholder="e.g. api.github.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addDomain()}
className="font-mono flex-1"
/>
<Button size="sm" variant="outline" onClick={addDomain} disabled={!newDomain.trim()}>
<Plus className="w-4 h-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-md bg-secondary/20 border border-border/30">
{(form.allowedDomains || []).length === 0
? <span className="text-xs text-muted-foreground">No domain restrictions all domains allowed</span>
: (form.allowedDomains || []).map((d: string) => (
<Badge key={d} variant="outline" className="font-mono text-[11px] gap-1 border-neon-amber/30 text-neon-amber">
{d}
<button onClick={() => removeDomain(d)} className="hover:text-neon-red transition-colors">
<X className="w-3 h-3" />
</button>
</Badge>
))
}
</div>
</div>
</TabsContent>
{/* Info Tab */}
<TabsContent value="info" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Metadata</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm font-mono">
<div className="flex justify-between">
<span className="text-muted-foreground">Created:</span>
<span>{new Date(agent.createdAt).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Updated:</span>
<span>{new Date(agent.updatedAt).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Agent ID:</span>
<span>{agent.id}</span>
</div>
{/* ── HISTORY ─────────────────────────────────── */}
<TabsContent value="history" className="pt-2">
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
{(history as any[]).length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
No conversation history yet
</div>
</CardContent>
</Card>
) : (
(history as any[]).map((item: any) => (
<Card key={item.id} className="bg-secondary/20 border-border/30">
<CardContent className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{item.status === "success"
? <CheckCircle className="w-3.5 h-3.5 text-neon-green" />
: <XCircle className="w-3.5 h-3.5 text-neon-red" />
}
<span className="text-[10px] font-mono text-muted-foreground">
{new Date(item.createdAt).toLocaleString()}
</span>
</div>
{item.conversationId && (
<span className="text-[10px] font-mono text-muted-foreground">
conv: {item.conversationId.slice(0, 8)}
</span>
)}
</div>
<div className="text-xs">
<div className="text-muted-foreground font-mono mb-1">USER:</div>
<div className="text-foreground bg-secondary/30 rounded p-2 font-mono text-[11px] line-clamp-2">
{item.userMessage}
</div>
</div>
{item.agentResponse && (
<div className="text-xs">
<div className="text-primary font-mono mb-1">AGENT:</div>
<div className="text-foreground bg-primary/5 border border-primary/10 rounded p-2 font-mono text-[11px] line-clamp-3">
{item.agentResponse}
</div>
</div>
)}
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
{formData.tags && formData.tags.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* ── STATS ───────────────────────────────────── */}
<TabsContent value="stats" className="pt-2">
{stats ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{[
{ label: "Total Requests (24h)", value: stats.totalRequests, icon: Zap, color: "text-primary" },
{ label: "Success Rate", value: `${stats.successRate.toFixed(1)}%`, icon: CheckCircle, color: "text-neon-green" },
{ label: "Avg Processing", value: `${stats.avgProcessingTime}ms`, icon: Clock, color: "text-neon-amber" },
{ label: "Total Tokens", value: stats.totalTokens.toLocaleString(), icon: Zap, color: "text-primary" },
].map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="flex items-center gap-2 mb-1">
<Icon className={`w-3.5 h-3.5 ${color}`} />
<span className="text-[10px] text-muted-foreground font-mono">{label.toUpperCase()}</span>
</div>
<div className={`text-xl font-bold font-mono ${color}`}>{value}</div>
</CardContent>
</Card>
))}
</div>
<Card className="bg-secondary/20 border-border/30">
<CardContent className="p-3">
<div className="text-[10px] text-muted-foreground font-mono mb-2">REQUEST BREAKDOWN</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-neon-green font-mono">Success</span>
<span className="font-mono">{stats.successRequests}</span>
</div>
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-green rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.successRequests / stats.totalRequests) * 100 : 0}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-neon-red font-mono">Errors</span>
<span className="font-mono">{stats.errorRequests}</span>
</div>
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-neon-red rounded-full transition-all"
style={{ width: `${stats.totalRequests > 0 ? (stats.errorRequests / stats.totalRequests) * 100 : 0}%` }}
/>
</div>
</div>
</CardContent>
</Card>
<div className="text-[10px] text-muted-foreground font-mono text-center">
Agent ID: {agent.id} · Created: {new Date(agent.createdAt).toLocaleDateString()} · Updated: {new Date(agent.updatedAt).toLocaleDateString()}
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm">
No statistics available yet
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
<div className="flex justify-end gap-2 pt-2 border-t border-border/30">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || updateMutation.isPending}
className="bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25">
{isSaving || updateMutation.isPending
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving...</>
: <><Save className="w-4 h-4 mr-2" />Save Changes</>
}
</Button>
<Button onClick={handleSave} disabled={isLoading || updateMutation.isPending}>
{isLoading || updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);

View File

@@ -18,6 +18,7 @@ import {
Cpu,
HardDrive,
Wifi,
Wrench,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { motion, AnimatePresence } from "framer-motion";
@@ -25,6 +26,7 @@ import { motion, AnimatePresence } from "framer-motion";
const NAV_ITEMS = [
{ path: "/", icon: LayoutDashboard, label: "Дашборд" },
{ path: "/agents", icon: Bot, label: "Агенты" },
{ path: "/tools", icon: Wrench, label: "Инструменты" },
{ path: "/nodes", icon: Server, label: "Ноды" },
{ path: "/chat", icon: MessageSquare, label: "Чат" },
{ path: "/settings", icon: Settings, label: "Настройки" },

View File

@@ -0,0 +1,344 @@
/*
* AgentMetrics — Detailed metrics and conversation history for a specific agent
* Design: Charts + timeline + stats cards
*/
import { useState } from "react";
import { useRoute, Link } from "wouter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ArrowLeft,
Loader2,
CheckCircle,
XCircle,
Clock,
Zap,
MessageSquare,
TrendingUp,
AlertCircle,
} from "lucide-react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
export default function AgentMetrics() {
const [, params] = useRoute("/agents/:id/metrics");
const agentId = parseInt(params?.id ?? "0");
const [hoursBack, setHoursBack] = useState(24);
const { data: agent, isLoading: agentLoading } = trpc.agents.get.useQuery(
{ id: agentId },
{ enabled: agentId > 0 }
);
const { data: stats, isLoading: statsLoading } = trpc.agents.stats.useQuery(
{ id: agentId, hoursBack },
{ enabled: agentId > 0 }
);
const { data: metrics = [], isLoading: metricsLoading } = trpc.agents.metrics.useQuery(
{ id: agentId, hoursBack },
{ enabled: agentId > 0 }
);
const { data: history = [], isLoading: historyLoading } = trpc.agents.history.useQuery(
{ id: agentId, limit: 50 },
{ enabled: agentId > 0 }
);
if (agentLoading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
if (!agent) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle className="w-12 h-12 text-muted-foreground" />
<p className="text-muted-foreground">Agent not found</p>
<Link href="/agents">
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Agents
</Button>
</Link>
</div>
);
}
const typedMetrics = metrics as any[];
const typedHistory = history as any[];
// Build hourly buckets for the bar chart
const hourlyBuckets: Record<number, { success: number; error: number }> = {};
for (let h = hoursBack - 1; h >= 0; h--) {
hourlyBuckets[h] = { success: 0, error: 0 };
}
typedMetrics.forEach((m: any) => {
const ageHours = Math.floor((Date.now() - new Date(m.createdAt).getTime()) / 3600000);
if (ageHours < hoursBack) {
const bucket = hourlyBuckets[ageHours];
if (bucket) {
if (m.status === "success") bucket.success++;
else bucket.error++;
}
}
});
const maxBucket = Math.max(...Object.values(hourlyBuckets).map((b) => b.success + b.error), 1);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/agents">
<Button variant="outline" size="sm" className="h-8 border-border/50">
<ArrowLeft className="w-4 h-4 mr-1" />
Agents
</Button>
</Link>
<div>
<h2 className="text-xl font-bold text-foreground font-mono">{(agent as any).name}</h2>
<p className="text-xs text-muted-foreground font-mono mt-0.5">
{(agent as any).model} · {(agent as any).provider} · ID:{agentId}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{[6, 24, 48, 168].map((h) => (
<Button
key={h}
size="sm"
variant={hoursBack === h ? "default" : "outline"}
className={`h-7 text-[11px] font-mono ${hoursBack === h ? "bg-primary/20 text-primary border-primary/40" : "border-border/50"}`}
onClick={() => setHoursBack(h)}
>
{h < 24 ? `${h}h` : h === 168 ? "7d" : `${h / 24}d`}
</Button>
))}
</div>
</div>
{/* Stats cards */}
{statsLoading ? (
<div className="grid grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => (
<Card key={i} className="bg-card border-border/50 animate-pulse h-20" />
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Total Requests", value: stats.totalRequests, icon: Zap, color: "text-primary" },
{ label: "Success Rate", value: `${stats.successRate.toFixed(1)}%`, icon: CheckCircle, color: "text-neon-green" },
{ label: "Avg Response", value: `${stats.avgProcessingTime}ms`, icon: Clock, color: "text-neon-amber" },
{ label: "Total Tokens", value: stats.totalTokens.toLocaleString(), icon: TrendingUp, color: "text-purple-400" },
].map(({ label, value, icon: Icon, color }, i) => (
<motion.div key={label} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.08 }}>
<Card className="bg-card border-border/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-4 h-4 ${color}`} />
<span className="text-[10px] text-muted-foreground font-mono">{label.toUpperCase()}</span>
</div>
<div className={`text-2xl font-bold font-mono ${color}`}>{value}</div>
<div className="text-[10px] text-muted-foreground font-mono mt-1">Last {hoursBack}h</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
) : null}
<Tabs defaultValue="chart">
<TabsList className="bg-secondary/30">
<TabsTrigger value="chart">Request Timeline</TabsTrigger>
<TabsTrigger value="history">Conversation Log</TabsTrigger>
<TabsTrigger value="raw">Raw Metrics</TabsTrigger>
</TabsList>
{/* ── CHART ─────────────────────────────────── */}
<TabsContent value="chart" className="pt-4">
<Card className="bg-card border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-mono text-muted-foreground">
REQUESTS PER HOUR (last {hoursBack}h)
</CardTitle>
</CardHeader>
<CardContent>
{metricsLoading ? (
<div className="h-40 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : (
<div className="flex items-end gap-0.5 h-40 overflow-x-auto pb-2">
{Object.entries(hourlyBuckets)
.sort(([a], [b]) => parseInt(b) - parseInt(a))
.map(([hour, counts]) => {
const total = counts.success + counts.error;
const heightPct = (total / maxBucket) * 100;
const successPct = total > 0 ? (counts.success / total) * 100 : 0;
return (
<div
key={hour}
className="flex flex-col items-center gap-0.5 flex-1 min-w-[8px] group relative"
title={`${hour}h ago: ${counts.success} success, ${counts.error} errors`}
>
<div
className="w-full rounded-t-sm overflow-hidden flex flex-col justify-end transition-all"
style={{ height: `${Math.max(heightPct, total > 0 ? 4 : 0)}%`, minHeight: total > 0 ? "4px" : "0" }}
>
<div className="w-full bg-neon-green/70" style={{ height: `${successPct}%` }} />
<div className="w-full bg-neon-red/70" style={{ height: `${100 - successPct}%` }} />
</div>
{/* Tooltip */}
{total > 0 && (
<div className="absolute bottom-full mb-1 hidden group-hover:block z-10 bg-popover border border-border rounded p-1.5 text-[10px] font-mono whitespace-nowrap shadow-lg">
<div className="text-muted-foreground">{hour}h ago</div>
<div className="text-neon-green"> {counts.success}</div>
<div className="text-neon-red"> {counts.error}</div>
</div>
)}
</div>
);
})}
</div>
)}
<div className="flex items-center gap-4 mt-2 text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1">
<div className="w-3 h-2 bg-neon-green/70 rounded-sm" />
<span>Success</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-2 bg-neon-red/70 rounded-sm" />
<span>Error</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── HISTORY ─────────────────────────────────── */}
<TabsContent value="history" className="pt-4">
<div className="space-y-2">
{historyLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : typedHistory.length === 0 ? (
<Card className="bg-card border-border/50">
<CardContent className="p-8 text-center">
<MessageSquare className="w-8 h-8 mx-auto mb-2 text-muted-foreground opacity-50" />
<p className="text-sm text-muted-foreground">No conversation history yet</p>
</CardContent>
</Card>
) : (
typedHistory.map((item: any, i: number) => (
<motion.div key={item.id} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: i * 0.03 }}>
<Card className="bg-card border-border/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{item.status === "success"
? <CheckCircle className="w-3.5 h-3.5 text-neon-green" />
: <XCircle className="w-3.5 h-3.5 text-neon-red" />
}
<span className="text-[10px] font-mono text-muted-foreground">
{new Date(item.createdAt).toLocaleString()}
</span>
</div>
{item.conversationId && (
<Badge variant="outline" className="text-[10px] font-mono border-border/40">
{item.conversationId.slice(0, 8)}
</Badge>
)}
</div>
<div className="space-y-2">
<div>
<div className="text-[10px] text-muted-foreground font-mono mb-1">USER</div>
<div className="text-xs font-mono bg-secondary/30 rounded p-2 text-foreground">
{item.userMessage}
</div>
</div>
{item.agentResponse && (
<div>
<div className="text-[10px] text-primary font-mono mb-1">AGENT</div>
<div className="text-xs font-mono bg-primary/5 border border-primary/10 rounded p-2 text-foreground line-clamp-4">
{item.agentResponse}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</TabsContent>
{/* ── RAW METRICS ─────────────────────────────── */}
<TabsContent value="raw" className="pt-4">
<Card className="bg-card border-border/50">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-border/50">
<th className="text-left p-3 text-muted-foreground">TIME</th>
<th className="text-left p-3 text-muted-foreground">STATUS</th>
<th className="text-right p-3 text-muted-foreground">PROC MS</th>
<th className="text-right p-3 text-muted-foreground">TOKENS</th>
<th className="text-right p-3 text-muted-foreground">PROMPT</th>
<th className="text-right p-3 text-muted-foreground">COMPLETION</th>
</tr>
</thead>
<tbody>
{metricsLoading ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mx-auto" />
</td>
</tr>
) : typedMetrics.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">
No metrics data for this period
</td>
</tr>
) : (
typedMetrics.map((m: any) => (
<tr key={m.id} className="border-b border-border/20 hover:bg-secondary/20">
<td className="p-3 text-muted-foreground">
{new Date(m.createdAt).toLocaleTimeString()}
</td>
<td className="p-3">
<Badge
variant="outline"
className={`text-[10px] ${m.status === "success" ? "text-neon-green border-neon-green/30" : "text-neon-red border-neon-red/30"}`}
>
{m.status}
</Badge>
</td>
<td className="p-3 text-right text-neon-amber">{m.processingTimeMs}</td>
<td className="p-3 text-right text-primary">{m.totalTokens ?? 0}</td>
<td className="p-3 text-right text-muted-foreground">{m.promptTokens ?? 0}</td>
<td className="p-3 text-right text-muted-foreground">{m.completionTokens ?? 0}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -35,10 +35,12 @@ import {
Eye,
Loader2,
AlertCircle,
BarChart2,
} from "lucide-react";
import { motion } from "framer-motion";
import { useState } from "react";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { AgentDetailModal } from "@/components/AgentDetailModal";
import { AgentCreateModal } from "@/components/AgentCreateModal";
@@ -69,6 +71,7 @@ export default function Agents() {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [agentToDelete, setAgentToDelete] = useState<number | null>(null);
const [, navigate] = useLocation();
// Fetch agents from database
const { data: agents = [], isLoading, refetch } = trpc.agents.list.useQuery();
@@ -256,6 +259,17 @@ export default function Agents() {
>
Edit
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] text-neon-amber border-neon-amber/30 hover:bg-neon-amber/10"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${agent.id}/metrics`);
}}
>
<BarChart2 className="w-3 h-3 mr-1" /> Metrics
</Button>
<Button
size="sm"
variant="outline"

362
client/src/pages/Tools.tsx Normal file
View File

@@ -0,0 +1,362 @@
/*
* Tools — Tool Binding Management
* Design: Registry view + execution sandbox
* Colors: Cyan/amber for tool categories, green for success, red for errors
*/
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Globe,
Terminal,
FileText,
Box,
Camera,
Play,
Loader2,
CheckCircle,
XCircle,
Zap,
Shield,
AlertTriangle,
} from "lucide-react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
const CATEGORY_ICONS: Record<string, any> = {
http: Globe,
shell: Terminal,
file: FileText,
docker: Box,
browser: Camera,
system: Zap,
};
const CATEGORY_COLORS: Record<string, string> = {
http: "text-primary border-primary/30 bg-primary/10",
shell: "text-neon-amber border-neon-amber/30 bg-neon-amber/10",
file: "text-neon-green border-neon-green/30 bg-neon-green/10",
docker: "text-blue-400 border-blue-400/30 bg-blue-400/10",
browser: "text-purple-400 border-purple-400/30 bg-purple-400/10",
system: "text-muted-foreground border-border bg-secondary/30",
};
interface ToolDef {
id: string;
name: string;
description: string;
category: string;
icon: string;
parameters: Record<string, { type: string; description: string; required?: boolean }>;
dangerous: boolean;
}
export default function Tools() {
const [selectedTool, setSelectedTool] = useState<ToolDef | null>(null);
const [execModalOpen, setExecModalOpen] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [params, setParams] = useState<Record<string, string>>({});
const [result, setResult] = useState<any>(null);
const [isExecuting, setIsExecuting] = useState(false);
const [filterCategory, setFilterCategory] = useState<string>("all");
const { data: tools = [], isLoading } = trpc.tools.list.useQuery();
const { data: agents = [] } = trpc.agents.list.useQuery();
const executeMutation = trpc.tools.execute.useMutation({
onSuccess: (data) => {
setResult(data);
if (data.success) {
toast.success(`Tool executed in ${data.executionTimeMs}ms`);
} else {
toast.error(`Execution failed: ${data.error}`);
}
},
onError: (err) => {
toast.error(`Execution error: ${err.message}`);
},
});
const handleExecute = async () => {
if (!selectedTool || !selectedAgentId) return;
setIsExecuting(true);
setResult(null);
try {
const parsedParams: Record<string, any> = {};
for (const [key, value] of Object.entries(params)) {
try {
parsedParams[key] = JSON.parse(value);
} catch {
parsedParams[key] = value;
}
}
await executeMutation.mutateAsync({
agentId: parseInt(selectedAgentId),
tool: selectedTool.id,
params: parsedParams,
});
} finally {
setIsExecuting(false);
}
};
const openExecModal = (tool: ToolDef) => {
setSelectedTool(tool);
setParams({});
setResult(null);
setExecModalOpen(true);
};
const typedTools = tools as ToolDef[];
const categories = ["all", ...Array.from(new Set(typedTools.map((t) => t.category)))];
const filteredTools = filterCategory === "all" ? typedTools : typedTools.filter((t) => t.category === filterCategory);
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-foreground">Tool Registry</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{typedTools.length} tools available · Bind to agents via Agent Configuration
</p>
</div>
<div className="flex items-center gap-2">
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-36 font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c} value={c} className="font-mono text-xs">
{c === "all" ? "All Categories" : c.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: "Total Tools", value: typedTools.length, color: "text-primary" },
{ label: "Safe Tools", value: typedTools.filter((t) => !t.dangerous).length, color: "text-neon-green" },
{ label: "Dangerous", value: typedTools.filter((t) => t.dangerous).length, color: "text-neon-red" },
{ label: "Categories", value: categories.length - 1, color: "text-neon-amber" },
].map(({ label, value, color }) => (
<Card key={label} className="bg-card border-border/50">
<CardContent className="p-3">
<div className="text-[10px] text-muted-foreground font-mono">{label.toUpperCase()}</div>
<div className={`text-2xl font-bold font-mono mt-1 ${color}`}>{value}</div>
</CardContent>
</Card>
))}
</div>
{/* Tool cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTools.map((tool, i) => {
const Icon = CATEGORY_ICONS[tool.category] || Zap;
const colorClass = CATEGORY_COLORS[tool.category] || CATEGORY_COLORS.system;
const paramCount = Object.keys(tool.parameters).length;
const requiredCount = Object.values(tool.parameters).filter((p) => p.required).length;
return (
<motion.div
key={tool.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all h-full flex flex-col">
<CardContent className="p-4 flex flex-col flex-1">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-md flex items-center justify-center border ${colorClass}`}>
<Icon className="w-4 h-4" />
</div>
<div>
<div className="text-sm font-semibold text-foreground">{tool.name}</div>
<div className="text-[10px] font-mono text-muted-foreground">{tool.id}</div>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className={`text-[10px] font-mono ${colorClass}`}>
{tool.category.toUpperCase()}
</Badge>
{tool.dangerous && (
<Badge variant="outline" className="text-[10px] font-mono text-neon-red border-neon-red/30 bg-neon-red/5">
<AlertTriangle className="w-2.5 h-2.5 mr-1" />
DANGEROUS
</Badge>
)}
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-3 flex-1">{tool.description}</p>
{/* Parameters */}
<div className="space-y-1.5 mb-3">
<div className="text-[10px] text-muted-foreground font-mono">
PARAMS: {paramCount} total · {requiredCount} required
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(tool.parameters).map(([key, param]) => (
<span
key={key}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
param.required
? "border-primary/30 text-primary bg-primary/5"
: "border-border/40 text-muted-foreground bg-secondary/20"
}`}
>
{key}
{param.required && <span className="text-neon-red ml-0.5">*</span>}
</span>
))}
</div>
</div>
{/* Action */}
<Button
size="sm"
variant="outline"
className="w-full h-7 text-[11px] border-primary/30 text-primary hover:bg-primary/10"
onClick={() => openExecModal(tool)}
>
<Play className="w-3 h-3 mr-1.5" />
Test Execute
</Button>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
{/* Execute Modal */}
<Dialog open={execModalOpen} onOpenChange={setExecModalOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-card border-border">
<DialogHeader>
<DialogTitle className="font-mono text-primary flex items-center gap-2">
<Play className="w-4 h-4" />
Execute: {selectedTool?.name}
{selectedTool?.dangerous && (
<Badge variant="outline" className="text-[10px] text-neon-red border-neon-red/30 ml-2">
<AlertTriangle className="w-3 h-3 mr-1" />
DANGEROUS
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Agent selector */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground font-mono">EXECUTE AS AGENT *</Label>
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
<SelectTrigger className="font-mono">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{(agents as any[]).map((agent: any) => (
<SelectItem key={agent.id} value={String(agent.id)} className="font-mono">
{agent.name} ({agent.model})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Parameters */}
{selectedTool && Object.keys(selectedTool.parameters).length > 0 && (
<div className="space-y-3">
<Label className="text-xs text-muted-foreground font-mono">PARAMETERS</Label>
{Object.entries(selectedTool.parameters).map(([key, param]) => (
<div key={key} className="space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono text-primary">{key}</span>
{param.required && <span className="text-[10px] text-neon-red">*required</span>}
<span className="text-[10px] text-muted-foreground">({param.type})</span>
</div>
<Input
placeholder={param.description}
value={params[key] ?? ""}
onChange={(e) => setParams({ ...params, [key]: e.target.value })}
className="font-mono text-sm"
/>
</div>
))}
</div>
)}
{/* Execute button */}
<Button
onClick={handleExecute}
disabled={!selectedAgentId || isExecuting || executeMutation.isPending}
className="w-full bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25"
>
{isExecuting || executeMutation.isPending ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Executing...</>
) : (
<><Play className="w-4 h-4 mr-2" />Execute Tool</>
)}
</Button>
{/* Result */}
{result && (
<div className="space-y-2">
<div className="flex items-center gap-2">
{result.success
? <CheckCircle className="w-4 h-4 text-neon-green" />
: <XCircle className="w-4 h-4 text-neon-red" />
}
<span className={`text-sm font-mono ${result.success ? "text-neon-green" : "text-neon-red"}`}>
{result.success ? `Success (${result.executionTimeMs}ms)` : `Error: ${result.error}`}
</span>
</div>
{result.result && (
<Textarea
readOnly
value={JSON.stringify(result.result, null, 2)}
rows={8}
className="font-mono text-xs bg-secondary/30 resize-none"
/>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}