diff --git a/apps/coder/src/routes/analytics.ts b/apps/coder/src/routes/analytics.ts new file mode 100644 index 0000000..865b562 --- /dev/null +++ b/apps/coder/src/routes/analytics.ts @@ -0,0 +1,78 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; + +// token-analyzer-ui: aggregate token/cost analytics across all agent_sessions. +// v1 — global view only (no per-project or per-user filtering). + +export interface AnalyticsSummary { + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + session_count: number; +} + +export interface SessionAnalyticsRow { + session_id: string; + session_name: string; + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + last_active_at: string | null; +} + +export interface TokenBreakdownAgg { + category: string; + total_tokens: number; +} + +export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/analytics/summary — aggregate totals across all agent_sessions. + app.get('/api/analytics/summary', async () => { + const [row] = await sql` + SELECT + COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens, + COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens, + COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost, + COUNT(DISTINCT c.session_id)::INT AS session_count + FROM agent_sessions a + JOIN chats c ON c.id = a.chat_id + `; + return row ?? { total_input_tokens: 0, total_output_tokens: 0, total_cost: 0, session_count: 0 }; + }); + + // GET /api/analytics/sessions — per-session token/cost breakdown. + app.get('/api/analytics/sessions', async () => { + const rows = await sql` + SELECT + c.session_id AS session_id, + s.name AS session_name, + COALESCE(SUM(a.input_tokens), 0)::BIGINT AS total_input_tokens, + COALESCE(SUM(a.output_tokens), 0)::BIGINT AS total_output_tokens, + COALESCE(SUM(a.cost), 0)::DOUBLE PRECISION AS total_cost, + MAX(a.last_active_at) AS last_active_at + FROM agent_sessions a + JOIN chats c ON c.id = a.chat_id + JOIN sessions s ON s.id = c.session_id + GROUP BY c.session_id, s.name + ORDER BY MAX(a.last_active_at) DESC NULLS LAST + `; + return { sessions: rows }; + }); + + // GET /api/analytics/token-breakdown — aggregate token_breakdown categories + // across all tasks that carry the JSONB field. + app.get('/api/analytics/token-breakdown', async () => { + const rows = await sql<{ category: string; total_tokens: number }[]>` + SELECT + key AS category, + SUM((value->>0)::BIGINT)::BIGINT AS total_tokens + FROM tasks, + LATERAL jsonb_each(token_breakdown) + WHERE token_breakdown IS NOT NULL + AND jsonb_typeof(token_breakdown) = 'object' + GROUP BY key + ORDER BY total_tokens DESC + `; + return { categories: rows }; + }); +} diff --git a/apps/server/src/routes/analytics.ts b/apps/server/src/routes/analytics.ts new file mode 100644 index 0000000..081f38e --- /dev/null +++ b/apps/server/src/routes/analytics.ts @@ -0,0 +1,33 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; + +// token-analyzer-ui: context window utilization and token breakdown data. +// v1 — global aggregates only. + +export interface ContextWindowStats { + avg_ctx_used: number | null; + avg_ctx_max: number | null; + avg_utilization_pct: number | null; + message_count: number; +} + +export function registerAnalyticsRoutes(app: FastifyInstance, sql: Sql): void { + // GET /api/analytics/context — average context window utilization across + // completed assistant messages that carry ctx_used/ctx_max. + app.get('/api/analytics/context', async () => { + const [row] = await sql` + SELECT + AVG(ctx_used)::DOUBLE PRECISION AS avg_ctx_used, + AVG(ctx_max)::DOUBLE PRECISION AS avg_ctx_max, + AVG(ctx_used::float / NULLIF(ctx_max, 0))::DOUBLE PRECISION AS avg_utilization_pct, + COUNT(*)::INT AS message_count + FROM messages + WHERE role = 'assistant' + AND status = 'complete' + AND ctx_used IS NOT NULL + AND ctx_max IS NOT NULL + AND ctx_max > 0 + `; + return row ?? { avg_ctx_used: null, avg_ctx_max: null, avg_utilization_pct: null, message_count: 0 }; + }); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 130ea33..3fa6007 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,8 @@ import { Home } from '@/pages/Home'; import { Project } from '@/pages/Project'; import { Session } from '@/pages/Session'; import { Settings } from '@/pages/Settings'; +import { Analytics } from '@/pages/Analytics'; +import { Results } from '@/pages/Results'; import { Toaster } from '@/components/ui/sonner'; import { useUserEvents } from '@/hooks/useUserEvents'; import { useCoderUserEvents } from '@/hooks/useCoderUserEvents'; @@ -95,6 +97,8 @@ function AppShell() { } /> } /> } /> + } /> + } /> diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 9b2291a..8eaa466 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; -import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react'; +import { BarChart3, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import mascot from '@/assets/brand/banner-mascot.png'; @@ -519,11 +519,40 @@ export function ProjectSidebar() { })} - {/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the - workspace settings pane via the sessionEvents bus (Session.tsx owns - the panesHook). Outside a session there's no workspace to mount the - pane in, so we navigate to /settings (themes page) instead. */} -
+ {/* bottom-pinned nav buttons. Results → Analytics → Settings. */} +
+ { if (isMobile) setDrawerOpen(false); }} + className={({ isActive }) => + `w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${ + isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : '' + }` + } + aria-label="Results" + > + + Results + + + { if (isMobile) setDrawerOpen(false); }} + className={({ isActive }) => + `w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${ + isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : '' + }` + } + aria-label="Token Analytics" + > + + Token Analytics + + + {/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the + workspace settings pane via the sessionEvents bus (Session.tsx owns + the panesHook). Outside a session there's no workspace to mount the + pane in, so we navigate to /settings (themes page) instead. */} +
+ ) : ( + children + )} + + + ); +} + +function EmptyState({ message }: { message: string }) { + return

{message}

; +} + +// --- Per-Session Token Table --- +function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) { + if (sessions.length === 0) { + return ; + } + + return ( +
+ + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + ))} + +
SessionInputOutputCostLast Active
+ {s.session_name || 'Untitled'} + {formatNumber(s.total_input_tokens)}{formatNumber(s.total_output_tokens)}{formatCost(s.total_cost)}{formatDate(s.last_active_at)}
+
+ ); +} + +// --- Per-Tool Cost Table --- +function ToolTable({ stats }: { stats: ToolCostStat[] }) { + if (stats.length === 0) { + return ; + } + + return ( +
+ + + + + + + + + + + + {stats.map((t) => ( + + + + + + + + ))} + +
ToolCallsAvg PromptAvg CompletionAvg Total
+ + {t.tool_name} + {t.n_calls}{formatNumber(t.mean_prompt_tokens)}{formatNumber(t.mean_completion_tokens)}{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}
+
+ ); +} + +// --- Context Window Utilization --- +function ContextSection({ stats }: { stats: ContextWindowStats }) { + if (stats.message_count === 0) { + return ; + } + + return ( +
+
+
Avg Context Used
+
{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}
+
+
+
Avg Context Limit
+
{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}
+
+
+
Avg Utilization
+
{formatPct(stats.avg_utilization_pct)}
+
+
+
Based on {formatNumber(stats.message_count)} completed assistant messages
+
+
+
+
+
+ ); +} + +// --- Token Category Breakdown (CSS stacked bar) --- +const CATEGORY_COLORS: Record = { + system: 'bg-blue-500', + user: 'bg-green-500', + assistant: 'bg-amber-500', + tools: 'bg-purple-500', + reasoning: 'bg-rose-500', +}; + +const CATEGORY_LABELS: Record = { + system: 'System', + user: 'User', + assistant: 'Assistant', + tools: 'Tools', + reasoning: 'Reasoning', +}; + +function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) { + if (categories.length === 0) { + return ; + } + + const total = categories.reduce((sum, c) => sum + c.total_tokens, 0); + if (total === 0) return ; + + // Sort in a consistent order + const order = ['system', 'user', 'assistant', 'tools', 'reasoning']; + const sorted = [...categories].sort( + (a, b) => order.indexOf(a.category) - order.indexOf(b.category), + ); + + return ( +
+
+ {sorted.map((c) => { + const pct = (c.total_tokens / total) * 100; + if (pct < 1) return null; + return ( +
+ ); + })} +
+
+ {sorted.map((c) => { + const pct = (c.total_tokens / total) * 100; + return ( +
+ + {CATEGORY_LABELS[c.category] ?? c.category} + {pct.toFixed(1)}% + ({formatNumber(c.total_tokens)}) +
+ ); + })} +
+
+ ); +} + +// --- Main Page --- +export function Analytics() { + const navigate = useNavigate(); + + const summary = useFetch(() => api.analytics.summary()); + const sessions = useFetch(() => api.analytics.sessions().then((r) => r.sessions)); + const tools = useFetch(() => api.tools.costStats().then((r) => r.stats)); + const context = useFetch(() => api.analytics.context()); + const breakdown = useFetch(() => api.analytics.tokenBreakdown().then((r) => r.categories)); + + function handleBack() { + if (window.history.length > 1) { + navigate(-1); + } else { + navigate('/'); + } + } + + return ( +
+
+
+ +
+

