chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import Overview from "./tabs/Overview.jsx";
|
||||
import Combos from "./tabs/Combos.jsx";
|
||||
import Providers from "./tabs/Providers.jsx";
|
||||
import Keys from "./tabs/Keys.jsx";
|
||||
import Logs from "./tabs/Logs.jsx";
|
||||
import Health from "./tabs/Health.jsx";
|
||||
import Cost from "./tabs/Cost.jsx";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview", Component: Overview },
|
||||
{ id: "combos", label: "Combos", Component: Combos },
|
||||
{ id: "providers", label: "Providers", Component: Providers },
|
||||
{ id: "keys", label: "Keys", Component: Keys },
|
||||
{ id: "logs", label: "Logs", Component: Logs },
|
||||
{ id: "health", label: "Health", Component: Health },
|
||||
{ id: "cost", label: "Cost $", Component: Cost },
|
||||
];
|
||||
|
||||
function DashboardApp({ port, baseUrl, apiKey, onExit }) {
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === "q" || (key.ctrl && input === "c")) onExit();
|
||||
const n = parseInt(input, 10);
|
||||
if (n >= 1 && n <= TABS.length) setActive(n - 1);
|
||||
if (key.tab && !key.shift) setActive((a) => (a + 1) % TABS.length);
|
||||
if (key.tab && key.shift) setActive((a) => (a - 1 + TABS.length) % TABS.length);
|
||||
});
|
||||
|
||||
const ActiveComponent = TABS[active]?.Component;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="round" borderColor="cyan" paddingX={1} gap={1}>
|
||||
<Text bold color="cyan">
|
||||
OmniRoute
|
||||
</Text>
|
||||
<Text dimColor>|</Text>
|
||||
{TABS.map((tab, i) => (
|
||||
<Text
|
||||
key={tab.id}
|
||||
bold={i === active}
|
||||
underline={i === active}
|
||||
color={i === active ? "yellow" : undefined}
|
||||
>
|
||||
[{i + 1}]{tab.label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexGrow={1} paddingX={1} paddingY={1}>
|
||||
{ActiveComponent && <ActiveComponent port={port} baseUrl={baseUrl} apiKey={apiKey} />}
|
||||
</Box>
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>[q]uit [Tab] next [1-7] jump [r]efresh [/]filter</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export async function startInteractiveTui({ port = 20128, baseUrl, apiKey } = {}) {
|
||||
const resolvedUrl = baseUrl ?? `http://localhost:${port}`;
|
||||
return new Promise((resolve) => {
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<DashboardApp port={port} baseUrl={resolvedUrl} apiKey={apiKey} onExit={() => unmount()} />
|
||||
);
|
||||
waitUntilExit().then(resolve).catch(resolve);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import { MenuSelect } from "../tui-components/MenuSelect.jsx";
|
||||
|
||||
function InterfaceMenuApp({ version, baseUrl, hasUpdate, latestVersion, onChoice }) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<Box borderStyle="double" borderColor="cyan" paddingX={2} paddingY={1} flexDirection="column">
|
||||
<Text bold color="cyan">
|
||||
⚡ OmniRoute {version ? `v${version}` : ""}
|
||||
</Text>
|
||||
<Text dimColor>{baseUrl}</Text>
|
||||
</Box>
|
||||
{hasUpdate && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="yellow">
|
||||
↑ Update available: v{latestVersion} (run `omniroute update --apply`)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<MenuSelect
|
||||
items={[
|
||||
{ label: "🌐 Open Web UI in Browser", hint: "(default)" },
|
||||
{ label: "💻 Interactive TUI Dashboard" },
|
||||
{ label: "🔔 Start in Background (daemon)" },
|
||||
{ label: "📊 Show Live Logs" },
|
||||
{ label: "🚪 Exit" },
|
||||
]}
|
||||
onSelect={(item) => onChoice(item.label)}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] navigate [Enter] select [1-5] shortcut [q] exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export async function showInterfaceMenu({ version, baseUrl, hasUpdate, latestVersion } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { unmount } = render(
|
||||
<InterfaceMenuApp
|
||||
version={version}
|
||||
baseUrl={baseUrl}
|
||||
hasUpdate={hasUpdate}
|
||||
latestVersion={latestVersion}
|
||||
onChoice={(choice) => {
|
||||
unmount();
|
||||
resolve(choice);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import { StatusBadge } from "../tui-components/StatusBadge.jsx";
|
||||
import { ConfirmDialog } from "../tui-components/ConfirmDialog.jsx";
|
||||
|
||||
const PHASE = {
|
||||
WAITING: "waiting",
|
||||
POLLING: "polling",
|
||||
DONE: "done",
|
||||
FAILED: "failed",
|
||||
CANCELLED: "cancelled",
|
||||
};
|
||||
|
||||
let _globalDone = null;
|
||||
let _globalFail = null;
|
||||
|
||||
function OAuthFlowApp({ provider, url, deviceCode, onCancel, onDone, onFail }) {
|
||||
const [phase, setPhase] = useState(PHASE.WAITING);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
_globalDone = (res) => {
|
||||
setResult(res);
|
||||
setPhase(PHASE.DONE);
|
||||
onDone?.(res);
|
||||
};
|
||||
_globalFail = (err) => {
|
||||
setError(typeof err === "string" ? err : (err?.message ?? String(err)));
|
||||
setPhase(PHASE.FAILED);
|
||||
onFail?.(err);
|
||||
};
|
||||
setPhase(PHASE.POLLING);
|
||||
return () => {
|
||||
_globalDone = null;
|
||||
_globalFail = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (phase === PHASE.DONE || phase === PHASE.FAILED || phase === PHASE.CANCELLED) return;
|
||||
if (input === "q" || (key.ctrl && input === "c")) {
|
||||
setConfirmCancel(true);
|
||||
}
|
||||
});
|
||||
|
||||
function handleCancelConfirm(yes) {
|
||||
setConfirmCancel(false);
|
||||
if (yes) {
|
||||
setPhase(PHASE.CANCELLED);
|
||||
onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed_str = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;
|
||||
|
||||
if (confirmCancel) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<ConfirmDialog
|
||||
message="Cancel OAuth authorization?"
|
||||
onConfirm={handleCancelConfirm}
|
||||
defaultNo
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={2} paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
OmniRoute OAuth — {provider}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{url && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>Open this URL in your browser to authorize:</Text>
|
||||
<Box marginTop={0}>
|
||||
<Text bold color="yellow">
|
||||
{url}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{deviceCode && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text>
|
||||
Device code:{" "}
|
||||
<Text bold color="yellow">
|
||||
{deviceCode}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
{phase === PHASE.POLLING || phase === PHASE.WAITING ? (
|
||||
<Box>
|
||||
<Text color="green">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text> Waiting for authorization... </Text>
|
||||
<Text dimColor>({elapsed_str})</Text>
|
||||
</Box>
|
||||
) : phase === PHASE.DONE ? (
|
||||
<Box>
|
||||
<StatusBadge status="ok" />
|
||||
<Text> Authorized: {result?.email ?? result?.account ?? "connected"}</Text>
|
||||
</Box>
|
||||
) : phase === PHASE.FAILED ? (
|
||||
<Box>
|
||||
<StatusBadge status="error" />
|
||||
<Text> Failed: {error}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<StatusBadge status="warn" />
|
||||
<Text> Cancelled.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(phase === PHASE.POLLING || phase === PHASE.WAITING) && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[q] cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export async function startOAuthTui({ provider, url, deviceCode }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
|
||||
function onDone(result) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
unmount();
|
||||
resolve({ status: "authorized", result });
|
||||
}
|
||||
|
||||
function onFail(err) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
unmount();
|
||||
resolve({ status: "failed", error: err });
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
unmount();
|
||||
resolve({ status: "cancelled" });
|
||||
}
|
||||
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<OAuthFlowApp
|
||||
provider={provider}
|
||||
url={url}
|
||||
deviceCode={deviceCode}
|
||||
onDone={onDone}
|
||||
onFail={onFail}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
waitUntilExit()
|
||||
.then(() => {
|
||||
if (!resolved) resolve({ status: "exited" });
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function markOAuthDone(result) {
|
||||
_globalDone?.(result);
|
||||
}
|
||||
|
||||
export function markOAuthFailed(error) {
|
||||
_globalFail?.(error);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
import { marked } from "marked";
|
||||
import { markedTerminal } from "marked-terminal";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { TokenCounter } from "../tui-components/TokenCounter.jsx";
|
||||
import { MarkdownView } from "../tui-components/MarkdownView.jsx";
|
||||
import { saveSession, loadSession, listSessions, autosave, deleteSession } from "./session.mjs";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
marked.use(markedTerminal({ width: 80 }));
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
"model",
|
||||
"combo",
|
||||
"system",
|
||||
"clear",
|
||||
"save",
|
||||
"load",
|
||||
"list",
|
||||
"history",
|
||||
"export",
|
||||
"tokens",
|
||||
"file",
|
||||
"temperature",
|
||||
"max-tokens",
|
||||
"reasoning",
|
||||
"skill",
|
||||
"memory",
|
||||
"help",
|
||||
"exit",
|
||||
"quit",
|
||||
];
|
||||
|
||||
const HELP_TEXT = `Available commands:
|
||||
/model <id> Change active model
|
||||
/combo <name> Change active combo
|
||||
/system <prompt> Set system prompt
|
||||
/clear Clear conversation history
|
||||
/save <name> Save current session
|
||||
/load <name> Load a saved session
|
||||
/list List saved sessions
|
||||
/history [N] Show last N messages (default 10)
|
||||
/export <file> Export conversation (md/json/txt)
|
||||
/tokens Show token usage + cost
|
||||
/file <path> Attach file content to next message
|
||||
/temperature <t> Adjust temperature (0-2)
|
||||
/max-tokens <n> Adjust max tokens
|
||||
/reasoning <level> Adjust reasoning level
|
||||
/skill execute <id> '<args>' Run a skill
|
||||
/memory search <q> Search memory
|
||||
/memory add <text> Add to memory
|
||||
/help Show this help
|
||||
/exit, /quit Exit REPL`;
|
||||
|
||||
function Message({ message }) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{isUser && (
|
||||
<Text color="green" bold>
|
||||
{">"}{" "}
|
||||
</Text>
|
||||
)}
|
||||
{isSystem && <Text color="yellow">[system] </Text>}
|
||||
<MarkdownView content={message.content} />
|
||||
{message.latencyMs != null && (
|
||||
<Text dimColor>
|
||||
[{message.model} · {message.latencyMs}ms · {message.usage?.total_tokens ?? "?"} tok]
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ session }) {
|
||||
return (
|
||||
<Box flexDirection="column" width={20} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text bold underline>
|
||||
Session
|
||||
</Text>
|
||||
<Text>
|
||||
Model: <Text color="yellow">{session.model}</Text>
|
||||
</Text>
|
||||
{session.combo && <Text>Combo: {session.combo}</Text>}
|
||||
<Text>Msgs: {session.messages.length}</Text>
|
||||
<Box marginTop={1}>
|
||||
<TokenCounter
|
||||
tokensIn={session.totalUsage.in}
|
||||
tokensOut={session.totalUsage.out}
|
||||
costUsd={session.totalCost}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor bold>
|
||||
Commands
|
||||
</Text>
|
||||
{["/model", "/combo", "/system", "/clear", "/save", "/load", "/tokens", "/exit"].map(
|
||||
(c) => (
|
||||
<Text key={c} dimColor>
|
||||
{c}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplApp({ initialOptions, onExit }) {
|
||||
const [session, setSession] = useState(() => {
|
||||
if (initialOptions.resume) {
|
||||
try {
|
||||
return loadSession(initialOptions.resume);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
model: initialOptions.model || "auto",
|
||||
combo: initialOptions.combo || null,
|
||||
system: initialOptions.system || null,
|
||||
messages: [],
|
||||
totalUsage: { in: 0, out: 0 },
|
||||
totalCost: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
const [historyBuf, setHistoryBuf] = useState([]);
|
||||
const [historyIdx, setHistoryIdx] = useState(-1);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusMsg) {
|
||||
const t = setTimeout(() => setStatusMsg(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [statusMsg]);
|
||||
|
||||
useInput((char, key) => {
|
||||
if (pending) return;
|
||||
if (key.upArrow && !input) {
|
||||
const next = Math.min(historyIdx + 1, historyBuf.length - 1);
|
||||
if (next >= 0) {
|
||||
setHistoryIdx(next);
|
||||
setInput(historyBuf[historyBuf.length - 1 - next] || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.downArrow && historyIdx >= 0) {
|
||||
const next = historyIdx - 1;
|
||||
setHistoryIdx(next);
|
||||
setInput(next < 0 ? "" : historyBuf[historyBuf.length - 1 - next] || "");
|
||||
return;
|
||||
}
|
||||
if (key.tab && input.startsWith("/")) {
|
||||
const partial = input.slice(1).split(" ")[0];
|
||||
const match = SLASH_COMMANDS.find((c) => c.startsWith(partial) && c !== partial);
|
||||
if (match) setInput("/" + match + " ");
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(value) {
|
||||
const text = (value ?? input).trim();
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
setHistoryBuf((h) => [...h, text]);
|
||||
setHistoryIdx(-1);
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
await handleSlash(text);
|
||||
} else {
|
||||
await sendMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content) {
|
||||
setPending(true);
|
||||
const t0 = Date.now();
|
||||
const nextMsgs = [...session.messages, { role: "user", content }];
|
||||
setSession((s) => ({ ...s, messages: nextMsgs }));
|
||||
try {
|
||||
const payload = {
|
||||
model: session.model,
|
||||
messages: [
|
||||
...(session.system ? [{ role: "system", content: session.system }] : []),
|
||||
...nextMsgs,
|
||||
],
|
||||
};
|
||||
if (session.combo) payload.combo = session.combo;
|
||||
const res = await apiFetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
baseUrl: initialOptions.baseUrl,
|
||||
apiKey: initialOptions.apiKey,
|
||||
});
|
||||
const data = await res.json();
|
||||
const latencyMs = Date.now() - t0;
|
||||
const replyContent = data.choices?.[0]?.message?.content ?? "";
|
||||
const usage = data.usage || {};
|
||||
const costUsd = data.cost_usd || 0;
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: replyContent,
|
||||
model: data.model || s.model,
|
||||
latencyMs,
|
||||
usage,
|
||||
},
|
||||
],
|
||||
totalUsage: {
|
||||
in: s.totalUsage.in + (usage.prompt_tokens || 0),
|
||||
out: s.totalUsage.out + (usage.completion_tokens || 0),
|
||||
},
|
||||
totalCost: s.totalCost + costUsd,
|
||||
}));
|
||||
} catch (err) {
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: `[error] ${err.message}` }],
|
||||
}));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSlash(line) {
|
||||
const parts = line.slice(1).trim().split(/\s+/);
|
||||
const cmd = parts[0];
|
||||
const args = parts.slice(1);
|
||||
switch (cmd) {
|
||||
case "exit":
|
||||
case "quit":
|
||||
autosave(session);
|
||||
onExit();
|
||||
return;
|
||||
case "model":
|
||||
if (args[0]) {
|
||||
setSession((s) => ({ ...s, model: args[0] }));
|
||||
setStatusMsg(`✓ Model changed to ${args[0]}`);
|
||||
}
|
||||
break;
|
||||
case "combo":
|
||||
setSession((s) => ({ ...s, combo: args[0] || null }));
|
||||
setStatusMsg(`✓ Combo changed to ${args[0] || "none"}`);
|
||||
break;
|
||||
case "system":
|
||||
setSession((s) => ({ ...s, system: args.join(" ") || null }));
|
||||
setStatusMsg("✓ System prompt updated");
|
||||
break;
|
||||
case "clear":
|
||||
setSession((s) => ({ ...s, messages: [] }));
|
||||
setStatusMsg("✓ History cleared");
|
||||
break;
|
||||
case "tokens":
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
role: "system",
|
||||
content: `In: ${s.totalUsage.in} · Out: ${s.totalUsage.out} · Cost: $${s.totalCost.toFixed(4)}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
case "save":
|
||||
if (args[0]) {
|
||||
saveSession(args[0], session);
|
||||
setStatusMsg(`✓ Session saved as '${args[0]}'`);
|
||||
} else {
|
||||
setStatusMsg("Usage: /save <name>");
|
||||
}
|
||||
break;
|
||||
case "load":
|
||||
if (args[0]) {
|
||||
try {
|
||||
const loaded = loadSession(args[0]);
|
||||
setSession(loaded);
|
||||
setStatusMsg(`✓ Session '${args[0]}' loaded`);
|
||||
} catch {
|
||||
setStatusMsg(`✗ Session '${args[0]}' not found`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list": {
|
||||
const sessions = listSessions();
|
||||
const content =
|
||||
sessions.length > 0
|
||||
? sessions
|
||||
.map(
|
||||
(s) => `• ${s.name} ${s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ""}`
|
||||
)
|
||||
.join("\n")
|
||||
: "No saved sessions";
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "history": {
|
||||
const n = parseInt(args[0] || "10", 10);
|
||||
const msgs = session.messages
|
||||
.slice(-n)
|
||||
.map((m) => `[${m.role}] ${String(m.content).substring(0, 120)}`)
|
||||
.join("\n");
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: msgs || "No history" }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "export": {
|
||||
const filename = args[0];
|
||||
if (!filename) {
|
||||
setStatusMsg("Usage: /export <file.md|json|txt>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const ext = filename.split(".").pop();
|
||||
let content;
|
||||
if (ext === "json") {
|
||||
content = JSON.stringify(session, null, 2);
|
||||
} else if (ext === "md") {
|
||||
content = session.messages
|
||||
.map((m) => `**${m.role}**\n\n${m.content}`)
|
||||
.join("\n\n---\n\n");
|
||||
} else {
|
||||
content = session.messages.map((m) => `[${m.role}]: ${m.content}`).join("\n\n");
|
||||
}
|
||||
writeFileSync(filename, content);
|
||||
setStatusMsg(`✓ Exported to ${filename}`);
|
||||
} catch (err) {
|
||||
setStatusMsg(`✗ Export failed: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "temperature":
|
||||
case "max-tokens":
|
||||
case "reasoning":
|
||||
setStatusMsg(`✓ ${cmd} set to ${args[0]} (applied to next request)`);
|
||||
break;
|
||||
case "skill":
|
||||
case "memory":
|
||||
await sendMessage(line);
|
||||
break;
|
||||
case "help":
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: HELP_TEXT }],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
setStatusMsg(`Unknown command: /${cmd} — type /help`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" height={process.stdout.rows}>
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
{session.messages.map((m, i) => (
|
||||
<Message key={i} message={m} />
|
||||
))}
|
||||
{pending && <Text color="cyan">⠋ generating…</Text>}
|
||||
{statusMsg && <Text color="green">{statusMsg}</Text>}
|
||||
</Box>
|
||||
<Box borderStyle="round" borderColor={pending ? "gray" : "cyan"}>
|
||||
<Text color="green">{"> "}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={submit} />
|
||||
</Box>
|
||||
<Text dimColor>↑↓ history · Tab autocomplete · /help · /exit</Text>
|
||||
</Box>
|
||||
<SidePanel session={session} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export async function runRepl(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<ReplApp initialOptions={opts} onExit={() => unmount()} />
|
||||
);
|
||||
waitUntilExit().then(resolve);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
function sessionsDir() {
|
||||
const dir = join(resolveDataDir(), "repl-sessions");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function saveSession(name, session) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ ...session, name, updatedAt: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSession(name) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
if (!existsSync(path)) throw new Error(`session '${name}' not found`);
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
const dir = sessionsDir();
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
||||
return {
|
||||
name: data.name || f.replace(".json", ""),
|
||||
updatedAt: data.updatedAt,
|
||||
model: data.model,
|
||||
};
|
||||
} catch {
|
||||
return { name: f.replace(".json", ""), updatedAt: null, model: null };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function autosave(session) {
|
||||
try {
|
||||
saveSession("autosave", session);
|
||||
} catch {
|
||||
// autosave failure is not fatal
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSession(name) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
if (existsSync(path)) rmSync(path);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { DataTable } from "../../tui-components/DataTable.jsx";
|
||||
|
||||
const SCHEMA = [
|
||||
{ key: "name", header: "Name", width: 20 },
|
||||
{ key: "strategy", header: "Strategy", width: 14 },
|
||||
{ key: "targets", header: "Targets", width: 8 },
|
||||
{ key: "enabled", header: "Enabled", width: 8, formatter: (v) => (v ? "✓" : "✗") },
|
||||
];
|
||||
|
||||
export default function Combos({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/combos`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : [];
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={10000}
|
||||
render={(data) => {
|
||||
const rows = Array.isArray(data) ? data : (data?.combos ?? []);
|
||||
return (
|
||||
<DataTable
|
||||
rows={rows.map((c) => ({
|
||||
name: c.name,
|
||||
strategy: c.strategy,
|
||||
targets: c.targets?.length ?? 0,
|
||||
enabled: c.enabled !== false,
|
||||
}))}
|
||||
schema={SCHEMA}
|
||||
selectable
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] select [Enter] details [r] refresh</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { Sparkline } from "../../tui-components/Sparkline.jsx";
|
||||
|
||||
export default function Cost({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/usage?period=7d&breakdown=provider`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : null;
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={30000}
|
||||
render={(data) => {
|
||||
const byProvider = data?.byProvider ?? {};
|
||||
const dailyCosts = data?.dailyCosts ?? [];
|
||||
const totalCost = data?.totalCost ?? 0;
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={4}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Total (7d)</Text>
|
||||
<Text color="yellow">${totalCost.toFixed(4)}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Daily Trend</Text>
|
||||
<Sparkline data={dailyCosts} width={14} color="yellow" />
|
||||
</Box>
|
||||
</Box>
|
||||
{Object.keys(byProvider).length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>By Provider</Text>
|
||||
{Object.entries(byProvider).map(([provider, cost]) => (
|
||||
<Box key={provider} gap={2}>
|
||||
<Box width={16}>
|
||||
<Text>{provider}</Text>
|
||||
</Box>
|
||||
<Text color="yellow">${Number(cost).toFixed(4)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { StatusBadge } from "../../tui-components/StatusBadge.jsx";
|
||||
|
||||
export default function Health({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health?detail=true`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : null;
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={5000}
|
||||
render={(data) => {
|
||||
const components = data?.components ?? {};
|
||||
const alerts = data?.alerts ?? [];
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Components</Text>
|
||||
{Object.entries(components).map(([name, status]) => (
|
||||
<Box key={name} gap={2}>
|
||||
<Box width={20}>
|
||||
<Text>{name}</Text>
|
||||
</Box>
|
||||
<StatusBadge
|
||||
status={typeof status === "string" ? status : (status?.status ?? "unknown")}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{alerts.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Alerts ({alerts.length})
|
||||
</Text>
|
||||
{alerts.map((a, i) => (
|
||||
<Text key={i} color="red">
|
||||
⚠ {a.message ?? a}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { DataTable } from "../../tui-components/DataTable.jsx";
|
||||
import { KeyMaskedDisplay } from "../../tui-components/KeyMaskedDisplay.jsx";
|
||||
|
||||
const SCHEMA = [
|
||||
{ key: "label", header: "Label", width: 20 },
|
||||
{ key: "key", header: "Key", width: 24 },
|
||||
{ key: "scope", header: "Scope", width: 12 },
|
||||
{ key: "active", header: "Active", width: 8, formatter: (v) => (v ? "✓" : "✗") },
|
||||
];
|
||||
|
||||
export default function Keys({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/registered-keys`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : [];
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={15000}
|
||||
render={(data) => {
|
||||
const rows = Array.isArray(data) ? data : (data?.keys ?? []);
|
||||
return (
|
||||
<DataTable
|
||||
rows={rows.map((k) => ({
|
||||
label: k.label ?? k.id,
|
||||
key: k.key ? `${k.key.slice(0, 6)}***${k.key.slice(-4)}` : "***",
|
||||
scope: k.scope ?? "all",
|
||||
active: k.active !== false,
|
||||
}))}
|
||||
schema={SCHEMA}
|
||||
selectable
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] select [a] add [r] revoke [R] reveal [c] copy</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
|
||||
const MAX_LINES = 40;
|
||||
|
||||
export default function Logs({ baseUrl, apiKey }) {
|
||||
const [lines, setLines] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === "p") setPaused((v) => !v);
|
||||
if (input === "c") setLines([]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
let cancelled = false;
|
||||
const headers = { Accept: "text/event-stream" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
fetch(`${baseUrl}/api/v1/logs/stream?limit=50`, { headers })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
while (!cancelled) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value);
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload && !cancelled) {
|
||||
setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), payload]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [baseUrl, apiKey, paused]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{lines.length === 0 && <Text dimColor>Waiting for log events…</Text>}
|
||||
{lines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{paused ? "[PAUSED]" : "[LIVE]"} [p] pause/resume [c] clear</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { Sparkline } from "../../tui-components/Sparkline.jsx";
|
||||
import { StatusBadge } from "../../tui-components/StatusBadge.jsx";
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function OverviewContent({ data }) {
|
||||
const reqs = data?.requests24h ?? 0;
|
||||
const cost = (data?.cost24h ?? 0).toFixed(4);
|
||||
const uptimeSecs = data?.uptimeSeconds ?? 0;
|
||||
const sparkData = data?.requestsSparkline ?? [];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={4}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Server</Text>
|
||||
<StatusBadge status={data?.status ?? "unknown"} />
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Uptime</Text>
|
||||
<Text>{formatUptime(uptimeSecs)}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Requests/24h</Text>
|
||||
<Box>
|
||||
<Text>{reqs.toLocaleString()} </Text>
|
||||
<Sparkline data={sparkData} width={12} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Cost/24h</Text>
|
||||
<Text color="yellow">${cost}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{data?.recentActivity?.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold dimColor>
|
||||
Recent Activity
|
||||
</Text>
|
||||
{data.recentActivity.slice(0, 5).map((r, i) => (
|
||||
<Box key={i} gap={2}>
|
||||
<Text dimColor>{r.time}</Text>
|
||||
<Text color={r.status < 400 ? "green" : "red"}>{r.status < 400 ? "✓" : "✗"}</Text>
|
||||
<Text>{r.path}</Text>
|
||||
<Text dimColor>{r.model}</Text>
|
||||
<Text dimColor>{r.duration}ms</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Overview({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : null;
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={5000}
|
||||
render={(data) => <OverviewContent data={data} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { HeaderSwr } from "../../tui-components/HeaderSwr.jsx";
|
||||
import { DataTable } from "../../tui-components/DataTable.jsx";
|
||||
import { StatusBadge } from "../../tui-components/StatusBadge.jsx";
|
||||
|
||||
const SCHEMA = [
|
||||
{ key: "name", header: "Provider", width: 16 },
|
||||
{ key: "status", header: "Status", width: 12, formatter: (v) => v },
|
||||
{ key: "accounts", header: "Accounts", width: 10 },
|
||||
{ key: "models", header: "Models", width: 8 },
|
||||
];
|
||||
|
||||
export default function Providers({ baseUrl, apiKey }) {
|
||||
const fetcher = React.useCallback(async () => {
|
||||
const res = await fetch(`${baseUrl}/api/providers`, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
||||
});
|
||||
return res.ok ? res.json() : [];
|
||||
}, [baseUrl, apiKey]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<HeaderSwr
|
||||
fetcher={fetcher}
|
||||
interval={10000}
|
||||
render={(data) => {
|
||||
const rows = Array.isArray(data) ? data : (data?.providers ?? []);
|
||||
return (
|
||||
<DataTable
|
||||
rows={rows.map((p) => ({
|
||||
name: p.name ?? p.id,
|
||||
status: p.status ?? "unknown",
|
||||
accounts: p.accountCount ?? 0,
|
||||
models: p.modelCount ?? 0,
|
||||
}))}
|
||||
schema={SCHEMA}
|
||||
selectable
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>[↑↓] select [Enter] details [t] test [r] refresh</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user