paperclip / ui /src /components /AgentPerformanceTab.tsx
Leon4gr45's picture
Bundle paperclip + openviking into HF Docker Space
64648ce
Raw
History Blame Contribute Delete
21 kB
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
agentKpisApi,
analyticsApi,
experimentsApi,
type AgentKpi,
type KpiObservation,
type AgentExperiment,
type ObservationCreateRequest,
type ExperimentCreateRequest,
} from "../api/agentKpis";
import { queryKeys } from "../lib/queryKeys";
import { useToast } from "../context/ToastContext";
import { cn, formatCents } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
BarChart3,
TrendingUp,
DollarSign,
Clock,
Activity,
Plus,
Trash2,
Eye,
FlaskConical,
} from "lucide-react";
const experimentStatusColors: Record<string, string> = {
draft: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300",
running: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
paused: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
completed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
cancelled: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const severityColors: Record<string, string> = {
info: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
warning: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
critical: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
interface AgentPerformanceTabProps {
agentId: string;
companyId: string;
}
export function AgentPerformanceTab({ agentId, companyId }: AgentPerformanceTabProps) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [obsDialogOpen, setObsDialogOpen] = useState(false);
const [obsForm, setObsForm] = useState<{ title: string; content: string; severity: "info" | "warning" | "critical" }>({ title: "", content: "", severity: "info" });
const [deleteObsConfirm, setDeleteObsConfirm] = useState<string | null>(null);
const [expDialogOpen, setExpDialogOpen] = useState(false);
const [expForm, setExpForm] = useState({ name: "", description: "", hypothesis: "" });
const [deleteExpConfirm, setDeleteExpConfirm] = useState<string | null>(null);
const kpisQuery = useQuery({
queryKey: queryKeys.agentKpis.list(agentId),
queryFn: () => agentKpisApi.list(agentId, { limit: 20 }),
});
const observationsQuery = useQuery({
queryKey: queryKeys.observations.list(companyId),
queryFn: () => analyticsApi.listObservations(companyId),
});
const experimentsQuery = useQuery({
queryKey: queryKeys.experiments.list(agentId),
queryFn: () => experimentsApi.list(agentId),
});
const createObsMutation = useMutation({
mutationFn: (data: ObservationCreateRequest) => analyticsApi.createObservation(companyId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.observations.list(companyId) });
pushToast({ title: "Observation created" });
setObsDialogOpen(false);
setObsForm({ title: "", content: "", severity: "info" });
},
onError: () => pushToast({ tone: "warn", title: "Failed to create observation" }),
});
const deleteObsMutation = useMutation({
mutationFn: (id: string) => analyticsApi.deleteObservation(companyId, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.observations.list(companyId) });
pushToast({ title: "Observation deleted" });
setDeleteObsConfirm(null);
},
onError: () => pushToast({ tone: "warn", title: "Failed to delete observation" }),
});
const createExpMutation = useMutation({
mutationFn: (data: ExperimentCreateRequest) => experimentsApi.create(agentId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.experiments.list(agentId) });
pushToast({ title: "Experiment created" });
setExpDialogOpen(false);
setExpForm({ name: "", description: "", hypothesis: "" });
},
onError: () => pushToast({ tone: "warn", title: "Failed to create experiment" }),
});
const deleteExpMutation = useMutation({
mutationFn: (id: string) => experimentsApi.delete(agentId, id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.experiments.list(agentId) });
pushToast({ title: "Experiment deleted" });
setDeleteExpConfirm(null);
},
onError: () => pushToast({ tone: "warn", title: "Failed to delete experiment" }),
});
const kpis = kpisQuery.data ?? [];
const observations = (observationsQuery.data ?? []).filter(
(o) => !o.agentId || o.agentId === agentId,
);
const experiments = experimentsQuery.data ?? [];
// Compute summary values from KPIs
const completionRates = kpis.filter((k) => k.completionRate != null).map((k) => k.completionRate!);
const avgCompletion =
completionRates.length > 0
? completionRates.reduce((a, b) => a + b, 0) / completionRates.length
: null;
const costs = kpis.filter((k) => k.costCents != null).map((k) => k.costCents!);
const avgCost = costs.length > 0 ? costs.reduce((a, b) => a + b, 0) / costs.length : null;
const durations = kpis.filter((k) => k.durationMs != null).map((k) => k.durationMs!);
const avgDuration =
durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : null;
const totalRuns = kpis.length;
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
return (
<div className="space-y-8">
{/* KPI Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard
icon={TrendingUp}
label="Completion Rate"
value={avgCompletion != null ? `${Math.round(avgCompletion * 100)}%` : "--"}
/>
<SummaryCard
icon={DollarSign}
label="Avg Cost"
value={avgCost != null ? formatCents(Math.round(avgCost)) : "--"}
/>
<SummaryCard
icon={Clock}
label="Avg Duration"
value={avgDuration != null ? formatDuration(avgDuration) : "--"}
/>
<SummaryCard
icon={Activity}
label="Total Runs"
value={String(totalRuns)}
/>
</div>
{/* Recent KPIs */}
<section>
<h3 className="text-sm font-semibold mb-3">Recent KPI Entries</h3>
{kpis.length === 0 ? (
<EmptySection icon={BarChart3} message="No KPI data recorded yet." />
) : (
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Date</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Completion</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Cost</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">Errors</th>
</tr>
</thead>
<tbody>
{kpis.map((kpi) => (
<tr key={kpi.id} className="border-b border-border last:border-b-0">
<td className="px-3 py-2 text-muted-foreground">
{new Date(kpi.createdAt).toLocaleDateString()}
</td>
<td className="px-3 py-2">
{kpi.completionRate != null
? `${Math.round(kpi.completionRate * 100)}%`
: "--"}
</td>
<td className="px-3 py-2">
{kpi.costCents != null ? formatCents(kpi.costCents) : "--"}
</td>
<td className="px-3 py-2">
{kpi.durationMs != null ? formatDuration(kpi.durationMs) : "--"}
</td>
<td className="px-3 py-2">
{kpi.errorCount > 0 ? (
<span className="text-destructive">{kpi.errorCount}</span>
) : (
"0"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Observations */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Observations</h3>
<Button size="sm" variant="outline" onClick={() => setObsDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Observation
</Button>
</div>
{observations.length === 0 ? (
<EmptySection icon={Eye} message="No observations recorded." />
) : (
<div className="space-y-2">
{observations.map((obs) => (
<Card key={obs.id} className="p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium truncate">{obs.title}</h4>
<Badge
variant="secondary"
className={cn("text-[10px] px-1.5 py-0", severityColors[obs.severity])}
>
{obs.severity}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{obs.observerType}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2">{obs.content}</p>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => setDeleteObsConfirm(obs.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</Card>
))}
</div>
)}
</section>
{/* Experiments */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Experiments</h3>
<Button size="sm" variant="outline" onClick={() => setExpDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
New Experiment
</Button>
</div>
{experiments.length === 0 ? (
<EmptySection icon={FlaskConical} message="No experiments running." />
) : (
<div className="space-y-2">
{experiments.map((exp) => (
<Card key={exp.id} className="p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium truncate">{exp.name}</h4>
<Badge
variant="secondary"
className={cn(
"text-[10px] px-1.5 py-0",
experimentStatusColors[exp.status],
)}
>
{exp.status}
</Badge>
</div>
{exp.description && (
<p className="text-xs text-muted-foreground line-clamp-1">
{exp.description}
</p>
)}
{exp.hypothesis && (
<p className="text-xs text-muted-foreground/70 mt-0.5 italic line-clamp-1">
Hypothesis: {exp.hypothesis}
</p>
)}
{exp.result && (
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5 line-clamp-1">
Result: {exp.result}
</p>
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => setDeleteExpConfirm(exp.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</Card>
))}
</div>
)}
</section>
{/* Observation Dialog */}
<Dialog open={obsDialogOpen} onOpenChange={(open) => !open && setObsDialogOpen(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Observation</DialogTitle>
<DialogDescription>Record an observation about this agent's performance.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Title</label>
<Input
value={obsForm.title}
onChange={(e) => setObsForm({ ...obsForm, title: e.target.value })}
placeholder="Observation title"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Content</label>
<Textarea
value={obsForm.content}
onChange={(e) => setObsForm({ ...obsForm, content: e.target.value })}
placeholder="Describe the observation..."
rows={3}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Severity</label>
<Select
value={obsForm.severity}
onValueChange={(v) =>
setObsForm({ ...obsForm, severity: v as "info" | "warning" | "critical" })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setObsDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
createObsMutation.mutate({
agentId,
title: obsForm.title,
content: obsForm.content,
severity: obsForm.severity,
})
}
disabled={!obsForm.title.trim() || !obsForm.content.trim() || createObsMutation.isPending}
>
{createObsMutation.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Observation Confirmation */}
<Dialog open={!!deleteObsConfirm} onOpenChange={(open) => !open && setDeleteObsConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Observation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this observation?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteObsConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteObsConfirm && deleteObsMutation.mutate(deleteObsConfirm)}
disabled={deleteObsMutation.isPending}
>
{deleteObsMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Experiment Dialog */}
<Dialog open={expDialogOpen} onOpenChange={(open) => !open && setExpDialogOpen(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Experiment</DialogTitle>
<DialogDescription>Create an experiment to test a hypothesis.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
value={expForm.name}
onChange={(e) => setExpForm({ ...expForm, name: e.target.value })}
placeholder="Experiment name"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Description</label>
<Textarea
value={expForm.description}
onChange={(e) => setExpForm({ ...expForm, description: e.target.value })}
placeholder="What are you testing?"
rows={2}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Hypothesis</label>
<Textarea
value={expForm.hypothesis}
onChange={(e) => setExpForm({ ...expForm, hypothesis: e.target.value })}
placeholder="What do you expect to happen?"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setExpDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
createExpMutation.mutate({
name: expForm.name,
description: expForm.description || null,
hypothesis: expForm.hypothesis,
approachA: expForm.hypothesis,
approachB: expForm.hypothesis,
})
}
disabled={!expForm.name.trim() || createExpMutation.isPending}
>
{createExpMutation.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Experiment Confirmation */}
<Dialog open={!!deleteExpConfirm} onOpenChange={(open) => !open && setDeleteExpConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Experiment</DialogTitle>
<DialogDescription>
Are you sure you want to delete this experiment?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteExpConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteExpConfirm && deleteExpMutation.mutate(deleteExpConfirm)}
disabled={deleteExpMutation.isPending}
>
{deleteExpMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function SummaryCard({
icon: Icon,
label,
value,
}: {
icon: typeof TrendingUp;
label: string;
value: string;
}) {
return (
<Card className="p-4">
<div className="flex items-center gap-2 mb-1">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<p className="text-lg font-semibold">{value}</p>
</Card>
);
}
function EmptySection({ icon: Icon, message }: { icon: typeof BarChart3; message: string }) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Icon className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground">{message}</p>
</div>
);
}