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:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: "Настройки" },
|
||||
|
||||
344
client/src/pages/AgentMetrics.tsx
Normal file
344
client/src/pages/AgentMetrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
362
client/src/pages/Tools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user