Compare commits
1 Commits
v2.8.2-enh
...
v2.8.3-ana
| Author | SHA1 | Date | |
|---|---|---|---|
| a72f7954b4 |
78
apps/coder/src/routes/analytics.ts
Normal file
78
apps/coder/src/routes/analytics.ts
Normal file
@@ -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<AnalyticsSummary[]>`
|
||||||
|
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<SessionAnalyticsRow[]>`
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
33
apps/server/src/routes/analytics.ts
Normal file
33
apps/server/src/routes/analytics.ts
Normal file
@@ -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<ContextWindowStats[]>`
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { Home } from '@/pages/Home';
|
|||||||
import { Project } from '@/pages/Project';
|
import { Project } from '@/pages/Project';
|
||||||
import { Session } from '@/pages/Session';
|
import { Session } from '@/pages/Session';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { Analytics } from '@/pages/Analytics';
|
||||||
|
import { Results } from '@/pages/Results';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useUserEvents } from '@/hooks/useUserEvents';
|
import { useUserEvents } from '@/hooks/useUserEvents';
|
||||||
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
|
||||||
@@ -95,6 +97,8 @@ function AppShell() {
|
|||||||
<Route path="/project/:id" element={<Project />} />
|
<Route path="/project/:id" element={<Project />} />
|
||||||
<Route path="/session/:id" element={<Session />} />
|
<Route path="/session/:id" element={<Session />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="/results" element={<Results />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<MobileRightRailBackdrop />
|
<MobileRightRailBackdrop />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import mascot from '@/assets/brand/banner-mascot.png';
|
import mascot from '@/assets/brand/banner-mascot.png';
|
||||||
@@ -519,11 +519,40 @@ export function ProjectSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* bottom-pinned nav buttons. Results → Analytics → Settings. */}
|
||||||
|
<div className="border-t shrink-0 p-2 space-y-0.5">
|
||||||
|
<NavLink
|
||||||
|
to="/results"
|
||||||
|
onClick={() => { 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"
|
||||||
|
>
|
||||||
|
<ScrollText className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Results</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/analytics"
|
||||||
|
onClick={() => { 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"
|
||||||
|
>
|
||||||
|
<BarChart3 className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
<span className="flex-1 text-left">Token Analytics</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
||||||
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
||||||
the panesHook). Outside a session there's no workspace to mount the
|
the panesHook). Outside a session there's no workspace to mount the
|
||||||
pane in, so we navigate to /settings (themes page) instead. */}
|
pane in, so we navigate to /settings (themes page) instead. */}
|
||||||
<div className="border-t shrink-0 p-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
454
apps/web/src/pages/Analytics.tsx
Normal file
454
apps/web/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BarChart3, Wifi, Wrench, Layers } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type {
|
||||||
|
AnalyticsSummary,
|
||||||
|
SessionAnalyticsRow,
|
||||||
|
ToolCostStat,
|
||||||
|
ContextWindowStats,
|
||||||
|
TokenBreakdownAgg,
|
||||||
|
} from '@/api/types';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// --- Independent section data fetcher ---
|
||||||
|
// Each section manages its own loading/error/data state so one failure doesn't
|
||||||
|
// block the rest of the page.
|
||||||
|
|
||||||
|
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 pulse placeholder ---
|
||||||
|
function SkeletonBar({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse rounded bg-muted/40', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Number formatting ---
|
||||||
|
function formatNumber(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
if (n < 0.001) return `$${(n * 1000).toFixed(2)}m`;
|
||||||
|
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||||
|
return `$${n.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(n: number | null | undefined): string {
|
||||||
|
if (n == null) return '—';
|
||||||
|
return `${(n * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary Cards ---
|
||||||
|
function SummaryCards({ summary }: { summary: AnalyticsSummary }) {
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: 'Total Input Tokens',
|
||||||
|
value: formatNumber(summary.total_input_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Output Tokens',
|
||||||
|
value: formatNumber(summary.total_output_tokens),
|
||||||
|
icon: BarChart3,
|
||||||
|
color: 'text-green-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Cost',
|
||||||
|
value: formatCost(summary.total_cost),
|
||||||
|
icon: Wifi,
|
||||||
|
color: 'text-amber-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sessions Tracked',
|
||||||
|
value: formatNumber(summary.session_count),
|
||||||
|
icon: Layers,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} size="sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-3">
|
||||||
|
<c.icon className={cn('size-5 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-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} size="sm">
|
||||||
|
<CardContent className="pt-3">
|
||||||
|
<SkeletonBar className="h-5 w-20 mb-2" />
|
||||||
|
<SkeletonBar className="h-3 w-24" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Section wrappers ---
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonBar className="h-4 w-full" />
|
||||||
|
<SkeletonBar className="h-4 w-3/4" />
|
||||||
|
<SkeletonBar className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-sm text-muted-foreground py-2">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Session Token Table ---
|
||||||
|
function SessionTable({ sessions }: { sessions: SessionAnalyticsRow[] }) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return <EmptyState message="No session token data available yet. Token data is collected as agent sessions run." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Session</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Input</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Output</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Cost</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Last Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 truncate max-w-[200px]" title={s.session_name}>
|
||||||
|
{s.session_name || 'Untitled'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_input_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(s.total_output_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatCost(s.total_cost)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right text-muted-foreground">{formatDate(s.last_active_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-Tool Cost Table ---
|
||||||
|
function ToolTable({ stats }: { stats: ToolCostStat[] }) {
|
||||||
|
if (stats.length === 0) {
|
||||||
|
return <EmptyState message="No tool cost data available yet. Stats accumulate after tool calls are made." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<th className="py-2 pr-4 font-medium">Tool</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Calls</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Prompt</th>
|
||||||
|
<th className="py-2 pr-4 font-medium tabular-nums text-right">Avg Completion</th>
|
||||||
|
<th className="py-2 font-medium tabular-nums text-right">Avg Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map((t) => (
|
||||||
|
<tr key={t.tool_name} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="py-2 pr-4 flex items-center gap-2">
|
||||||
|
<Wrench className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-[200px]" title={t.tool_name}>{t.tool_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{t.n_calls}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens)}</td>
|
||||||
|
<td className="py-2 pr-4 tabular-nums text-right">{formatNumber(t.mean_completion_tokens)}</td>
|
||||||
|
<td className="py-2 tabular-nums text-right">{formatNumber(t.mean_prompt_tokens + t.mean_completion_tokens)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context Window Utilization ---
|
||||||
|
function ContextSection({ stats }: { stats: ContextWindowStats }) {
|
||||||
|
if (stats.message_count === 0) {
|
||||||
|
return <EmptyState message="No context window data available yet. Data is captured during inference." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Used</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_used ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Context Limit</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatNumber(Math.round(stats.avg_ctx_max ?? 0))}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Avg Utilization</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums mt-1">{formatPct(stats.avg_utilization_pct)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Based on {formatNumber(stats.message_count)} completed assistant messages</div>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: stats.avg_utilization_pct != null
|
||||||
|
? `${Math.min(stats.avg_utilization_pct * 100, 100)}%`
|
||||||
|
: '0%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Token Category Breakdown (CSS stacked bar) ---
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
system: 'bg-blue-500',
|
||||||
|
user: 'bg-green-500',
|
||||||
|
assistant: 'bg-amber-500',
|
||||||
|
tools: 'bg-purple-500',
|
||||||
|
reasoning: 'bg-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
system: 'System',
|
||||||
|
user: 'User',
|
||||||
|
assistant: 'Assistant',
|
||||||
|
tools: 'Tools',
|
||||||
|
reasoning: 'Reasoning',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TokenBreakdownSection({ categories }: { categories: TokenBreakdownAgg[] }) {
|
||||||
|
if (categories.length === 0) {
|
||||||
|
return <EmptyState message="No token breakdown data available. Breakdown is captured for arena contestants and certain task types." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = categories.reduce((sum, c) => sum + c.total_tokens, 0);
|
||||||
|
if (total === 0) return <EmptyState message="Token breakdown totals are zero." />;
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="h-4 rounded-full bg-muted overflow-hidden flex">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
if (pct < 1) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.category}
|
||||||
|
className={cn('h-full first:rounded-l-full last:rounded-r-full', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
title={`${CATEGORY_LABELS[c.category] ?? c.category}: ${formatNumber(c.total_tokens)} (${pct.toFixed(1)}%)`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||||
|
{sorted.map((c) => {
|
||||||
|
const pct = (c.total_tokens / total) * 100;
|
||||||
|
return (
|
||||||
|
<div key={c.category} className="flex items-center gap-1.5">
|
||||||
|
<span className={cn('size-2.5 rounded-sm', CATEGORY_COLORS[c.category] ?? 'bg-gray-400')} />
|
||||||
|
<span className="text-muted-foreground">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||||
|
<span className="font-medium tabular-nums">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums">({formatNumber(c.total_tokens)})</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[1000px] mx-auto w-full px-6 py-6 space-y-8">
|
||||||
|
<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>
|
||||||
|
<h1 className="text-xl font-semibold">Token Analytics</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Aggregate token usage, cost, and context window data across all sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary.loading ? (
|
||||||
|
<SummaryCardsSkeleton />
|
||||||
|
) : summary.error ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-destructive">{summary.error}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={summary.retry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : summary.data ? (
|
||||||
|
<SummaryCards summary={summary.data} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Per-Session Token Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Session Token Usage"
|
||||||
|
loading={sessions.loading}
|
||||||
|
error={sessions.error}
|
||||||
|
onRetry={sessions.retry}
|
||||||
|
>
|
||||||
|
{sessions.data && <SessionTable sessions={sessions.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Per-Tool Cost Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Per-Tool Token Cost"
|
||||||
|
loading={tools.loading}
|
||||||
|
error={tools.error}
|
||||||
|
onRetry={tools.retry}
|
||||||
|
>
|
||||||
|
{tools.data && <ToolTable stats={tools.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Context Window Utilization */}
|
||||||
|
<SectionCard
|
||||||
|
title="Context Window Utilization"
|
||||||
|
loading={context.loading}
|
||||||
|
error={context.error}
|
||||||
|
onRetry={context.retry}
|
||||||
|
>
|
||||||
|
{context.data && <ContextSection stats={context.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Token Category Breakdown */}
|
||||||
|
<SectionCard
|
||||||
|
title="Token Breakdown by Category"
|
||||||
|
loading={breakdown.loading}
|
||||||
|
error={breakdown.error}
|
||||||
|
onRetry={breakdown.retry}
|
||||||
|
>
|
||||||
|
{breakdown.data && <TokenBreakdownSection categories={breakdown.data} />}
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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