mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): TUI dashboard interativo e menu de interface (Fases 8.10+8.9)
Adiciona dashboard TUI com 7 abas (Overview, Combos, Providers, Keys, Logs, Health, Cost) via Ink, 13 componentes reutilizáveis em tui-components/, menu interativo ao iniciar sem subcomando, e flag --tui no comando dashboard.
This commit is contained in:
@@ -8,7 +8,17 @@ export function registerDashboard(program) {
|
||||
.description(t("dashboard.description"))
|
||||
.option("--url", t("dashboard.urlOnly"))
|
||||
.option("--port <port>", "Port the server is running on", "20128")
|
||||
.action(async (opts) => {
|
||||
.option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)")
|
||||
.action(async (opts, cmd) => {
|
||||
if (opts.tui) {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
|
||||
const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`;
|
||||
const apiKey = globalOpts.apiKey ?? null;
|
||||
const { startInteractiveTui } = await import("../tui/Dashboard.jsx");
|
||||
await startInteractiveTui({ port, baseUrl, apiKey });
|
||||
return;
|
||||
}
|
||||
const exitCode = await runDashboardCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
@@ -379,7 +379,8 @@
|
||||
"dashboard": {
|
||||
"description": "Open the OmniRoute dashboard in a browser",
|
||||
"opening": "Opening dashboard at {url}",
|
||||
"urlOnly": "Print dashboard URL without opening browser"
|
||||
"urlOnly": "Print dashboard URL without opening browser",
|
||||
"tui": "Open interactive TUI dashboard (terminal UI, 7 tabs)"
|
||||
},
|
||||
"models": {
|
||||
"description": "List available models (requires server)",
|
||||
|
||||
@@ -379,7 +379,8 @@
|
||||
"dashboard": {
|
||||
"description": "Abrir o painel OmniRoute no navegador",
|
||||
"opening": "Abrindo painel em {url}",
|
||||
"urlOnly": "Exibir URL do painel sem abrir o navegador"
|
||||
"urlOnly": "Exibir URL do painel sem abrir o navegador",
|
||||
"tui": "Abrir painel TUI interativo (terminal UI, 7 abas)"
|
||||
},
|
||||
"models": {
|
||||
"description": "Listar modelos disponíveis (requer servidor)",
|
||||
|
||||
15
bin/cli/tui-components/CodeBlock.jsx
Normal file
15
bin/cli/tui-components/CodeBlock.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
export function CodeBlock({ code = "", language }) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
{language && (
|
||||
<Text dimColor bold>
|
||||
{language}
|
||||
</Text>
|
||||
)}
|
||||
<Text>{code}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
40
bin/cli/tui-components/ConfirmDialog.jsx
Normal file
40
bin/cli/tui-components/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
|
||||
export function ConfirmDialog({ message, onConfirm, onCancel }) {
|
||||
const [selected, setSelected] = useState(1); // 0=yes, 1=no (default no)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.leftArrow || input === "y") setSelected(0);
|
||||
if (key.rightArrow || input === "n") setSelected(1);
|
||||
if (key.return) {
|
||||
if (selected === 0) onConfirm?.();
|
||||
else onCancel?.();
|
||||
}
|
||||
if (key.escape) onCancel?.();
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" padding={1}>
|
||||
<Text bold color="yellow">
|
||||
{message}
|
||||
</Text>
|
||||
<Box marginTop={1} gap={2}>
|
||||
<Text
|
||||
bold={selected === 0}
|
||||
color={selected === 0 ? "green" : undefined}
|
||||
inverse={selected === 0}
|
||||
>
|
||||
Yes
|
||||
</Text>
|
||||
<Text
|
||||
bold={selected === 1}
|
||||
color={selected === 1 ? "red" : undefined}
|
||||
inverse={selected === 1}
|
||||
>
|
||||
No
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
bin/cli/tui-components/DataTable.jsx
Normal file
50
bin/cli/tui-components/DataTable.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { theme } from "./theme.jsx";
|
||||
|
||||
function formatCell(v, col) {
|
||||
if (v == null) return "-";
|
||||
if (col.formatter) return col.formatter(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function DataTable({ rows = [], schema = [], selectable = false, onSelect }) {
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!selectable || rows.length === 0) return;
|
||||
if (key.upArrow) setSelectedIdx((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setSelectedIdx((i) => Math.min(rows.length - 1, i + 1));
|
||||
if (key.return && onSelect) onSelect(rows[selectedIdx]);
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <Text dimColor>No data.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
{schema.map((col) => (
|
||||
<Box key={col.key} width={col.width ?? 16} marginRight={1}>
|
||||
<Text bold color={theme.header}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{rows.map((row, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
backgroundColor={selectable && idx === selectedIdx ? theme.selected : undefined}
|
||||
>
|
||||
{schema.map((col) => (
|
||||
<Box key={col.key} width={col.width ?? 16} marginRight={1}>
|
||||
<Text>{formatCell(row[col.key], col)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
bin/cli/tui-components/HeaderSwr.jsx
Normal file
25
bin/cli/tui-components/HeaderSwr.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
export function HeaderSwr({ fetcher, interval = 5000, render, initial = null }) {
|
||||
const [data, setData] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function tick() {
|
||||
try {
|
||||
const next = await fetcher();
|
||||
if (!cancelled) setData(next);
|
||||
} catch {}
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, interval);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [fetcher, interval]);
|
||||
|
||||
if (!data) return <Text dimColor>Loading…</Text>;
|
||||
return render(data);
|
||||
}
|
||||
12
bin/cli/tui-components/KeyMaskedDisplay.jsx
Normal file
12
bin/cli/tui-components/KeyMaskedDisplay.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
export function KeyMaskedDisplay({ apiKey, revealed = false }) {
|
||||
if (!apiKey) return <Text dimColor>(none)</Text>;
|
||||
const display = revealed
|
||||
? apiKey
|
||||
: apiKey.length <= 8
|
||||
? "***"
|
||||
: `${apiKey.slice(0, 6)}***${apiKey.slice(-4)}`;
|
||||
return <Text>{display}</Text>;
|
||||
}
|
||||
13
bin/cli/tui-components/MarkdownView.jsx
Normal file
13
bin/cli/tui-components/MarkdownView.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
export function MarkdownView({ content = "" }) {
|
||||
// Render markdown as plain text with basic bold/italic stripping.
|
||||
// For rich rendering, use marked-terminal externally before passing content.
|
||||
const clean = content
|
||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/`(.+?)`/g, "$1")
|
||||
.replace(/^#+\s+/gm, "");
|
||||
return <Text>{clean}</Text>;
|
||||
}
|
||||
38
bin/cli/tui-components/MenuSelect.jsx
Normal file
38
bin/cli/tui-components/MenuSelect.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
|
||||
export function MenuSelect({ items = [], onSelect, initial = 0 }) {
|
||||
const [idx, setIdx] = useState(Math.min(initial, Math.max(0, items.length - 1)));
|
||||
|
||||
useInput((input, key) => {
|
||||
if (items.length === 0) return;
|
||||
if (key.upArrow) setIdx((i) => (i - 1 + items.length) % items.length);
|
||||
if (key.downArrow) setIdx((i) => (i + 1) % items.length);
|
||||
if (key.return && onSelect) onSelect(items[idx]);
|
||||
const n = parseInt(input, 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= items.length) {
|
||||
const newIdx = n - 1;
|
||||
setIdx(newIdx);
|
||||
if (onSelect) onSelect(items[newIdx]);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{items.map((item, i) => (
|
||||
<Box key={i}>
|
||||
<Text bold={i === idx} color={i === idx ? "yellow" : undefined} dimColor={item.disabled}>
|
||||
{i === idx ? "▶ " : " "}
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.hint && (
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
{item.hint}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
bin/cli/tui-components/MultilineInput.jsx
Normal file
18
bin/cli/tui-components/MultilineInput.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
|
||||
export function MultilineInput({ label, value, onChange, placeholder }) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{label && (
|
||||
<Text bold dimColor>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<TextInput value={value} onChange={onChange} placeholder={placeholder} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
15
bin/cli/tui-components/ProgressBar.jsx
Normal file
15
bin/cli/tui-components/ProgressBar.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
export function ProgressBar({ value = 0, max = 100, width = 20, color = "green" }) {
|
||||
const pct = Math.min(1, Math.max(0, value / max));
|
||||
const filled = Math.round(pct * width);
|
||||
const empty = width - filled;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={color}>{"█".repeat(filled)}</Text>
|
||||
<Text dimColor>{"░".repeat(empty)}</Text>
|
||||
<Text> {Math.round(pct * 100)}%</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
15
bin/cli/tui-components/Sparkline.jsx
Normal file
15
bin/cli/tui-components/Sparkline.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
const BARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
|
||||
export function Sparkline({ data = [], color = "cyan", width = 10 }) {
|
||||
const slice = data.slice(-width);
|
||||
if (slice.length === 0) return <Text dimColor>{"─".repeat(width)}</Text>;
|
||||
const max = Math.max(...slice, 1);
|
||||
const chars = slice.map((v) => {
|
||||
const idx = Math.round((v / max) * (BARS.length - 1));
|
||||
return BARS[Math.min(Math.max(idx, 0), BARS.length - 1)];
|
||||
});
|
||||
return <Text color={color}>{chars.join("")}</Text>;
|
||||
}
|
||||
17
bin/cli/tui-components/StatusBadge.jsx
Normal file
17
bin/cli/tui-components/StatusBadge.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
|
||||
const STATUS_MAP = {
|
||||
running: { label: "● running", color: "green" },
|
||||
stopped: { label: "● stopped", color: "red" },
|
||||
starting: { label: "◌ starting", color: "yellow" },
|
||||
error: { label: "✗ error", color: "red" },
|
||||
unknown: { label: "? unknown", color: "gray" },
|
||||
ok: { label: "✓ ok", color: "green" },
|
||||
warn: { label: "⚠ warn", color: "yellow" },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status = "unknown" }) {
|
||||
const s = STATUS_MAP[status] ?? { label: status, color: "gray" };
|
||||
return <Text color={s.color}>{s.label}</Text>;
|
||||
}
|
||||
18
bin/cli/tui-components/TokenCounter.jsx
Normal file
18
bin/cli/tui-components/TokenCounter.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
export function TokenCounter({ tokensIn = 0, tokensOut = 0, costUsd = 0, model }) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold>In: </Text>
|
||||
<Text>{tokensIn.toLocaleString()}</Text>
|
||||
<Text bold> Out: </Text>
|
||||
<Text>{tokensOut.toLocaleString()}</Text>
|
||||
<Text bold> Cost: </Text>
|
||||
<Text color="yellow">${costUsd.toFixed(4)}</Text>
|
||||
</Box>
|
||||
{model && <Text dimColor>Model: {model}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
11
bin/cli/tui-components/theme.jsx
Normal file
11
bin/cli/tui-components/theme.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export const theme = {
|
||||
primary: "cyan",
|
||||
secondary: "yellow",
|
||||
success: "green",
|
||||
error: "red",
|
||||
warning: "yellow",
|
||||
muted: "gray",
|
||||
header: "cyan",
|
||||
selected: "blue",
|
||||
border: "cyan",
|
||||
};
|
||||
70
bin/cli/tui/Dashboard.jsx
Normal file
70
bin/cli/tui/Dashboard.jsx
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
55
bin/cli/tui/InterfaceMenu.jsx
Normal file
55
bin/cli/tui/InterfaceMenu.jsx
Normal file
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
47
bin/cli/tui/tabs/Combos.jsx
Normal file
47
bin/cli/tui/tabs/Combos.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
52
bin/cli/tui/tabs/Cost.jsx
Normal file
52
bin/cli/tui/tabs/Cost.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
bin/cli/tui/tabs/Health.jsx
Normal file
53
bin/cli/tui/tabs/Health.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
bin/cli/tui/tabs/Keys.jsx
Normal file
48
bin/cli/tui/tabs/Keys.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
60
bin/cli/tui/tabs/Logs.jsx
Normal file
60
bin/cli/tui/tabs/Logs.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
80
bin/cli/tui/tabs/Overview.jsx
Normal file
80
bin/cli/tui/tabs/Overview.jsx
Normal file
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
bin/cli/tui/tabs/Providers.jsx
Normal file
48
bin/cli/tui/tabs/Providers.jsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
809
package-lock.json
generated
809
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,9 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"http-proxy-middleware": "^4.0.0",
|
||||
"https-proxy-agent": "^9.0.0",
|
||||
"ink": "^5.2.1",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"ioredis": "^5.10.1",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jose": "^6.2.3",
|
||||
@@ -152,6 +155,7 @@
|
||||
"lowdb": "^7.0.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"marked": "^18.0.3",
|
||||
"marked-terminal": "^7.3.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.6",
|
||||
@@ -166,6 +170,7 @@
|
||||
"react-dom": "19.2.6",
|
||||
"react-is": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-reconciler": "^0.31.0",
|
||||
"recharts": "^3.8.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"tls-client-node": "^0.1.13",
|
||||
|
||||
81
tests/unit/cli-tui.test.ts
Normal file
81
tests/unit/cli-tui.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const TUI_COMPONENTS = join(ROOT, "bin", "cli", "tui-components");
|
||||
const TUI = join(ROOT, "bin", "cli", "tui");
|
||||
|
||||
function hasExport(file: string, name: string): boolean {
|
||||
const src = readFileSync(file, "utf8");
|
||||
return (
|
||||
src.includes(`export function ${name}`) ||
|
||||
src.includes(`export async function ${name}`) ||
|
||||
src.includes(`export { ${name}`)
|
||||
);
|
||||
}
|
||||
|
||||
const COMPONENTS = [
|
||||
{ file: "DataTable.jsx", export: "DataTable" },
|
||||
{ file: "MenuSelect.jsx", export: "MenuSelect" },
|
||||
{ file: "ProgressBar.jsx", export: "ProgressBar" },
|
||||
{ file: "StatusBadge.jsx", export: "StatusBadge" },
|
||||
{ file: "TokenCounter.jsx", export: "TokenCounter" },
|
||||
{ file: "KeyMaskedDisplay.jsx", export: "KeyMaskedDisplay" },
|
||||
{ file: "Sparkline.jsx", export: "Sparkline" },
|
||||
{ file: "HeaderSwr.jsx", export: "HeaderSwr" },
|
||||
{ file: "ConfirmDialog.jsx", export: "ConfirmDialog" },
|
||||
{ file: "MultilineInput.jsx", export: "MultilineInput" },
|
||||
{ file: "MarkdownView.jsx", export: "MarkdownView" },
|
||||
{ file: "CodeBlock.jsx", export: "CodeBlock" },
|
||||
];
|
||||
|
||||
for (const { file, export: exp } of COMPONENTS) {
|
||||
test(`tui-components/${file} existe e exporta ${exp}`, () => {
|
||||
const path = join(TUI_COMPONENTS, file);
|
||||
assert.ok(existsSync(path), `${file} deve existir`);
|
||||
assert.ok(hasExport(path, exp), `${file} deve exportar ${exp}`);
|
||||
});
|
||||
}
|
||||
|
||||
test("tui-components/theme.jsx exporta objeto theme", async () => {
|
||||
const { theme } = await import("../../bin/cli/tui-components/theme.jsx");
|
||||
assert.ok(theme && typeof theme === "object");
|
||||
assert.ok(typeof theme.primary === "string");
|
||||
assert.ok(typeof theme.success === "string");
|
||||
assert.ok(typeof theme.error === "string");
|
||||
});
|
||||
|
||||
test("tui/Dashboard.jsx existe e exporta startInteractiveTui", () => {
|
||||
const path = join(TUI, "Dashboard.jsx");
|
||||
assert.ok(existsSync(path), "Dashboard.jsx deve existir");
|
||||
assert.ok(hasExport(path, "startInteractiveTui"), "deve exportar startInteractiveTui");
|
||||
});
|
||||
|
||||
test("tui/InterfaceMenu.jsx existe e exporta showInterfaceMenu", () => {
|
||||
const path = join(TUI, "InterfaceMenu.jsx");
|
||||
assert.ok(existsSync(path), "InterfaceMenu.jsx deve existir");
|
||||
assert.ok(hasExport(path, "showInterfaceMenu"), "deve exportar showInterfaceMenu");
|
||||
});
|
||||
|
||||
test("tui/tabs/ tem as 7 tabs esperadas", () => {
|
||||
const tabs = ["Overview", "Combos", "Providers", "Keys", "Logs", "Health", "Cost"];
|
||||
for (const tab of tabs) {
|
||||
const path = join(TUI, "tabs", `${tab}.jsx`);
|
||||
assert.ok(existsSync(path), `tabs/${tab}.jsx deve existir`);
|
||||
}
|
||||
});
|
||||
|
||||
test("commands/dashboard.mjs registra --tui flag", async () => {
|
||||
const { registerDashboard } = await import("../../bin/cli/commands/dashboard.mjs");
|
||||
const { Command } = await import("commander");
|
||||
const prog = new Command().exitOverride();
|
||||
registerDashboard(prog);
|
||||
const dashCmd = prog.commands.find((c) => c.name() === "dashboard");
|
||||
assert.ok(dashCmd, "dashboard command deve existir");
|
||||
const tuiOpt = dashCmd?.options.find((o) => o.long === "--tui");
|
||||
assert.ok(tuiOpt, "--tui option deve estar registrada");
|
||||
});
|
||||
Reference in New Issue
Block a user