172 lines
5.3 KiB
React
172 lines
5.3 KiB
React
import React, { useState, useEffect, useCallback } from "react";
|
|
import { render, Box, Text, useInput } from "ink";
|
|
import Spinner from "ink-spinner";
|
|
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
|
|
import { StatusBadge } from "../tui-components/StatusBadge.jsx";
|
|
import { DataTable } from "../tui-components/DataTable.jsx";
|
|
import { HeaderSwr } from "../tui-components/HeaderSwr.jsx";
|
|
|
|
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
|
|
const RESULT_SCHEMA = [
|
|
{ key: "idx", header: "#", width: 5 },
|
|
{ key: "status", header: "Status", width: 10, formatter: (v) => (v === "pass" ? "✔" : "✖") },
|
|
{ key: "model", header: "Model", width: 22 },
|
|
{ key: "score", header: "Score", width: 8, formatter: (v) => (v != null ? String(v) : "-") },
|
|
{ key: "latencyMs", header: "ms", width: 8, formatter: (v) => (v != null ? String(v) : "-") },
|
|
];
|
|
|
|
function EvalWatchApp({ runId, suiteId, baseUrl, apiKey, onExit }) {
|
|
const [run, setRun] = useState(null);
|
|
const [results, setResults] = useState([]);
|
|
const [paused, setPaused] = useState(false);
|
|
const [done, setDone] = useState(false);
|
|
const [elapsed, setElapsed] = useState(0);
|
|
|
|
const fetchUrl = `${baseUrl ?? "http://localhost:20128"}/api/evals/${runId}`;
|
|
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const fetcher = useCallback(async () => {
|
|
if (paused) return null;
|
|
const res = await fetch(fetchUrl, { headers });
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}, [fetchUrl, paused]);
|
|
|
|
useEffect(() => {
|
|
if (!run) return;
|
|
if (TERMINAL_STATUSES.has(run.status)) {
|
|
setDone(true);
|
|
}
|
|
const samples = run.samples ?? run.results ?? [];
|
|
setResults(
|
|
samples.map((s, i) => ({
|
|
idx: i + 1,
|
|
status: s.pass ? "pass" : "fail",
|
|
model: s.model ?? "-",
|
|
score: s.score,
|
|
latencyMs: s.latencyMs,
|
|
}))
|
|
);
|
|
}, [run]);
|
|
|
|
useInput((input, key) => {
|
|
if (input === "q" || (key.ctrl && input === "c")) onExit?.();
|
|
if (input === "p") setPaused((p) => !p);
|
|
});
|
|
|
|
const total = run?.progress?.total ?? 0;
|
|
const completed = run?.progress?.completed ?? 0;
|
|
const passed = run?.progress?.passed ?? results.filter((r) => r.status === "pass").length;
|
|
const failed = run?.progress?.failed ?? results.filter((r) => r.status === "fail").length;
|
|
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
|
|
return (
|
|
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} paddingY={1}>
|
|
<Box marginBottom={1} justifyContent="space-between">
|
|
<Text bold color="cyan">
|
|
Eval Watch — Run {runId}
|
|
{suiteId ? ` (suite: ${suiteId})` : ""}
|
|
</Text>
|
|
<Text dimColor>
|
|
{Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, "0")}
|
|
{paused ? " [PAUSED]" : ""}
|
|
</Text>
|
|
</Box>
|
|
|
|
<HeaderSwr
|
|
fetcher={fetcher}
|
|
interval={done ? 0 : 3000}
|
|
render={(data) => {
|
|
if (data && data !== run) setRun(data);
|
|
return null;
|
|
}}
|
|
initial={null}
|
|
/>
|
|
|
|
{run ? (
|
|
<>
|
|
<Box marginBottom={1}>
|
|
<StatusBadge
|
|
status={
|
|
run.status === "running" ? "running" : run.status === "completed" ? "ok" : "error"
|
|
}
|
|
/>
|
|
<Text> {run.status} </Text>
|
|
<Text dimColor>
|
|
{completed}/{total || "?"} samples
|
|
</Text>
|
|
</Box>
|
|
|
|
{total > 0 && (
|
|
<Box marginBottom={1} flexDirection="column">
|
|
<ProgressBar value={pct} total={100} color="cyan" />
|
|
<Box marginTop={0}>
|
|
<Text color="green">✔ {passed} passed</Text>
|
|
<Text> </Text>
|
|
<Text color="red">✖ {failed} failed</Text>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{results.length > 0 && (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Text bold dimColor>
|
|
Recent results
|
|
</Text>
|
|
<DataTable rows={results.slice(-10)} schema={RESULT_SCHEMA} />
|
|
</Box>
|
|
)}
|
|
|
|
{done && (
|
|
<Box marginTop={1}>
|
|
<Text bold color={run.status === "completed" ? "green" : "red"}>
|
|
{run.status === "completed"
|
|
? `✔ Eval completed — ${passed}/${total} passed`
|
|
: `✖ Eval ${run.status}`}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Box>
|
|
<Text color="green">
|
|
<Spinner type="dots" />
|
|
</Text>
|
|
<Text> Loading...</Text>
|
|
</Box>
|
|
)}
|
|
|
|
<Box marginTop={1}>
|
|
<Text dimColor>[q] quit [p] {paused ? "resume" : "pause"}</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export async function startEvalWatchTui({ runId, suiteId, baseUrl, apiKey }) {
|
|
return new Promise((resolve, reject) => {
|
|
function onExit() {
|
|
unmount();
|
|
resolve();
|
|
}
|
|
|
|
const { unmount, waitUntilExit } = render(
|
|
<EvalWatchApp
|
|
runId={runId}
|
|
suiteId={suiteId}
|
|
baseUrl={baseUrl}
|
|
apiKey={apiKey}
|
|
onExit={onExit}
|
|
/>
|
|
);
|
|
|
|
waitUntilExit().then(resolve).catch(reject);
|
|
});
|
|
}
|