New /analytics route: token usage dashboard with aggregate summary, per-session breakdown, context window stats, and per-category token distribution. Data served from existing agent_sessions + tool_cost_stats. New /results route: browsable archive of orchestrator flow runs and arena battles. Two-tab layout (Analysis Runs / Arena Battles) using existing API endpoints (no new backend). Sidebar gains Results (ScrollText icon) and Token Analytics (BarChart3 icon) nav buttons above Settings.
511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { ArrowLeft, Beaker, CheckCircle2, FileText, ScrollText, Swords, XCircle } from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { api } from '@/api/client';
|
|
import type { BattleShape, FlowRunRow } from '@/api/types';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useSidebar } from '@/hooks/useSidebar';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ─── Independent section data fetcher (same pattern as Analytics.tsx) ────────
|
|
|
|
function useFetch<T>(fetcher: () => Promise<T>): {
|
|
data: T | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
retry: () => void;
|
|
} {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function load() {
|
|
setLoading(true);
|
|
setError(null);
|
|
fetcher()
|
|
.then(setData)
|
|
.catch((err: unknown) => {
|
|
setError(err instanceof Error ? err.message : 'failed to load data');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return { data, loading, error, retry: load };
|
|
}
|
|
|
|
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
|
|
|
function SkeletonBar({ className }: { className?: string }) {
|
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
|
}
|
|
|
|
// ─── Formatters ──────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatDuration(startIso: string, endIso?: string | null): string {
|
|
const start = new Date(startIso).getTime();
|
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
|
const ms = end - start;
|
|
if (ms < 0) return '—';
|
|
const s = Math.round(ms / 1000);
|
|
if (s < 60) return `${s}s`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
|
|
return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
|
|
}
|
|
|
|
function truncate(str: string, max: number): string {
|
|
if (str.length <= max) return str;
|
|
return str.slice(0, max) + '…';
|
|
}
|
|
|
|
// ─── Status dot (shared visual language with OrchestratorPane/ArenaPane) ──────
|
|
|
|
type DotStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'pending';
|
|
|
|
function StatusDot({ status }: { status: DotStatus }) {
|
|
if (status === 'running') {
|
|
return (
|
|
<span
|
|
aria-label="running"
|
|
className="inline-block w-2.5 h-2.5 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
|
|
/>
|
|
);
|
|
}
|
|
const cls =
|
|
status === 'completed'
|
|
? 'bg-emerald-500'
|
|
: status === 'failed'
|
|
? 'bg-destructive'
|
|
: status === 'cancelled'
|
|
? 'bg-muted-foreground/20'
|
|
: 'bg-muted-foreground/40'; // pending
|
|
return <span aria-label={status} className={cn('inline-block w-2 h-2 rounded-full shrink-0', cls)} />;
|
|
}
|
|
|
|
// ─── Tab bar ─────────────────────────────────────────────────────────────────
|
|
|
|
type TabId = 'runs' | 'battles';
|
|
|
|
function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) {
|
|
return (
|
|
<div className="flex gap-1 border-b pb-px">
|
|
{[
|
|
{ id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText },
|
|
{ id: 'battles' as TabId, label: 'Arena Battles', icon: Swords },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => onChange(tab.id)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
|
|
active === tab.id
|
|
? 'bg-background border-border text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
|
|
)}
|
|
>
|
|
<tab.icon className="size-3.5" />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Empty state ─────────────────────────────────────────────────────────────
|
|
|
|
function EmptyState({ message }: { message: string }) {
|
|
return <p className="text-sm text-muted-foreground py-8 text-center">{message}</p>;
|
|
}
|
|
|
|
// ─── Project selector ────────────────────────────────────────────────────────
|
|
|
|
function ProjectSelector({
|
|
projects,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
projects: Array<{ id: string; name: string }>;
|
|
value: string;
|
|
onChange: (id: string) => void;
|
|
}) {
|
|
return (
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="text-sm bg-muted/30 border border-border rounded px-2 py-1 text-foreground"
|
|
>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
// ─── Analysis Runs tab ───────────────────────────────────────────────────────
|
|
|
|
function AnalysisRunsTab({ projectId }: { projectId: string }) {
|
|
const { data, loading, error, retry } = useFetch(() => api.runs.list(projectId).then((r) => r.runs));
|
|
|
|
const [selectedRun, setSelectedRun] = useState<FlowRunRow | null>(null);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2 pt-4">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<SkeletonBar key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center gap-3 text-sm pt-4">
|
|
<span className="text-destructive">{error}</span>
|
|
<Button size="sm" variant="outline" onClick={retry}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data || data.length === 0) {
|
|
return <EmptyState message="No analysis runs yet. Start one from the Workflow button in any chat." />;
|
|
}
|
|
|
|
return (
|
|
<div className="pt-4 space-y-2">
|
|
{data.map((run) => (
|
|
<div key={run.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedRun(selectedRun?.id === run.id ? null : run)}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
|
selectedRun?.id === run.id && 'bg-muted/40',
|
|
)}
|
|
>
|
|
<StatusDot status={run.status as DotStatus} />
|
|
<span className="font-medium min-w-0 flex-1 truncate">
|
|
{run.flow_name}
|
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs uppercase">
|
|
{run.band}
|
|
</span>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
|
{run.model ? run.model.split('/').pop() : '—'}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
|
{formatDuration(run.created_at, run.updated_at)}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{formatDate(run.created_at)}
|
|
</span>
|
|
{run.error && (
|
|
<span className="text-destructive" title={run.error}>
|
|
<XCircle className="size-3.5" />
|
|
</span>
|
|
)}
|
|
{run.status === 'completed' && run.report && (
|
|
<FileText className="size-3.5 text-muted-foreground shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded detail — report preview */}
|
|
{selectedRun?.id === run.id && run.status === 'completed' && run.report && (
|
|
<div className="ml-8 mr-2 mb-2 p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
|
{truncate(run.report, 3000)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Arena Battles tab ───────────────────────────────────────────────────────
|
|
|
|
function ArenaBattlesTab({ projectId }: { projectId: string }) {
|
|
const { data, loading, error, retry } = useFetch(() => api.battles.list(projectId).then((r) => r.battles));
|
|
|
|
const [selectedBattle, setSelectedBattle] = useState<BattleShape | null>(null);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2 pt-4">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<SkeletonBar key={i} className="h-12 w-full" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center gap-3 text-sm pt-4">
|
|
<span className="text-destructive">{error}</span>
|
|
<Button size="sm" variant="outline" onClick={retry}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data || data.length === 0) {
|
|
return <EmptyState message="No arena battles yet. Start one from the Arena button in any chat." />;
|
|
}
|
|
|
|
return (
|
|
<div className="pt-4 space-y-2">
|
|
{data.map((battle) => {
|
|
const hasAnalysis = battle.status === 'completed' && battle.results_path;
|
|
return (
|
|
<div key={battle.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedBattle(selectedBattle?.id === battle.id ? null : battle)}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm text-left transition-colors hover:bg-muted/30',
|
|
selectedBattle?.id === battle.id && 'bg-muted/40',
|
|
)}
|
|
>
|
|
<StatusDot status={
|
|
battle.status === 'completed' ? 'completed'
|
|
: battle.status === 'failed' ? 'failed'
|
|
: battle.status === 'cancelled' ? 'cancelled'
|
|
: 'running'
|
|
} />
|
|
<span className="font-medium min-w-0 flex-1 truncate">
|
|
{battle.battle_type === 'coding' ? 'Coding Battle' : 'Q&A Battle'}
|
|
<span className="text-muted-foreground font-normal ml-1.5 text-xs">
|
|
{truncate(battle.prompt, 60)}
|
|
</span>
|
|
</span>
|
|
{battle.winner_contestant_id && (
|
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
|
<CheckCircle2 className="size-3" />
|
|
Winner
|
|
</span>
|
|
)}
|
|
{battle.error && (
|
|
<span className="text-destructive" title={battle.error}>
|
|
<XCircle className="size-3.5" />
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap hidden sm:block">
|
|
{formatDate(battle.created_at)}
|
|
</span>
|
|
{hasAnalysis && (
|
|
<Beaker className="size-3.5 text-muted-foreground shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded detail — analysis preview */}
|
|
{selectedBattle?.id === battle.id && hasAnalysis && (
|
|
<div className="ml-8 mr-2 mb-2">
|
|
<AnalysisPreview battleId={battle.id} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Battle analysis preview (fetches analysis.md on expand) ─────────────────
|
|
|
|
function AnalysisPreview({ battleId }: { battleId: string }) {
|
|
const { data, loading, error, retry } = useFetch(() => api.battles.getAnalysis(battleId).then((r) => r.text));
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2 p-3 rounded-md bg-muted/20 border border-border/50">
|
|
<SkeletonBar className="h-3 w-full" />
|
|
<SkeletonBar className="h-3 w-3/4" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center gap-3 p-3 rounded-md bg-muted/20 border border-border/50 text-xs">
|
|
<span className="text-destructive">{error}</span>
|
|
<Button size="sm" variant="outline" onClick={retry}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-3 rounded-md bg-muted/20 border border-border/50 text-xs leading-relaxed max-h-60 overflow-y-auto whitespace-pre-wrap font-mono">
|
|
{data ? truncate(data, 3000) : 'No analysis available.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Summary strip ───────────────────────────────────────────────────────────
|
|
|
|
function SummaryCards({
|
|
runs,
|
|
battles,
|
|
}: {
|
|
runs: FlowRunRow[] | null;
|
|
battles: BattleShape[] | null;
|
|
}) {
|
|
const totalRuns = runs?.length ?? 0;
|
|
const completedRuns = runs?.filter((r) => r.status === 'completed').length ?? 0;
|
|
const totalBattles = battles?.length ?? 0;
|
|
const completedBattles = battles?.filter((b) => b.status === 'completed').length ?? 0;
|
|
|
|
const cards = [
|
|
{ label: 'Total Runs', value: totalRuns, icon: FileText, color: 'text-blue-500' },
|
|
{ label: 'Completed Runs', value: completedRuns, icon: CheckCircle2, color: 'text-emerald-500' },
|
|
{ label: 'Total Battles', value: totalBattles, icon: Swords, color: 'text-violet-500' },
|
|
{ label: 'Completed Battles', value: completedBattles, icon: CheckCircle2, color: 'text-emerald-500' },
|
|
];
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{cards.map((c) => (
|
|
<Card key={c.label} size="sm">
|
|
<CardContent className="flex items-start gap-3 pt-3">
|
|
<c.icon className={cn('size-4 shrink-0 mt-0.5', c.color)} />
|
|
<div className="min-w-0">
|
|
<div className="text-lg font-semibold tabular-nums">{c.value}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">{c.label}</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCardsSkeleton() {
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Card key={i} size="sm">
|
|
<CardContent className="pt-3">
|
|
<SkeletonBar className="h-5 w-16 mb-2" />
|
|
<SkeletonBar className="h-3 w-20" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ───────────────────────────────────────────────────────────────
|
|
|
|
export function Results() {
|
|
const navigate = useNavigate();
|
|
const { data: sidebar, activeSession } = useSidebar();
|
|
|
|
const [tab, setTab] = useState<TabId>('runs');
|
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
|
|
// Derive default project from active session or first project.
|
|
const projects = useMemo(() => {
|
|
return sidebar?.projects?.map((p: { id: string; name: string }) => p) ?? [];
|
|
}, [sidebar]);
|
|
|
|
useEffect(() => {
|
|
if (!projectId && projects.length > 0) {
|
|
// Prefer active session's project, else first project.
|
|
const defaultId = activeSession?.project_id ?? projects[0]!.id;
|
|
setProjectId(defaultId);
|
|
}
|
|
}, [projects, activeSession, projectId]);
|
|
|
|
function handleBack() {
|
|
if (window.history.length > 1) {
|
|
navigate(-1);
|
|
} else {
|
|
navigate('/');
|
|
}
|
|
}
|
|
|
|
const runsFetch = useFetch(
|
|
projectId ? () => api.runs.list(projectId).then((r) => r.runs) : () => Promise.resolve([] as FlowRunRow[]),
|
|
);
|
|
const battlesFetch = useFetch(
|
|
projectId ? () => api.battles.list(projectId).then((r) => r.battles) : () => Promise.resolve([] as BattleShape[]),
|
|
);
|
|
|
|
const summaryLoading = runsFetch.loading && battlesFetch.loading;
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-6">
|
|
{/* Header */}
|
|
<header className="space-y-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground -ml-1 px-1 py-0.5 rounded"
|
|
aria-label="Back"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
<span>Back</span>
|
|
</button>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-xl font-semibold flex items-center gap-2">
|
|
<ScrollText className="size-5" />
|
|
Results
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Completed orchestrator runs and arena battles.
|
|
</p>
|
|
</div>
|
|
{projects.length > 0 && projectId && (
|
|
<ProjectSelector
|
|
projects={projects}
|
|
value={projectId}
|
|
onChange={setProjectId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Summary Cards */}
|
|
{summaryLoading ? (
|
|
<SummaryCardsSkeleton />
|
|
) : (
|
|
<SummaryCards runs={runsFetch.data} battles={battlesFetch.data} />
|
|
)}
|
|
|
|
{/* Tab bar */}
|
|
<TabBar active={tab} onChange={setTab} />
|
|
|
|
{/* Tab content */}
|
|
{!projectId ? (
|
|
<EmptyState message="Select a project to view results." />
|
|
) : tab === 'runs' ? (
|
|
<AnalysisRunsTab projectId={projectId} />
|
|
) : (
|
|
<ArenaBattlesTab projectId={projectId} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|