Merge pull request 'Budget Intelligence & TUI — programmatic spend data, live dashboard, mission cost tracking' (#5) from budget-intel into main
This commit is contained in:
commit
aefa35a5ca
5 changed files with 688 additions and 2 deletions
302
analyze.js
302
analyze.js
|
|
@ -9,6 +9,8 @@
|
|||
* - Rotation recommendations (rule-based)
|
||||
* - Underspend alerts
|
||||
* - Log hygiene (--prune)
|
||||
* - Budget JSON for agent consumption (--budget-json)
|
||||
* - Mission cost attribution (--mission, --mission-window)
|
||||
*
|
||||
* Usage:
|
||||
* node analyze.js # full report
|
||||
|
|
@ -20,6 +22,9 @@
|
|||
* node analyze.js --provider team-nadja # filter to one provider
|
||||
* node analyze.js --prune [--dry-run] # log hygiene
|
||||
* node analyze.js --xai # xAI spend section only
|
||||
* node analyze.js --budget-json # agent-consumable budget decision schema
|
||||
* node analyze.js --mission <ref> # mission cost (e.g. bookmarko#1 or commons#13)
|
||||
* node analyze.js --mission-window <iso-start> <iso-end> # mission cost, manual time range
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -27,7 +32,30 @@ import {
|
|||
} from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection } from './providers/xai-billing.js';
|
||||
import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection, aggregateByKey } from './providers/xai-billing.js';
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.config', 'token-monitor');
|
||||
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
||||
const MISSION_REPOS_FILE = join(CONFIG_DIR, 'mission-repos.json');
|
||||
const FORGEJO_URL = process.env.FORGEJO_URL || 'http://localhost:3001';
|
||||
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || null;
|
||||
|
||||
// ── Config loading ────────────────────────────────────────────────────────────
|
||||
|
||||
function loadConfig() {
|
||||
const defaults = { weekly_seat_usd: 7.50, seats_per_team: 1 };
|
||||
if (!existsSync(CONFIG_FILE)) return defaults;
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMissionRepos() {
|
||||
if (!existsSync(MISSION_REPOS_FILE)) return {};
|
||||
try { return JSON.parse(readFileSync(MISSION_REPOS_FILE, 'utf-8')); } catch { return {}; }
|
||||
}
|
||||
|
||||
const LOG_DIR = join(homedir(), '.logs', 'token-monitor');
|
||||
const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio'];
|
||||
|
|
@ -463,6 +491,227 @@ function generateFullReport(entries, xaiSection = null) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Budget JSON (Objective 1) ─────────────────────────────────────────────────
|
||||
|
||||
async function buildBudgetJson(entries, config) {
|
||||
const now = new Date().toISOString();
|
||||
const latest = latestPerProvider(entries, 'teams-direct');
|
||||
const ranked = rotationRank(entries);
|
||||
const weeklyUsd = config.weekly_seat_usd;
|
||||
|
||||
// budget_decision
|
||||
const usable = ranked.filter(r => r.severity !== 'critical' && r.severity !== 'unknown');
|
||||
const avoid = ranked.filter(r => r.severity === 'critical').map(r => r.provider);
|
||||
const recommended = usable.length > 0 ? usable[0].provider : null;
|
||||
const avoidReasons = ranked
|
||||
.filter(r => avoid.includes(r.provider))
|
||||
.map(r => r.provider + ': ' + r.reason)
|
||||
.join('; ');
|
||||
const recommendReason = recommended
|
||||
? `${recommended} at ${pct((latest[recommended]?.p?.utilization_7d) || 0)} 7d utilization`
|
||||
: 'no healthy provider found';
|
||||
const avoidReason = avoidReasons || 'none maxed';
|
||||
const reason = avoid.length > 0
|
||||
? `${avoidReason}; ${recommendReason}`
|
||||
: recommendReason;
|
||||
|
||||
// providers
|
||||
const providers = {};
|
||||
for (const name of TEAMS) {
|
||||
const rec = latest[name];
|
||||
if (!rec) continue;
|
||||
const p = rec.p;
|
||||
const util7d = p.utilization_7d ?? null;
|
||||
const headroom = util7d != null ? Math.round((1 - util7d) * 100) : null;
|
||||
providers[name] = {
|
||||
status: p.status || 'unknown',
|
||||
utilization_5h: p.utilization_5h ?? null,
|
||||
utilization_7d: util7d,
|
||||
headroom_7d_pct: headroom,
|
||||
estimated_weekly_budget_usd: weeklyUsd,
|
||||
estimated_spend_this_week_usd: util7d != null ? parseFloat((util7d * weeklyUsd).toFixed(2)) : null,
|
||||
reset_in_seconds: p.reset_in_seconds ?? null,
|
||||
severity: ranked.find(r => r.provider === name)?.severity || 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// alerts
|
||||
const alerts = [];
|
||||
for (const { provider, severity, reason: r } of ranked) {
|
||||
if (severity === 'critical') {
|
||||
const p = latest[provider]?.p;
|
||||
const resetStr = p?.reset_in_seconds != null ? ` — resets in ${formatDuration(p.reset_in_seconds)}` : '';
|
||||
alerts.push({ level: 'critical', provider, message: `maxed${resetStr}` });
|
||||
} else if (severity === 'warning') {
|
||||
alerts.push({ level: 'warning', provider, message: r });
|
||||
}
|
||||
}
|
||||
|
||||
// xAI section
|
||||
let xai = null;
|
||||
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
|
||||
try {
|
||||
const now2 = new Date();
|
||||
const startDate = new Date(Date.UTC(now2.getUTCFullYear(), now2.getUTCMonth(), 1));
|
||||
const [usageRows, preview] = await Promise.all([
|
||||
fetchXaiUsage({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID, startDate, endDate: now2 }),
|
||||
fetchXaiInvoicePreview({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID }),
|
||||
]);
|
||||
const byKey = aggregateByKey(usageRows);
|
||||
const period = `${now2.getUTCFullYear()}-${String(now2.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
const byKeyObj = {};
|
||||
const CACHED_UNIT = 'Cached prompt text tokens';
|
||||
const PROMPT_UNIT = 'Prompt text tokens';
|
||||
for (const [keyName, { total, byUnit }] of byKey) {
|
||||
byKeyObj[keyName] = {
|
||||
spend_usd: parseFloat(total.toFixed(2)),
|
||||
cached_usd: parseFloat((byUnit[CACHED_UNIT] || 0).toFixed(2)),
|
||||
prompt_usd: parseFloat((byUnit[PROMPT_UNIT] || 0).toFixed(2)),
|
||||
};
|
||||
}
|
||||
const totalUsd = [...byKey.values()].reduce((s, e) => s + e.total, 0);
|
||||
const prepaidRemaining = preview
|
||||
? parseFloat(((preview.prepaidCreditsCents - preview.prepaidUsedCents) / 100).toFixed(2))
|
||||
: null;
|
||||
xai = { period, by_key: byKeyObj, total_usd: parseFloat(totalUsd.toFixed(2)), prepaid_remaining_usd: prepaidRemaining };
|
||||
// xAI alert if prepaid low
|
||||
if (prepaidRemaining != null && prepaidRemaining < 5) {
|
||||
alerts.push({ level: 'warning', provider: 'xai', message: `prepaid balance low: $${prepaidRemaining.toFixed(2)} remaining` });
|
||||
}
|
||||
} catch (err) {
|
||||
xai = { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ts: now,
|
||||
budget_decision: { recommended_provider: recommended, avoid, reason },
|
||||
providers,
|
||||
...(xai ? { xai } : {}),
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mission cost tracking (Objective 2) ───────────────────────────────────────
|
||||
|
||||
async function resolveMissionWindow(missionRef) {
|
||||
if (!FORGEJO_TOKEN) throw new Error('FORGEJO_TOKEN not set — cannot resolve issue timestamps');
|
||||
const repos = loadMissionRepos();
|
||||
const m = missionRef.match(/^([^#]+)#(\d+)$/);
|
||||
if (!m) throw new Error(`Invalid mission ref: ${missionRef} (expected format: repo#num)`);
|
||||
const [, shortRepo, issueNum] = m;
|
||||
const repoFull = repos[shortRepo];
|
||||
if (!repoFull) throw new Error(`Unknown repo short name: ${shortRepo}. Add it to ${MISSION_REPOS_FILE}`);
|
||||
|
||||
const headers = { 'Authorization': `token ${FORGEJO_TOKEN}`, 'Content-Type': 'application/json' };
|
||||
const issueUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}`;
|
||||
const issueResp = await fetch(issueUrl, { headers });
|
||||
if (!issueResp.ok) throw new Error(`Forgejo issue fetch failed: ${issueResp.status}`);
|
||||
const issue = await issueResp.json();
|
||||
|
||||
const commentsUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}/comments?limit=50`;
|
||||
const commResp = await fetch(commentsUrl, { headers });
|
||||
const comments = commResp.ok ? await commResp.json() : [];
|
||||
|
||||
const startTs = issue.created_at;
|
||||
const timestamps = [issue.created_at, issue.updated_at, ...comments.map(c => c.updated_at)].filter(Boolean);
|
||||
const endTs = timestamps.sort().pop();
|
||||
|
||||
return {
|
||||
ref: missionRef,
|
||||
repo: repoFull,
|
||||
issue_num: issueNum,
|
||||
title: issue.title,
|
||||
start: startTs,
|
||||
end: endTs,
|
||||
};
|
||||
}
|
||||
|
||||
function entriesInWindow(allEntries, startIso, endIso) {
|
||||
const start = new Date(startIso).getTime();
|
||||
const end = new Date(endIso).getTime();
|
||||
return allEntries.filter(e => {
|
||||
const t = new Date(e.ts).getTime();
|
||||
return t >= start && t <= end;
|
||||
});
|
||||
}
|
||||
|
||||
async function computeMissionCost(startIso, endIso, missionLabel, config) {
|
||||
const allEntries = loadLogs();
|
||||
const window = entriesInWindow(allEntries, startIso, endIso);
|
||||
const weeklyUsd = config.weekly_seat_usd;
|
||||
|
||||
const startDt = new Date(startIso);
|
||||
const endDt = new Date(endIso);
|
||||
const durationMs = endDt - startDt;
|
||||
const durationH = Math.floor(durationMs / 3_600_000);
|
||||
const durationM = Math.floor((durationMs % 3_600_000) / 60_000);
|
||||
const durationStr = durationH > 0 ? `${durationH}h ${durationM}m` : `${durationM}m`;
|
||||
|
||||
const lines = [];
|
||||
if (missionLabel) lines.push(`Mission Cost: ${missionLabel}`);
|
||||
else lines.push('Mission Cost (time window)');
|
||||
|
||||
const startStr = startIso.slice(0, 16).replace('T', ' ');
|
||||
const endStr = endIso.slice(0, 16).replace('T', ' ');
|
||||
lines.push(` Window: ${startStr} → ${endStr} (${durationStr})`);
|
||||
lines.push(' ' + '─'.repeat(51));
|
||||
|
||||
let totalEstUsd = 0;
|
||||
let totalXaiUsd = 0;
|
||||
|
||||
// Anthropic Teams: utilization delta per provider
|
||||
for (const name of TEAMS) {
|
||||
const pts = window
|
||||
.filter(e => e.providers[name]?.utilization_7d != null)
|
||||
.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
if (pts.length < 2) continue;
|
||||
const firstUtil = pts[0].providers[name].utilization_7d;
|
||||
const lastUtil = pts[pts.length - 1].providers[name].utilization_7d;
|
||||
const delta = lastUtil - firstUtil;
|
||||
if (Math.abs(delta) < 0.001) continue;
|
||||
const estUsd = delta * weeklyUsd;
|
||||
totalEstUsd += Math.max(0, estUsd);
|
||||
const deltaStr = `${(delta * 100).toFixed(1)}%`;
|
||||
const usdStr = estUsd >= 0 ? `~$${estUsd.toFixed(2)} est.` : `~-$${Math.abs(estUsd).toFixed(2)} (decreased)`;
|
||||
lines.push(` ${name.padEnd(16)} 7d: ${pct(firstUtil)} → ${pct(lastUtil)} (${deltaStr.padStart(5)}) ${usdStr}`);
|
||||
}
|
||||
|
||||
if (totalEstUsd === 0 && window.length < 2) {
|
||||
lines.push(' (no utilization data in this time window)');
|
||||
}
|
||||
|
||||
// xAI: exact spend via management API
|
||||
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
|
||||
try {
|
||||
const usageRows = await fetchXaiUsage({
|
||||
managementKey: XAI_MANAGEMENT_KEY,
|
||||
teamId: XAI_TEAM_ID,
|
||||
startDate: new Date(startIso),
|
||||
endDate: new Date(endIso),
|
||||
});
|
||||
const byKey = aggregateByKey(usageRows);
|
||||
for (const [keyName, { total }] of byKey) {
|
||||
totalXaiUsd += total;
|
||||
lines.push(` ${keyName.padEnd(16)} $${total.toFixed(2)} exact (management API)`);
|
||||
}
|
||||
if (byKey.size === 0) {
|
||||
lines.push(' xAI $0.00 exact (management API)');
|
||||
}
|
||||
} catch (err) {
|
||||
lines.push(` xAI (error: ${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(' ' + '─'.repeat(51));
|
||||
const totalStr = XAI_MANAGEMENT_KEY
|
||||
? `~$${totalEstUsd.toFixed(2)} (Anthropic) + $${totalXaiUsd.toFixed(2)} (xAI) = ~$${(totalEstUsd + totalXaiUsd).toFixed(2)}`
|
||||
: `~$${totalEstUsd.toFixed(2)} estimated`;
|
||||
lines.push(` Total estimated: ${totalStr}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
|
@ -472,11 +721,18 @@ const showStagger = args.includes('--stagger');
|
|||
const showRotation = args.includes('--rotation');
|
||||
const showXai = args.includes('--xai');
|
||||
const isJson = args.includes('--json');
|
||||
const isBudgetJson = args.includes('--budget-json');
|
||||
const isPrune = args.includes('--prune');
|
||||
const isDryRun = args.includes('--dry-run');
|
||||
const providerIdx = args.indexOf('--provider');
|
||||
const providerFilter = providerIdx !== -1 ? args[providerIdx + 1] : null;
|
||||
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune;
|
||||
const missionIdx = args.indexOf('--mission');
|
||||
const missionRef = missionIdx !== -1 ? args[missionIdx + 1] : null;
|
||||
const missionWindowIdx = args.indexOf('--mission-window');
|
||||
const missionWindowStart = missionWindowIdx !== -1 ? args[missionWindowIdx + 1] : null;
|
||||
const missionWindowEnd = missionWindowIdx !== -1 ? args[missionWindowIdx + 2] : null;
|
||||
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune
|
||||
&& !isBudgetJson && !missionRef && !missionWindowStart;
|
||||
|
||||
if (isPrune) {
|
||||
pruneLogs(isDryRun);
|
||||
|
|
@ -485,10 +741,19 @@ if (isPrune) {
|
|||
|
||||
const entries = loadLogs(providerFilter);
|
||||
|
||||
// Early exit for mission-window (no xAI IIFE needed if pure time-window with no key)
|
||||
if (missionWindowStart && !missionRef) {
|
||||
if (!missionWindowEnd) {
|
||||
console.error('Usage: node analyze.js --mission-window <iso-start> <iso-end>');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── xAI billing helpers ────────────────────────────────────────────────────
|
||||
|
||||
const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null;
|
||||
const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null;
|
||||
const config = loadConfig();
|
||||
|
||||
async function getXaiSection() {
|
||||
if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null;
|
||||
|
|
@ -512,6 +777,39 @@ async function getXaiSection() {
|
|||
// All remaining paths may need xAI data (async), wrap in IIFE
|
||||
(async () => {
|
||||
|
||||
if (isBudgetJson) {
|
||||
const result = await buildBudgetJson(entries, config);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (missionWindowStart && missionWindowEnd && !missionRef) {
|
||||
const label = null;
|
||||
const out = await computeMissionCost(missionWindowStart, missionWindowEnd, label, config);
|
||||
console.log(out);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (missionRef) {
|
||||
try {
|
||||
const window = await resolveMissionWindow(missionRef);
|
||||
const label = `${window.title} (${missionRef})`;
|
||||
let startIso = window.start;
|
||||
let endIso = window.end;
|
||||
// --start / --end overrides
|
||||
const startOverrideIdx = args.indexOf('--start');
|
||||
const endOverrideIdx = args.indexOf('--end');
|
||||
if (startOverrideIdx !== -1) startIso = args[startOverrideIdx + 1];
|
||||
if (endOverrideIdx !== -1) endIso = args[endOverrideIdx + 1];
|
||||
const out = await computeMissionCost(startIso, endIso, label, config);
|
||||
console.log(out);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
const burnRates = {};
|
||||
for (const name of TEAMS) {
|
||||
|
|
|
|||
15
package-lock.json
generated
Normal file
15
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "token-monitor",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "token-monitor",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,25 @@ export async function probeTeamsProvider(providerName, baseUrl, apiKey) {
|
|||
}),
|
||||
});
|
||||
|
||||
// HTTP 400 with "extra_usage" policy: Anthropic changed billing April 4 2026.
|
||||
// Third-party apps (incl. pi) no longer draw from Teams plan limits.
|
||||
// Rate-limit headers ARE present but the provider is unusable for sessions.
|
||||
// Detect this before parseTeamsHeaders so we surface a clear status.
|
||||
if (response.status === 400) {
|
||||
let bodyText = '';
|
||||
try { bodyText = await response.text(); } catch (_) {}
|
||||
if (bodyText.includes('extra usage') || bodyText.includes('invalid_request_error')) {
|
||||
// Still parse headers for quota visibility, but override status
|
||||
const base = parseTeamsHeaders(response.headers, response.status, providerName);
|
||||
return {
|
||||
...base,
|
||||
status: 'policy_rejected',
|
||||
policy_message: 'Third-party apps blocked (Anthropic April 2026 billing change)',
|
||||
severity: 'critical',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return parseTeamsHeaders(response.headers, response.status, providerName);
|
||||
} catch (err) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
export function getSeverity(provider) {
|
||||
if (provider.type === 'teams-direct') {
|
||||
if (provider.status === 'rejected') return 'critical';
|
||||
if (provider.status === 'policy_rejected') return 'critical';
|
||||
if (provider.utilization_7d > 0.85) return 'warning';
|
||||
if (provider.utilization_5h > 0.7) return 'warning';
|
||||
return 'ok';
|
||||
|
|
@ -99,6 +100,8 @@ export function generateReport(result) {
|
|||
if (p.type === 'teams-direct') {
|
||||
if (p.status === 'invalid_key') {
|
||||
detail = 'Invalid API key (401)';
|
||||
} else if (p.status === 'policy_rejected') {
|
||||
detail = `POLICY BLOCKED — 7d: ${pct(p.utilization_7d)} | extra-usage billing required`;
|
||||
} else if (p.status === 'rejected') {
|
||||
const resetIn = formatDuration(p.reset_in_seconds);
|
||||
detail = `MAXED — 7d: ${pct(p.utilization_7d)} | resets in ${resetIn}`;
|
||||
|
|
|
|||
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