Skip to content

Commit 0074fd2

Browse files
christsoCopilot
andauthored
feat(studio): add targets tab drilldown (#1189)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d670b47 commit 0074fd2

2 files changed

Lines changed: 260 additions & 101 deletions

File tree

apps/studio/src/components/TargetsTab.tsx

Lines changed: 257 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,97 @@
11
/**
2-
* Targets table showing targets grouped across all runs.
2+
* Targets tab with drill-down from target -> experiment-grouped runs.
33
*
4-
* Displays target name, number of runs, experiments, pass rate, and
5-
* eval counts (passed/total). Links are not needed since targets are
6-
* informational groupings.
4+
* The summary table opens a target detail view. That detail view groups runs
5+
* by experiment and reuses the existing run-detail routes for the final click,
6+
* so category breakdowns and individual test cases stay consistent everywhere.
77
*/
88

9-
import { useTargets } from '~/lib/api';
10-
import type { TargetSummary } from '~/lib/types';
9+
import { useQuery } from '@tanstack/react-query';
10+
import { useEffect, useMemo, useState } from 'react';
11+
12+
import {
13+
benchmarkRunListOptions,
14+
benchmarkTargetsOptions,
15+
runListOptions,
16+
targetsOptions,
17+
} from '~/lib/api';
18+
import type { RunMeta, TargetsResponse } from '~/lib/types';
1119

1220
import { PassRatePill } from './PassRatePill';
21+
import { RunList } from './RunList';
22+
23+
interface TargetsTabProps {
24+
benchmarkId?: string;
25+
}
26+
27+
interface ExperimentRunGroup {
28+
name: string;
29+
runs: RunMeta[];
30+
latestTimestamp: string | null;
31+
evalCount: number;
32+
passedCount: number;
33+
passRate: number;
34+
}
35+
36+
export function TargetsTab({ benchmarkId }: TargetsTabProps = {}) {
37+
const [selectedTargetName, setSelectedTargetName] = useState<string | null>(null);
38+
const targetsQuery = useQuery(
39+
benchmarkId ? benchmarkTargetsOptions(benchmarkId) : targetsOptions,
40+
);
41+
const runsQuery = useQuery(benchmarkId ? benchmarkRunListOptions(benchmarkId) : runListOptions);
42+
const targets = (targetsQuery.data as TargetsResponse | undefined)?.targets ?? [];
43+
const runs = runsQuery.data?.runs ?? [];
44+
const error = targetsQuery.error ?? runsQuery.error;
45+
const isLoading = targetsQuery.isLoading || runsQuery.isLoading;
46+
47+
const selectedTarget = useMemo(
48+
() => targets.find((target) => target.name === selectedTargetName) ?? null,
49+
[selectedTargetName, targets],
50+
);
51+
52+
useEffect(() => {
53+
if (selectedTargetName && !targets.some((target) => target.name === selectedTargetName)) {
54+
setSelectedTargetName(null);
55+
}
56+
}, [selectedTargetName, targets]);
57+
58+
const experimentGroups = useMemo(() => {
59+
if (!selectedTarget) return [];
60+
61+
const groups = new Map<string, RunMeta[]>();
62+
for (const run of runs) {
63+
const targetName = run.target ?? 'default';
64+
if (targetName !== selectedTarget.name) continue;
1365

14-
export function TargetsTab() {
15-
const { data, isLoading } = useTargets();
66+
const experimentName = run.experiment ?? 'default';
67+
const existing = groups.get(experimentName) ?? [];
68+
existing.push(run);
69+
groups.set(experimentName, existing);
70+
}
71+
72+
return [...groups.entries()]
73+
.map(([name, experimentRuns]) => buildExperimentGroup(name, experimentRuns))
74+
.sort((a, b) => {
75+
if (a.latestTimestamp && b.latestTimestamp && a.latestTimestamp !== b.latestTimestamp) {
76+
return b.latestTimestamp.localeCompare(a.latestTimestamp);
77+
}
78+
if (a.latestTimestamp) return -1;
79+
if (b.latestTimestamp) return 1;
80+
return a.name.localeCompare(b.name);
81+
});
82+
}, [runs, selectedTarget]);
1683

1784
if (isLoading) {
1885
return <LoadingSkeleton />;
1986
}
2087

21-
const targets = data?.targets ?? [];
88+
if (error) {
89+
return (
90+
<div className="rounded-lg border border-red-900/50 bg-red-950/20 p-6 text-red-400">
91+
Failed to load targets: {error.message}
92+
</div>
93+
);
94+
}
2295

2396
if (targets.length === 0) {
2497
return (
@@ -31,60 +104,191 @@ export function TargetsTab() {
31104
);
32105
}
33106

34-
return (
35-
<div className="overflow-hidden rounded-lg border border-gray-800">
36-
<table className="w-full text-left text-sm">
37-
<thead className="border-b border-gray-800 bg-gray-900/50">
38-
<tr>
39-
<th className="px-4 py-3 font-medium text-gray-400">Target</th>
40-
<th className="px-4 py-3 text-right font-medium text-gray-400">Runs</th>
41-
<th className="px-4 py-3 text-right font-medium text-gray-400">Experiments</th>
42-
<th className="px-4 py-3 font-medium text-gray-400">Pass Rate</th>
43-
<th className="px-4 py-3 text-right font-medium text-gray-400">Evals</th>
44-
</tr>
45-
</thead>
46-
<tbody className="divide-y divide-gray-800/50">
47-
{targets.map((target: TargetSummary) => (
48-
<tr key={target.name} className="transition-colors hover:bg-gray-900/30">
49-
<td className="px-4 py-3 font-medium text-gray-200">{target.name}</td>
50-
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
51-
{target.run_count}
52-
</td>
53-
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
54-
{target.experiment_count}
55-
</td>
56-
<td className="px-4 py-3">
57-
<PassRatePill rate={target.pass_rate} />
58-
</td>
59-
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
60-
<span className="text-emerald-400">{target.passed_count}</span>
61-
<span className="text-gray-600">/</span>
62-
<span>{target.eval_count}</span>
63-
</td>
107+
if (!selectedTarget) {
108+
return (
109+
<div className="overflow-hidden rounded-lg border border-gray-800">
110+
<table className="w-full text-left text-sm">
111+
<thead className="border-b border-gray-800 bg-gray-900/50">
112+
<tr>
113+
<th className="px-4 py-3 font-medium text-gray-400">Target</th>
114+
<th className="px-4 py-3 text-right font-medium text-gray-400">Runs</th>
115+
<th className="px-4 py-3 text-right font-medium text-gray-400">Experiments</th>
116+
<th className="px-4 py-3 font-medium text-gray-400">Pass Rate</th>
117+
<th className="px-4 py-3 text-right font-medium text-gray-400">Evals</th>
64118
</tr>
119+
</thead>
120+
<tbody className="divide-y divide-gray-800/50">
121+
{targets.map((target) => (
122+
<tr key={target.name} className="transition-colors hover:bg-gray-900/30">
123+
<td className="px-4 py-3">
124+
<button
125+
type="button"
126+
onClick={() => setSelectedTargetName(target.name)}
127+
className="font-medium text-cyan-400 hover:text-cyan-300 hover:underline"
128+
>
129+
{target.name}
130+
</button>
131+
</td>
132+
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
133+
{target.run_count}
134+
</td>
135+
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
136+
{target.experiment_count}
137+
</td>
138+
<td className="px-4 py-3">
139+
<PassRatePill rate={target.pass_rate} />
140+
</td>
141+
<td className="px-4 py-3 text-right tabular-nums text-gray-400">
142+
<span className="text-emerald-400">{target.passed_count}</span>
143+
<span className="text-gray-600"> / </span>
144+
<span>{target.eval_count}</span>
145+
</td>
146+
</tr>
147+
))}
148+
</tbody>
149+
</table>
150+
</div>
151+
);
152+
}
153+
154+
return (
155+
<div className="space-y-6">
156+
<div className="space-y-3">
157+
<button
158+
type="button"
159+
onClick={() => setSelectedTargetName(null)}
160+
className="rounded-md px-3 py-1.5 text-sm text-gray-400 transition-colors hover:text-gray-200"
161+
>
162+
← Back to targets
163+
</button>
164+
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-4">
165+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
166+
<div>
167+
<h2 className="text-xl font-semibold text-white">{selectedTarget.name}</h2>
168+
<p className="mt-1 text-sm text-gray-400">
169+
{selectedTarget.run_count} run{selectedTarget.run_count === 1 ? '' : 's'} &middot;{' '}
170+
{selectedTarget.experiment_count} experiment
171+
{selectedTarget.experiment_count === 1 ? '' : 's'} &middot;{' '}
172+
<span className="text-emerald-400">{selectedTarget.passed_count}</span>
173+
<span className="text-gray-600"> / </span>
174+
{selectedTarget.eval_count} evals passed
175+
</p>
176+
</div>
177+
<div className="w-full max-w-52">
178+
<PassRatePill rate={selectedTarget.pass_rate} />
179+
</div>
180+
</div>
181+
</div>
182+
</div>
183+
184+
{experimentGroups.length === 0 ? (
185+
<div className="rounded-lg border border-gray-800 bg-gray-900 p-8 text-center">
186+
<p className="text-lg text-gray-400">No runs found for this target</p>
187+
<p className="mt-2 text-sm text-gray-500">
188+
This target summary exists, but there are no matching runs to group by experiment.
189+
</p>
190+
</div>
191+
) : (
192+
<div className="space-y-6">
193+
{experimentGroups.map((group) => (
194+
<section key={group.name} className="space-y-3">
195+
<div className="flex flex-col gap-3 rounded-lg border border-gray-800 bg-gray-900/40 p-4 sm:flex-row sm:items-center sm:justify-between">
196+
<div>
197+
<h3 className="text-lg font-medium text-gray-200">
198+
{formatExperimentName(group.name)}
199+
</h3>
200+
<p className="mt-1 text-sm text-gray-400">
201+
{group.runs.length} run{group.runs.length === 1 ? '' : 's'} &middot;{' '}
202+
<span className="text-emerald-400">{group.passedCount}</span>
203+
<span className="text-gray-600"> / </span>
204+
{group.evalCount} evals passed
205+
{group.latestTimestamp && (
206+
<span className="ml-2 text-gray-500">
207+
&middot; Last run {formatTimestamp(group.latestTimestamp)}
208+
</span>
209+
)}
210+
</p>
211+
</div>
212+
<div className="w-full max-w-52">
213+
<PassRatePill rate={group.passRate} />
214+
</div>
215+
</div>
216+
<RunList runs={group.runs} benchmarkId={benchmarkId} />
217+
</section>
65218
))}
66-
</tbody>
67-
</table>
219+
</div>
220+
)}
68221
</div>
69222
);
70223
}
71224

225+
function buildExperimentGroup(name: string, runs: RunMeta[]): ExperimentRunGroup {
226+
let evalCount = 0;
227+
let passedCount = 0;
228+
let latestTimestamp: string | null = null;
229+
230+
for (const run of runs) {
231+
evalCount += run.test_count;
232+
passedCount += Math.round(run.pass_rate * run.test_count);
233+
if (run.timestamp && (!latestTimestamp || run.timestamp > latestTimestamp)) {
234+
latestTimestamp = run.timestamp;
235+
}
236+
}
237+
238+
return {
239+
name,
240+
runs,
241+
latestTimestamp,
242+
evalCount,
243+
passedCount,
244+
passRate: evalCount > 0 ? passedCount / evalCount : 0,
245+
};
246+
}
247+
248+
function formatExperimentName(name: string): string {
249+
return name === 'default' ? 'Default experiment' : name;
250+
}
251+
252+
function formatTimestamp(ts: string): string {
253+
const date = new Date(ts);
254+
if (Number.isNaN(date.getTime())) return ts;
255+
256+
const diffMs = Date.now() - date.getTime();
257+
const diffMin = Math.floor(diffMs / 60_000);
258+
const diffHour = Math.floor(diffMs / 3_600_000);
259+
260+
if (diffMin < 1) return 'just now';
261+
if (diffMin < 60) return `${diffMin} min ago`;
262+
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
263+
return date.toLocaleDateString();
264+
}
265+
72266
function LoadingSkeleton() {
73267
return (
74-
<div className="overflow-hidden rounded-lg border border-gray-800">
75-
<div className="animate-pulse">
76-
<div className="border-b border-gray-800 bg-gray-900/50 px-4 py-3">
77-
<div className="h-4 w-48 rounded bg-gray-800" />
78-
</div>
79-
{['sk-1', 'sk-2', 'sk-3', 'sk-4', 'sk-5'].map((id) => (
80-
<div key={id} className="flex gap-4 border-b border-gray-800/50 px-4 py-3">
81-
<div className="h-4 w-32 rounded bg-gray-800" />
82-
<div className="h-4 w-12 rounded bg-gray-800" />
83-
<div className="h-4 w-12 rounded bg-gray-800" />
268+
<div className="space-y-4">
269+
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-4">
270+
<div className="h-6 w-40 animate-pulse rounded bg-gray-800" />
271+
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-gray-800" />
272+
</div>
273+
<div className="overflow-hidden rounded-lg border border-gray-800">
274+
<div className="animate-pulse">
275+
<div className="border-b border-gray-800 bg-gray-900/50 px-4 py-3">
84276
<div className="h-4 w-48 rounded bg-gray-800" />
85-
<div className="h-4 w-20 rounded bg-gray-800" />
86277
</div>
87-
))}
278+
{['sk-1', 'sk-2', 'sk-3', 'sk-4', 'sk-5'].map((id) => (
279+
<div key={id} className="flex gap-4 border-b border-gray-800/50 px-4 py-3">
280+
<div className="h-4 w-32 rounded bg-gray-800" />
281+
<div className="h-4 w-12 rounded bg-gray-800" />
282+
<div className="h-4 w-12 rounded bg-gray-800" />
283+
<div className="h-4 w-48 rounded bg-gray-800" />
284+
<div className="h-4 w-20 rounded bg-gray-800" />
285+
</div>
286+
))}
287+
</div>
288+
</div>
289+
<div className="rounded-lg border border-gray-800 bg-gray-900/40 p-4">
290+
<div className="h-5 w-48 animate-pulse rounded bg-gray-800" />
291+
<div className="mt-3 h-4 w-56 animate-pulse rounded bg-gray-800" />
88292
</div>
89293
</div>
90294
);

0 commit comments

Comments
 (0)