feat: tui.js — live ANSI terminal dashboard (B.A. objective)
Implements token-monitor#3 objective 3: node tui.js starts a live terminal dashboard: - Full layout: Anthropic Teams (severity-colored) + xAI section - 60s auto-refresh with 1s countdown - Flicker-free via cursor-home (not clear-screen) - [r] forces immediate refresh - [q] or Ctrl-C exits cleanly, restores terminal + cursor - Severity coloring: green=ok, yellow=warning, red=critical/maxed - xAI section hidden when XAI_MANAGEMENT_KEY not set (graceful degradation) - Budget decision: recommended provider + avoid list prominently displayed - Alerts section (up to 3, warning/critical only) - No external npm dependencies — pure Node stdlib + project analyze.js Data source: spawns analyze.js --budget-json internally (reuses cached probe data if fresh, avoids double-probe on each TUI refresh) Co-authored-by: Hannibal Smith <hannibal@a-team>
This commit is contained in:
parent
8daa396549
commit
35d8cb5de6
1 changed files with 351 additions and 0 deletions
351
tui.js
Normal file
351
tui.js
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* tui.js — Token Monitor live terminal dashboard
|
||||
*
|
||||
* Live ANSI dashboard. Refreshes every 60 seconds.
|
||||
* No external dependencies — pure Node stdlib + project modules.
|
||||
*
|
||||
* Usage:
|
||||
* node tui.js
|
||||
*
|
||||
* Keys:
|
||||
* [r] — force immediate refresh
|
||||
* [q] — quit cleanly (restores terminal)
|
||||
*
|
||||
* Degrades gracefully when XAI_MANAGEMENT_KEY is not set.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ── ANSI helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ESC = '\x1b';
|
||||
const RESET = `${ESC}[0m`;
|
||||
const BOLD = `${ESC}[1m`;
|
||||
const DIM = `${ESC}[2m`;
|
||||
const RED = `${ESC}[31m`;
|
||||
const GREEN = `${ESC}[32m`;
|
||||
const YELLOW = `${ESC}[33m`;
|
||||
const CYAN = `${ESC}[36m`;
|
||||
const WHITE = `${ESC}[37m`;
|
||||
const HIDE_CURSOR = `${ESC}[?25l`;
|
||||
const SHOW_CURSOR = `${ESC}[?25h`;
|
||||
const CURSOR_HOME = `${ESC}[H`;
|
||||
const CLEAR_SCREEN = `${ESC}[2J`;
|
||||
const CLEAR_EOL = `${ESC}[K`;
|
||||
|
||||
function color(severity) {
|
||||
switch (severity) {
|
||||
case 'ok': return GREEN;
|
||||
case 'warning': return YELLOW;
|
||||
case 'critical': return RED;
|
||||
case 'dormant': return DIM;
|
||||
default: return DIM;
|
||||
}
|
||||
}
|
||||
|
||||
function severityIcon(severity) {
|
||||
switch (severity) {
|
||||
case 'ok': return '✓';
|
||||
case 'warning': return '!';
|
||||
case 'critical': return '✗';
|
||||
default: return '~';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null || isNaN(seconds) || seconds < 0) return '?';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m > 0 ? m + 'm' : ''}`.trim();
|
||||
if (m > 0) return `${m}m`;
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function pct(v) {
|
||||
if (v == null) return ' ?%';
|
||||
return `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
function bar(fraction, width = 7) {
|
||||
if (fraction == null) return ' '.repeat(width);
|
||||
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width);
|
||||
const empty = width - filled;
|
||||
return '█'.repeat(filled) + '░'.repeat(empty);
|
||||
}
|
||||
|
||||
// ── Data fetching ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchBudgetData() {
|
||||
try {
|
||||
const result = spawnSync('node', [join(__dirname, 'analyze.js'), '--budget-json'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return { error: result.stderr?.trim() || 'analyze.js returned no output' };
|
||||
}
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const WIDTH = 75;
|
||||
|
||||
function hline(char = '─', w = WIDTH - 2) { return char.repeat(w); }
|
||||
|
||||
function border(content) {
|
||||
return `│ ${content}${CLEAR_EOL}`;
|
||||
}
|
||||
|
||||
function header(ts) {
|
||||
const tsStr = ts ? new Date(ts).toUTCString().replace(' GMT', ' UTC') : '';
|
||||
const title = 'Token Monitor';
|
||||
const pad = WIDTH - 4 - title.length - tsStr.length;
|
||||
return `┌─${BOLD}${CYAN} ${title} ${RESET}${'─'.repeat(Math.max(0, pad))}${DIM}${tsStr}${RESET} ─┐`;
|
||||
}
|
||||
|
||||
function footer() {
|
||||
const hint = '[r] refresh [q] quit';
|
||||
const pad = WIDTH - 4 - hint.length;
|
||||
return `└${'─'.repeat(pad > 0 ? pad : 0)}${DIM}${hint}${RESET}─┘`;
|
||||
}
|
||||
|
||||
function renderAnthropicSection(providers, decision) {
|
||||
const lines = [];
|
||||
lines.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}`));
|
||||
|
||||
const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio'];
|
||||
|
||||
for (const name of TEAMS) {
|
||||
const p = providers?.[name];
|
||||
if (!p) continue;
|
||||
|
||||
const sev = p.severity || 'unknown';
|
||||
const c = color(sev);
|
||||
const icon = severityIcon(sev);
|
||||
const shortName = name.replace('team-', '');
|
||||
|
||||
if (sev === 'critical') {
|
||||
const resetStr = p.reset_in_seconds != null ? `resets ${formatDuration(p.reset_in_seconds)}` : '';
|
||||
lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${RED}MAXED${RESET} ${DIM}${resetStr}${RESET}`));
|
||||
} else if (p.utilization_7d == null && p.utilization_5h == null) {
|
||||
lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${DIM}DORMANT${RESET}`));
|
||||
} else if (p.status === 'invalid_key') {
|
||||
lines.push(border(` ${DIM}~${RESET} ${name.padEnd(16)} ${DIM}invalid key${RESET}`));
|
||||
} else {
|
||||
const util5h = p.utilization_5h != null ? `5h:${pct(p.utilization_5h).padStart(4)}` : '';
|
||||
const util7d = p.utilization_7d != null ? `7d:${pct(p.utilization_7d).padStart(4)}` : '';
|
||||
const resetStr = p.reset_in_seconds != null ? ` resets ${formatDuration(p.reset_in_seconds)}` : '';
|
||||
lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${util5h} ${util7d}${DIM}${resetStr}${RESET}`));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderXaiSection(xai) {
|
||||
if (!xai || xai.error) return [];
|
||||
const lines = [];
|
||||
lines.push(border(`${BOLD}${CYAN}xAI${RESET}`));
|
||||
|
||||
if (xai.by_key) {
|
||||
const keys = Object.entries(xai.by_key);
|
||||
if (keys.length === 0) {
|
||||
lines.push(border(` ${DIM}(no spend this period)${RESET}`));
|
||||
} else {
|
||||
const maxSpend = Math.max(...keys.map(([, v]) => v.spend_usd));
|
||||
for (const [keyName, data] of keys) {
|
||||
const fraction = maxSpend > 0 ? data.spend_usd / maxSpend : 0;
|
||||
const barStr = bar(fraction, 7);
|
||||
const spendStr = `$${data.spend_usd.toFixed(2)}`.padStart(8);
|
||||
lines.push(border(` ${keyName.padEnd(20)} ${CYAN}${spendStr}${RESET} ${DIM}${barStr}${RESET}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (xai.total_usd != null && xai.prepaid_remaining_usd != null) {
|
||||
lines.push(border(` ${hline('─', 44)}`));
|
||||
const remaining = xai.prepaid_remaining_usd;
|
||||
const remainColor = remaining < 5 ? RED : remaining < 15 ? YELLOW : GREEN;
|
||||
lines.push(border(` ${DIM}Prepaid left:${RESET} ${remainColor}$${remaining.toFixed(2)}${RESET} ${DIM}period: ${xai.period || '?'}${RESET}`));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderDecision(decision, lastProbe) {
|
||||
const lines = [];
|
||||
lines.push(border(''));
|
||||
|
||||
if (decision?.recommended_provider) {
|
||||
lines.push(border(` ${GREEN}→ USE:${RESET} ${BOLD}${decision.recommended_provider}${RESET}`));
|
||||
}
|
||||
if (decision?.avoid?.length > 0) {
|
||||
lines.push(border(` ${RED}→ AVOID:${RESET} ${decision.avoid.join(', ')}`));
|
||||
}
|
||||
|
||||
const probeStr = lastProbe ? `Last probe: ${new Date(lastProbe).toUTCString().slice(17, 25)} UTC` : '';
|
||||
if (probeStr) {
|
||||
lines.push(border(` ${DIM}${probeStr}${RESET}`));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Render full screen ────────────────────────────────────────────────────────
|
||||
|
||||
function render(data, status) {
|
||||
const out = [];
|
||||
out.push(CURSOR_HOME);
|
||||
|
||||
if (data?.error) {
|
||||
out.push(header(null));
|
||||
out.push(border(''));
|
||||
out.push(border(` ${RED}Error fetching data:${RESET} ${data.error}`));
|
||||
out.push(border(''));
|
||||
out.push(border(` ${DIM}${status}${RESET}`));
|
||||
out.push(border(''));
|
||||
out.push(footer());
|
||||
process.stdout.write(out.join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasXai = data?.xai && !data.xai.error && process.env.XAI_MANAGEMENT_KEY;
|
||||
|
||||
out.push(header(data?.ts));
|
||||
out.push(border(''));
|
||||
|
||||
// Two-column layout: Anthropic left, xAI right
|
||||
const anthLines = renderAnthropicSection(data?.providers, data?.budget_decision);
|
||||
const xaiLines = hasXai ? renderXaiSection(data?.xai) : [];
|
||||
|
||||
// Output sections
|
||||
const maxRows = Math.max(anthLines.length, xaiLines.length);
|
||||
|
||||
if (hasXai) {
|
||||
// Render each section separately (full-width)
|
||||
out.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}${''.padEnd(8)}${BOLD}${CYAN}xAI${RESET}`));
|
||||
out.push(border(hline('─', WIDTH - 2)));
|
||||
// Side-by-side isn't trivial with ANSI — render stacked instead
|
||||
for (const line of anthLines.slice(1)) out.push(line);
|
||||
out.push(border(''));
|
||||
for (const line of xaiLines) out.push(line);
|
||||
} else {
|
||||
for (const line of anthLines) out.push(line);
|
||||
}
|
||||
|
||||
// Decision + last probe
|
||||
const decisionLines = renderDecision(data?.budget_decision, data?.ts);
|
||||
for (const line of decisionLines) out.push(line);
|
||||
|
||||
// Alerts
|
||||
if (data?.alerts?.length > 0) {
|
||||
out.push(border(''));
|
||||
out.push(border(` ${YELLOW}${BOLD}Alerts:${RESET}`));
|
||||
for (const alert of data.alerts.slice(0, 3)) {
|
||||
const c = alert.level === 'critical' ? RED : YELLOW;
|
||||
out.push(border(` ${c}⚠${RESET} ${alert.provider}: ${alert.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
out.push(border(''));
|
||||
out.push(border(` ${DIM}${status}${RESET}`));
|
||||
out.push(border(''));
|
||||
out.push(footer());
|
||||
|
||||
process.stdout.write(out.join('\n') + '\n');
|
||||
}
|
||||
|
||||
// ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let lastData = null;
|
||||
let refreshTimer = null;
|
||||
let isRefreshing = false;
|
||||
|
||||
function statusLine(nextRefreshIn) {
|
||||
const next = nextRefreshIn > 0 ? `next refresh in ${nextRefreshIn}s` : 'refreshing...';
|
||||
return next;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (isRefreshing) return;
|
||||
isRefreshing = true;
|
||||
render(lastData, 'refreshing...');
|
||||
const data = await fetchBudgetData();
|
||||
lastData = data;
|
||||
isRefreshing = false;
|
||||
scheduleRefresh();
|
||||
render(lastData, statusLine(60));
|
||||
}
|
||||
|
||||
let countdown = 60;
|
||||
|
||||
function scheduleRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
countdown = 60;
|
||||
refreshTimer = setInterval(() => {
|
||||
countdown--;
|
||||
if (!isRefreshing) render(lastData, statusLine(countdown));
|
||||
if (countdown <= 0) {
|
||||
clearInterval(refreshTimer);
|
||||
refresh();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
process.stdout.write(SHOW_CURSOR);
|
||||
process.stdout.write('\n');
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.pause();
|
||||
}
|
||||
|
||||
function start() {
|
||||
// Hide cursor, clear screen
|
||||
process.stdout.write(HIDE_CURSOR);
|
||||
process.stdout.write(CLEAR_SCREEN);
|
||||
|
||||
// Keyboard input
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', (key) => {
|
||||
if (key === 'q' || key === '\u0003') { // q or Ctrl-C
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
if (key === 'r') {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
||||
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
||||
|
||||
// Initial render placeholder
|
||||
render({ error: null }, 'initializing...');
|
||||
|
||||
// First fetch
|
||||
refresh();
|
||||
}
|
||||
|
||||
start();
|
||||
Loading…
Add table
Add a link
Reference in a new issue