Token Analytics

+

+ Aggregate token usage, cost, and context window data across all sessions. +

+
+
+ + {/* Summary Cards */} + {summary.loading ? ( + + ) : summary.error ? ( +
+ {summary.error} + +
+ ) : summary.data ? ( + + ) : null} + + {/* Per-Session Token Breakdown */} + + {sessions.data && } + + + {/* Per-Tool Cost Breakdown */} + + {tools.data && } + + + {/* Context Window Utilization */} + + {context.data && } + + + {/* Token Category Breakdown */} + + {breakdown.data && } + +
+
+ ); +} diff --git a/apps/web/src/pages/Results.tsx b/apps/web/src/pages/Results.tsx new file mode 100644 index 0000000..0b55038 --- /dev/null +++ b/apps/web/src/pages/Results.tsx @@ -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(fetcher: () => Promise): { + data: T | null; + loading: boolean; + error: string | null; + retry: () => void; +} { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
; +} + +// ─── 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 ( + + ); + } + const cls = + status === 'completed' + ? 'bg-emerald-500' + : status === 'failed' + ? 'bg-destructive' + : status === 'cancelled' + ? 'bg-muted-foreground/20' + : 'bg-muted-foreground/40'; // pending + return ; +} + +// ─── Tab bar ───────────────────────────────────────────────────────────────── + +type TabId = 'runs' | 'battles'; + +function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) { + return ( +
+ {[ + { id: 'runs' as TabId, label: 'Analysis Runs', icon: FileText }, + { id: 'battles' as TabId, label: 'Arena Battles', icon: Swords }, + ].map((tab) => ( + + ))} +
+ ); +} + +// ─── Empty state ───────────────────────────────────────────────────────────── + +function EmptyState({ message }: { message: string }) { + return

