Skip to content

Commit 94070cb

Browse files
committed
feat(evaluation): dataset card and run detail components
`DatasetCard` lists stored datasets and owns the upload dialog, the start button and the delete confirmation. Starting is gated on `runActive`, passed down from the run list — runs are serialised server-side, so the button should be disabled rather than reliably 409. `RunDetail` renders one run: header, error, indexing panel, quality panel and the per-question table. It polls only while `isActiveStatus(status)`, so a finished run stops refetching instead of polling a row that can no longer change. Both are self-contained: the page that composes them lands next.
1 parent b287020 commit 94070cb

2 files changed

Lines changed: 462 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { useState } from "react";
2+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3+
import { toast } from "sonner";
4+
import { Play, Trash2, Upload } from "lucide-react";
5+
import {
6+
createEvalDataset,
7+
deleteEvalDataset,
8+
listEvalDatasets,
9+
startEvalRun,
10+
} from "@/lib/api/evaluation";
11+
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
12+
import { Button } from "@/components/ui/button";
13+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
14+
import {
15+
Dialog,
16+
DialogContent,
17+
DialogDescription,
18+
DialogFooter,
19+
DialogHeader,
20+
DialogTitle,
21+
} from "@/components/ui/dialog";
22+
import { Input } from "@/components/ui/input";
23+
import { Label } from "@/components/ui/label";
24+
import { Skeleton } from "@/components/ui/skeleton";
25+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
26+
27+
const CSV_HINT = "question,expected_answer,expected_file_ids";
28+
29+
export function DatasetCard({ runActive }: { runActive: boolean }) {
30+
const queryClient = useQueryClient();
31+
const [uploadOpen, setUploadOpen] = useState(false);
32+
33+
const { data, isLoading } = useQuery({ queryKey: ["eval-datasets"], queryFn: listEvalDatasets });
34+
35+
const startMut = useMutation({
36+
mutationFn: (datasetId: string) => startEvalRun(datasetId),
37+
onSuccess: () => {
38+
toast.success("Evaluation run queued");
39+
queryClient.invalidateQueries({ queryKey: ["eval-runs"] });
40+
},
41+
onError: (e) => toast.error((e as Error).message),
42+
});
43+
44+
const deleteMut = useMutation({
45+
mutationFn: (id: string) => deleteEvalDataset(id),
46+
onSuccess: () => {
47+
toast.success("Dataset deleted");
48+
queryClient.invalidateQueries({ queryKey: ["eval-datasets"] });
49+
},
50+
onError: (e) => toast.error((e as Error).message),
51+
});
52+
53+
const datasets = data ?? [];
54+
55+
return (
56+
<Card>
57+
<CardHeader className="flex flex-row items-start justify-between">
58+
<div>
59+
<CardTitle>Datasets</CardTitle>
60+
<CardDescription>
61+
A corpus to index plus a CSV test set (<code>{CSV_HINT}</code>).
62+
</CardDescription>
63+
</div>
64+
<Button size="sm" onClick={() => setUploadOpen(true)}>
65+
<Upload className="mr-2 h-4 w-4" />
66+
New dataset
67+
</Button>
68+
</CardHeader>
69+
<CardContent>
70+
{isLoading ? (
71+
<Skeleton className="h-24" />
72+
) : datasets.length === 0 ? (
73+
<p className="py-6 text-center text-sm text-muted-foreground">
74+
No datasets yet. Upload a corpus and a test set to run an evaluation.
75+
</p>
76+
) : (
77+
<Table>
78+
<TableHeader>
79+
<TableRow>
80+
<TableHead>Name</TableHead>
81+
<TableHead className="text-right">Files</TableHead>
82+
<TableHead className="text-right">Questions</TableHead>
83+
<TableHead className="w-32" />
84+
</TableRow>
85+
</TableHeader>
86+
<TableBody>
87+
{datasets.map((dataset) => (
88+
<TableRow key={dataset.id}>
89+
<TableCell className="font-medium">{dataset.name}</TableCell>
90+
<TableCell className="text-right tabular-nums">{dataset.corpus_file_count}</TableCell>
91+
<TableCell className="text-right tabular-nums">{dataset.testset_row_count}</TableCell>
92+
<TableCell className="text-right">
93+
<Button
94+
size="sm"
95+
variant="outline"
96+
disabled={runActive || startMut.isPending}
97+
title={runActive ? "Another run is already in progress" : undefined}
98+
onClick={() => startMut.mutate(dataset.id)}
99+
>
100+
<Play className="mr-1 h-3 w-3" />
101+
Run
102+
</Button>
103+
<ConfirmDialog
104+
title="Delete dataset?"
105+
description={`"${dataset.name}" and its stored files will be removed. Past runs keep their results.`}
106+
onConfirm={() => deleteMut.mutate(dataset.id)}
107+
>
108+
<Button size="sm" variant="ghost" aria-label={`Delete ${dataset.name}`}>
109+
<Trash2 className="h-4 w-4 text-destructive" />
110+
</Button>
111+
</ConfirmDialog>
112+
</TableCell>
113+
</TableRow>
114+
))}
115+
</TableBody>
116+
</Table>
117+
)}
118+
</CardContent>
119+
120+
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
121+
</Card>
122+
);
123+
}
124+
125+
function UploadDialog({
126+
open,
127+
onOpenChange,
128+
}: {
129+
open: boolean;
130+
onOpenChange: (open: boolean) => void;
131+
}) {
132+
const queryClient = useQueryClient();
133+
const [name, setName] = useState("");
134+
const [testset, setTestset] = useState<File | null>(null);
135+
const [corpus, setCorpus] = useState<File[]>([]);
136+
137+
const reset = () => {
138+
setName("");
139+
setTestset(null);
140+
setCorpus([]);
141+
};
142+
143+
const createMut = useMutation({
144+
mutationFn: () => createEvalDataset(name, testset as File, corpus),
145+
onSuccess: (dataset) => {
146+
toast.success(`Dataset "${dataset.name}" created (${dataset.testset_row_count} questions)`);
147+
queryClient.invalidateQueries({ queryKey: ["eval-datasets"] });
148+
reset();
149+
onOpenChange(false);
150+
},
151+
// The API validates the CSV row by row; surface its message verbatim.
152+
onError: (e) => toast.error((e as Error).message),
153+
});
154+
155+
const canSubmit = name.trim() !== "" && testset !== null && corpus.length > 0;
156+
157+
// Every dismissal path — backdrop, Escape, Cancel — clears the form, so a
158+
// reopened dialog never silently resubmits the previous selection.
159+
const close = () => {
160+
reset();
161+
onOpenChange(false);
162+
};
163+
164+
return (
165+
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
166+
<DialogContent>
167+
<DialogHeader>
168+
<DialogTitle>New evaluation dataset</DialogTitle>
169+
<DialogDescription>
170+
The corpus is re-indexed on every run, which is what the indexing-speed numbers measure.
171+
</DialogDescription>
172+
</DialogHeader>
173+
174+
<div className="space-y-4">
175+
<div className="space-y-2">
176+
<Label htmlFor="eval-dataset-name">Name</Label>
177+
<Input
178+
id="eval-dataset-name"
179+
value={name}
180+
onChange={(e) => setName(e.target.value)}
181+
placeholder="Support docs — July"
182+
/>
183+
</div>
184+
<div className="space-y-2">
185+
<Label htmlFor="eval-testset">Test set (CSV)</Label>
186+
<Input
187+
id="eval-testset"
188+
type="file"
189+
accept=".csv,text/csv"
190+
onChange={(e) => setTestset(e.target.files?.[0] ?? null)}
191+
/>
192+
<p className="text-xs text-muted-foreground">
193+
Header: <code>{CSV_HINT}</code>. <code>expected_file_ids</code> is optional and
194+
semicolon-separated; rows without it are excluded from hit rate, MRR and recall.
195+
</p>
196+
</div>
197+
<div className="space-y-2">
198+
<Label htmlFor="eval-corpus">Corpus files</Label>
199+
<Input
200+
id="eval-corpus"
201+
type="file"
202+
multiple
203+
onChange={(e) => setCorpus(Array.from(e.target.files ?? []))}
204+
/>
205+
{corpus.length > 0 && (
206+
<p className="text-xs text-muted-foreground">{corpus.length} file(s) selected</p>
207+
)}
208+
</div>
209+
</div>
210+
211+
<DialogFooter>
212+
<Button variant="outline" onClick={close}>
213+
Cancel
214+
</Button>
215+
<Button disabled={!canSubmit || createMut.isPending} onClick={() => createMut.mutate()}>
216+
{createMut.isPending ? "Uploading…" : "Create"}
217+
</Button>
218+
</DialogFooter>
219+
</DialogContent>
220+
</Dialog>
221+
);
222+
}

0 commit comments

Comments
 (0)