feat(web,coder): add analytics + results pages for token usage and run history
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.
This commit is contained in:
510
apps/web/src/pages/Results.tsx
Normal file
510
apps/web/src/pages/Results.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user