{message}

; +} + +// ─── Project selector ──────────────────────────────────────────────────────── + +function ProjectSelector({ + projects, + value, + onChange, +}: { + projects: Array<{ id: string; name: string }>; + value: string; + onChange: (id: string) => void; +}) { + return ( + + ); +} + +// ─── 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(null); + + if (loading) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (!data || data.length === 0) { + return ; + } + + return ( +
+ {data.map((run) => ( +
+ + + {/* Expanded detail — report preview */} + {selectedRun?.id === run.id && run.status === 'completed' && run.report && ( +
+ {truncate(run.report, 3000)} +
+ )} +
+ ))} +
+ ); +} + +// ─── 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(null); + + if (loading) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (!data || data.length === 0) { + return ; + } + + return ( +
+ {data.map((battle) => { + const hasAnalysis = battle.status === 'completed' && battle.results_path; + return ( +
+ + + {/* Expanded detail — analysis preview */} + {selectedBattle?.id === battle.id && hasAnalysis && ( +
+ +
+ )} +
+ ); + })} +
+ ); +} + +// ─── 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 ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + return ( +
+ {data ? truncate(data, 3000) : 'No analysis available.'} +
+ ); +} + +// ─── 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 ( +
+ {cards.map((c) => ( + + + +
+
{c.value}
+
{c.label}
+
+
+
+ ))} +
+ ); +} + +function SummaryCardsSkeleton() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( + + + + + + + ))} +
+ ); +} + +// ─── Main Page ─────────────────────────────────────────────────────────────── + +export function Results() { + const navigate = useNavigate(); + const { data: sidebar, activeSession } = useSidebar(); + + const [tab, setTab] = useState('runs'); + const [projectId, setProjectId] = useState(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 ( +
+
+ {/* Header */} +
+ +
+
+

+ + Results +

+

+ Completed orchestrator runs and arena battles. +

+
+ {projects.length > 0 && projectId && ( + + )} +
+
+ + {/* Summary Cards */} + {summaryLoading ? ( + + ) : ( + + )} + + {/* Tab bar */} + + + {/* Tab content */} + {!projectId ? ( + + ) : tab === 'runs' ? ( + + ) : ( + + )} +
+
+ ); +}