191 lines
5.4 KiB
React
191 lines
5.4 KiB
React
import React, { useState, useEffect, useCallback } from "react";
|
|
import { render, Box, Text, useInput } from "ink";
|
|
import Spinner from "ink-spinner";
|
|
import { DataTable } from "../tui-components/DataTable.jsx";
|
|
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
|
|
|
|
const STATUS = {
|
|
PENDING: "pending",
|
|
RUNNING: "running",
|
|
PASS: "pass",
|
|
FAIL: "fail",
|
|
SKIP: "skip",
|
|
};
|
|
|
|
const TABLE_SCHEMA = [
|
|
{ key: "provider", header: "Provider", width: 28 },
|
|
{ key: "model", header: "Model", width: 32 },
|
|
{
|
|
key: "status",
|
|
header: "Status",
|
|
width: 10,
|
|
formatter: (v) => {
|
|
if (v === STATUS.RUNNING) return "…";
|
|
if (v === STATUS.PASS) return "✔";
|
|
if (v === STATUS.FAIL) return "✖";
|
|
if (v === STATUS.SKIP) return "—";
|
|
return "·";
|
|
},
|
|
},
|
|
{ key: "latencyMs", header: "ms", width: 8, formatter: (v) => (v != null ? String(v) : "-") },
|
|
{ key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") },
|
|
];
|
|
|
|
async function testOne(provider, model, baseUrl, apiKey) {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
};
|
|
const start = Date.now();
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/v1/providers/test`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ provider, model }),
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
const latencyMs = Date.now() - start;
|
|
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
|
|
return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
status: STATUS.FAIL,
|
|
latencyMs: Date.now() - start,
|
|
error: msg.slice(0, 100),
|
|
};
|
|
}
|
|
}
|
|
|
|
function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onExit }) {
|
|
const resolved = `${baseUrl ?? "http://localhost:20128"}`;
|
|
|
|
const [rows, setRows] = useState(() =>
|
|
providers.map((p, i) => ({
|
|
id: i,
|
|
provider: p.provider ?? p.id ?? String(p),
|
|
model: p.model ?? p.defaultModel ?? "",
|
|
status: STATUS.PENDING,
|
|
latencyMs: null,
|
|
error: null,
|
|
}))
|
|
);
|
|
const [done, setDone] = useState(false);
|
|
const [started, setStarted] = useState(false);
|
|
|
|
const update = useCallback((id, patch) => {
|
|
setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (started) return;
|
|
setStarted(true);
|
|
|
|
async function runAll() {
|
|
const queue = [...rows];
|
|
let running = 0;
|
|
let cursor = 0;
|
|
|
|
function nextSlot() {
|
|
while (running < concurrency && cursor < queue.length) {
|
|
const row = queue[cursor++];
|
|
running++;
|
|
update(row.id, { status: STATUS.RUNNING });
|
|
testOne(row.provider, row.model, resolved, apiKey).then((result) => {
|
|
update(row.id, result);
|
|
running--;
|
|
nextSlot();
|
|
if (cursor >= queue.length && running === 0) setDone(true);
|
|
});
|
|
}
|
|
}
|
|
|
|
nextSlot();
|
|
}
|
|
|
|
runAll();
|
|
}, []);
|
|
|
|
useInput((input, key) => {
|
|
if (input === "q" || (key.ctrl && input === "c")) onExit?.();
|
|
});
|
|
|
|
const total = rows.length;
|
|
const completed = rows.filter((r) => r.status === STATUS.PASS || r.status === STATUS.FAIL).length;
|
|
const passed = rows.filter((r) => r.status === STATUS.PASS).length;
|
|
const failed = rows.filter((r) => r.status === STATUS.FAIL).length;
|
|
const running = rows.filter((r) => r.status === STATUS.RUNNING).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">
|
|
Providers Test All
|
|
</Text>
|
|
<Text dimColor>
|
|
{running > 0 ? (
|
|
<>
|
|
<Spinner type="dots" /> {running} running
|
|
</>
|
|
) : done ? (
|
|
"done"
|
|
) : (
|
|
"queued"
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
|
|
<Box marginBottom={1} flexDirection="column">
|
|
<ProgressBar value={pct} total={100} color={done && failed > 0 ? "red" : "cyan"} />
|
|
<Box marginTop={0}>
|
|
<Text>
|
|
{completed}/{total}
|
|
</Text>
|
|
<Text> </Text>
|
|
<Text color="green">✔ {passed}</Text>
|
|
<Text> </Text>
|
|
<Text color="red">✖ {failed}</Text>
|
|
</Box>
|
|
</Box>
|
|
|
|
<DataTable rows={rows} schema={TABLE_SCHEMA} />
|
|
|
|
{done && (
|
|
<Box marginTop={1}>
|
|
<Text bold color={failed === 0 ? "green" : "yellow"}>
|
|
{failed === 0
|
|
? `All ${passed} providers passed!`
|
|
: `${passed} passed, ${failed} failed`}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
<Box marginTop={1}>
|
|
<Text dimColor>[q] quit</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export async function startProvidersTestTui({ providers, baseUrl, apiKey, concurrency = 4 }) {
|
|
return new Promise((resolve, reject) => {
|
|
function onExit() {
|
|
unmount();
|
|
resolve();
|
|
}
|
|
|
|
const { unmount, waitUntilExit } = render(
|
|
<ProvidersTestAllApp
|
|
providers={providers}
|
|
baseUrl={baseUrl}
|
|
apiKey={apiKey}
|
|
concurrency={concurrency}
|
|
onExit={onExit}
|
|
/>
|
|
);
|
|
|
|
waitUntilExit().then(resolve).catch(reject);
|
|
});
|
|
}
|