feat: add budget dashboard with per-bead cost tracking and EMA burn rate
Adds Budget panel (B key) to TUI with per-bead/per-worker cost tracking, EMA-smoothed burn rate, time-series storage, and CostDashboard web component. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
27d9278cf9
commit
46c51a79c3
10 changed files with 1358 additions and 28 deletions
|
|
@ -16,6 +16,7 @@
|
|||
"tui": "node dist/cli.js tui",
|
||||
"web": "node dist/cli.js web",
|
||||
"clean": "rm -rf dist",
|
||||
"deploy": "npm run build && npm run build:web",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
|
|||
442
src/parser.real-logs.integration.test.ts
Normal file
442
src/parser.real-logs.integration.test.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/**
|
||||
* Real NEEDLE Log Integration Test (bd-129)
|
||||
*
|
||||
* Reads actual NEEDLE log files from ~/.needle/logs/ and verifies
|
||||
* the parser correctly extracts worker, bead, timestamp and event
|
||||
* information from production logs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parseLogLine, parseLogLines } from './parser.js';
|
||||
|
||||
const NEEDLE_LOGS_DIR = join(
|
||||
process.env.HOME || '/home/coding',
|
||||
'.needle',
|
||||
'logs',
|
||||
);
|
||||
|
||||
/** Read first N lines from a file (avoids loading multi-MB files entirely). */
|
||||
function headLines(filePath: string, maxLines: number): string {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n').slice(0, maxLines);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Read lines matching a grep pattern from a file. */
|
||||
function grepLines(filePath: string, pattern: RegExp, maxLines = 50): string {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const matching = content
|
||||
.split('\n')
|
||||
.filter((line) => pattern.test(line))
|
||||
.slice(0, maxLines);
|
||||
return matching.join('\n');
|
||||
}
|
||||
|
||||
/** Pick a small-ish log file with a variety of events for targeted tests. */
|
||||
function pickFixtureFile(dir: string): string {
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.startsWith('needle-') && f.endsWith('.log'))
|
||||
.sort();
|
||||
// Prefer foxtrot — small file with worker lifecycle, claim, exhaust, idle, and bead work events
|
||||
const preferred = files.find((f) => f.includes('foxtrot'));
|
||||
return preferred ? join(dir, preferred) : join(dir, files[0]);
|
||||
}
|
||||
|
||||
describe('Real NEEDLE Log Integration', () => {
|
||||
let logsDir: string;
|
||||
let fixturePath: string;
|
||||
|
||||
beforeAll(() => {
|
||||
if (!existsSync(NEEDLE_LOGS_DIR)) {
|
||||
throw new Error(
|
||||
`NEEDLE logs directory not found: ${NEEDLE_LOGS_DIR}. ` +
|
||||
`This test requires production NEEDLE log files.`,
|
||||
);
|
||||
}
|
||||
logsDir = NEEDLE_LOGS_DIR;
|
||||
fixturePath = pickFixtureFile(logsDir);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Directory-level sanity checks
|
||||
// -----------------------------------------------------------------------
|
||||
describe('log directory', () => {
|
||||
it('should contain needle-*.log files', () => {
|
||||
const files = readdirSync(logsDir).filter(
|
||||
(f) => f.startsWith('needle-') && f.endsWith('.log'),
|
||||
);
|
||||
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should have files that are valid JSONL', () => {
|
||||
const files = readdirSync(logsDir)
|
||||
.filter((f) => f.startsWith('needle-') && f.endsWith('.log'))
|
||||
.slice(0, 5);
|
||||
|
||||
for (const file of files) {
|
||||
const content = headLines(join(logsDir, file), 5);
|
||||
const events = parseLogLines(content);
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fixture file: targeted assertions on a single small log
|
||||
// -----------------------------------------------------------------------
|
||||
describe('fixture file parsing', () => {
|
||||
it('should parse every line in the fixture file', () => {
|
||||
const content = readFileSync(fixturePath, 'utf-8');
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
// Every non-empty line should produce an event (no silent drops)
|
||||
expect(events).toHaveLength(lines.length);
|
||||
});
|
||||
|
||||
it('should extract worker identifier on every event', () => {
|
||||
const content = readFileSync(fixturePath, 'utf-8');
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
for (const event of events) {
|
||||
expect(event.worker).toBeTruthy();
|
||||
expect(typeof event.worker).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract ISO timestamps and convert to Unix ms', () => {
|
||||
const content = headLines(fixturePath, 10);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
for (const event of events) {
|
||||
expect(event.ts).toBeGreaterThan(1700000000000); // after 2023
|
||||
expect(event.ts).toBeLessThan(2000000000000); // before 2033
|
||||
expect(Number.isFinite(event.ts)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract session identifier matching log filename', () => {
|
||||
const content = headLines(fixturePath, 20);
|
||||
const events = parseLogLines(content);
|
||||
const expectedSession = fixturePath
|
||||
.replace(/\.log$/, '')
|
||||
.split('/')
|
||||
.pop()!;
|
||||
|
||||
for (const event of events) {
|
||||
expect(event.session).toBe(expectedSession);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce monotonically increasing timestamps', () => {
|
||||
const content = readFileSync(fixturePath, 'utf-8');
|
||||
const events = parseLogLines(content);
|
||||
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].ts).toBeGreaterThanOrEqual(events[i - 1].ts);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Worker lifecycle events from real logs
|
||||
// -----------------------------------------------------------------------
|
||||
describe('worker lifecycle events', () => {
|
||||
it('should parse worker.started with pid and workspace from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-anthropic-sonnet-alpha.log'),
|
||||
/"event":"worker.started"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
const startedEvents = events.filter((e) => e.msg === 'worker.started');
|
||||
expect(startedEvents.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// First worker.started should have PID
|
||||
const first = startedEvents[0];
|
||||
expect(first.pid).toBeDefined();
|
||||
expect(typeof first.pid).toBe('number');
|
||||
expect(first.workspace).toBeDefined();
|
||||
expect(typeof first.workspace).toBe('string');
|
||||
expect(first.level).toBe('info');
|
||||
});
|
||||
|
||||
it('should parse worker.idle with consecutive_empty from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"worker.idle"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const idle = events[0];
|
||||
expect(idle.msg).toBe('worker.idle');
|
||||
expect(idle.level).toBe('info');
|
||||
expect(typeof idle.consecutive_empty).toBe('number');
|
||||
expect(typeof idle.idle_seconds).toBe('number');
|
||||
});
|
||||
|
||||
it('should parse worker.draining from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-anthropic-sonnet-alpha.log'),
|
||||
/"event":"worker.draining"/,
|
||||
3,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(events[0].msg).toBe('worker.draining');
|
||||
expect(events[0].level).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bead lifecycle events from real logs
|
||||
// -----------------------------------------------------------------------
|
||||
describe('bead lifecycle events', () => {
|
||||
it('should parse bead.claimed with bead_id and workspace from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.claimed"/,
|
||||
10,
|
||||
);
|
||||
const events = parseLogLines(content).filter(
|
||||
(e) => e.msg === 'bead.claimed',
|
||||
);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const claimed = events[0];
|
||||
expect(claimed.bead).toBeTruthy();
|
||||
expect(typeof claimed.bead).toBe('string');
|
||||
expect(claimed.workspace).toBeTruthy();
|
||||
expect(typeof claimed.workspace).toBe('string');
|
||||
expect(claimed.level).toBe('info');
|
||||
});
|
||||
|
||||
it('should parse bead.claim_retry with warn level from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.claim_retry"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const retry = events[0];
|
||||
expect(retry.msg).toBe('bead.claim_retry');
|
||||
expect(retry.level).toBe('warn');
|
||||
expect(typeof retry.bead).toBe('string');
|
||||
expect(typeof retry.attempt).toBe('number');
|
||||
});
|
||||
|
||||
it('should parse bead.claim_exhausted with error level from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.claim_exhausted"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const exhausted = events[0];
|
||||
expect(exhausted.msg).toBe('bead.claim_exhausted');
|
||||
expect(exhausted.level).toBe('error');
|
||||
});
|
||||
|
||||
it('should parse bead.completed with duration_ms from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-code-glm-4.7-bravo.log'),
|
||||
/"event":"bead.completed"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const completed = events[0];
|
||||
expect(completed.msg).toBe('bead.completed');
|
||||
expect(completed.level).toBe('info');
|
||||
expect(typeof completed.bead).toBe('string');
|
||||
expect(typeof completed.duration_ms).toBe('number');
|
||||
expect(completed.duration_ms).toBeGreaterThan(0);
|
||||
expect(typeof completed.output_file).toBe('string');
|
||||
});
|
||||
|
||||
it('should parse bead.prompt_built with prompt_length from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.prompt_built"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const prompt = events[0];
|
||||
expect(prompt.msg).toBe('bead.prompt_built');
|
||||
expect(typeof prompt.bead).toBe('string');
|
||||
expect(typeof prompt.prompt_length).toBe('number');
|
||||
expect(prompt.prompt_length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should parse bead.agent_started from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.agent_started"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
const started = events[0];
|
||||
expect(started.msg).toBe('bead.agent_started');
|
||||
expect(typeof started.bead).toBe('string');
|
||||
expect(typeof started.agent).toBe('string');
|
||||
});
|
||||
|
||||
it('should parse bead.mitosis.check from real logs', () => {
|
||||
const content = grepLines(
|
||||
fixturePath,
|
||||
/"event":"bead.mitosis.check"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(events[0].msg).toBe('bead.mitosis.check');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Error events from real logs
|
||||
// -----------------------------------------------------------------------
|
||||
describe('error events', () => {
|
||||
it('should parse error.release_failed with error level from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-anthropic-sonnet-alpha.log'),
|
||||
/"event":"error.release_failed"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
if (events.length > 0) {
|
||||
expect(events[0].level).toBe('error');
|
||||
expect(events[0].msg).toBe('error.release_failed');
|
||||
}
|
||||
// If no events found, test passes — file may not have this event type
|
||||
});
|
||||
|
||||
it('should parse error.agent_crash with error level from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-anthropic-sonnet-alpha.log'),
|
||||
/"event":"error.agent_crash"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
if (events.length > 0) {
|
||||
expect(events[0].level).toBe('error');
|
||||
expect(events[0].msg).toBe('error.agent_crash');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse bead.failed with error level from real logs', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-anthropic-sonnet-alpha.log'),
|
||||
/"event":"bead.failed"/,
|
||||
5,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
if (events.length > 0) {
|
||||
expect(events[0].level).toBe('error');
|
||||
expect(events[0].msg).toBe('bead.failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cross-file consistency: parse multiple real log files
|
||||
// -----------------------------------------------------------------------
|
||||
describe('cross-file consistency', () => {
|
||||
it('should successfully parse a sample of 10 different log files', () => {
|
||||
const files = readdirSync(logsDir)
|
||||
.filter((f) => f.startsWith('needle-') && f.endsWith('.log'))
|
||||
.slice(0, 10);
|
||||
|
||||
for (const file of files) {
|
||||
const content = headLines(join(logsDir, file), 100);
|
||||
const events = parseLogLines(content);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract consistent worker names within each session', () => {
|
||||
const files = readdirSync(logsDir)
|
||||
.filter((f) => f.startsWith('needle-') && f.endsWith('.log'))
|
||||
.slice(0, 5);
|
||||
|
||||
for (const file of files) {
|
||||
const content = headLines(join(logsDir, file), 200);
|
||||
const events = parseLogLines(content);
|
||||
if (events.length === 0) continue;
|
||||
|
||||
const workers = new Set(events.map((e) => e.worker));
|
||||
// All events in a single session file should have the same worker
|
||||
expect(workers.size).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract valid event types across all log files', () => {
|
||||
const files = readdirSync(logsDir)
|
||||
.filter((f) => f.startsWith('needle-') && f.endsWith('.log'))
|
||||
.slice(0, 10);
|
||||
|
||||
const knownPrefixes = [
|
||||
'worker.',
|
||||
'bead.',
|
||||
'effort.',
|
||||
'error.',
|
||||
'explore.',
|
||||
'engine.',
|
||||
'pulse.',
|
||||
'config.',
|
||||
'hook.',
|
||||
'intent.',
|
||||
'file.',
|
||||
'test.',
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const content = headLines(join(logsDir, file), 100);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
for (const event of events) {
|
||||
const hasKnownPrefix = knownPrefixes.some((p) =>
|
||||
event.msg.startsWith(p),
|
||||
);
|
||||
expect(hasKnownPrefix).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve all data payload fields on parsed events', () => {
|
||||
const content = grepLines(
|
||||
join(logsDir, 'needle-claude-code-glm-4.7-bravo.log'),
|
||||
/"event":"bead.completed"/,
|
||||
1,
|
||||
);
|
||||
const events = parseLogLines(content);
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
const completed = events[0];
|
||||
// These fields come from data payload and should be spread onto the event
|
||||
expect(completed.bead).toBeDefined();
|
||||
expect(completed.duration_ms).toBeDefined();
|
||||
expect(completed.output_file).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -44,6 +44,7 @@ import { ErrorGroupManager, getErrorGroupManager } from './errorGrouping.js';
|
|||
import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlaybook.js';
|
||||
import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js';
|
||||
import { WorkerAnalytics, getWorkerAnalytics } from './workerAnalytics.js';
|
||||
import { CostTracker } from './tui/utils/costTracking.js';
|
||||
import { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js';
|
||||
import { HistoricalStore, getHistoricalStore } from './historicalStore.js';
|
||||
|
||||
|
|
@ -1276,6 +1277,13 @@ export class InMemoryEventStore implements EventStore {
|
|||
return this.workerAnalytics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost tracker instance for budget/cost data
|
||||
*/
|
||||
getCostTracker(): CostTracker {
|
||||
return this.workerAnalytics.getCostTracker();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics metrics for a specific worker
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export class FabricTuiApp {
|
|||
private fileContextPanel!: FileContextPanel;
|
||||
private conversationTranscript!: ConversationTranscript;
|
||||
private crossReferencePanel!: CrossReferencePanel;
|
||||
private budgetAlertPanel!: BudgetAlertPanel;
|
||||
private footerBox!: blessed.Widgets.BoxElement;
|
||||
private helpOverlay?: blessed.Widgets.BoxElement;
|
||||
|
||||
|
|
@ -383,6 +384,21 @@ export class FabricTuiApp {
|
|||
});
|
||||
this.crossReferencePanel.hide();
|
||||
|
||||
// Budget Alert panel (hidden by default, 'B' key)
|
||||
this.budgetAlertPanel = new BudgetAlertPanel({
|
||||
parent: this.screen,
|
||||
top: 1,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
bottom: 1,
|
||||
onAcknowledge: (alertId) => {
|
||||
const tracker = getCostTracker();
|
||||
tracker.acknowledgeAlert(alertId);
|
||||
this.updateBudgetPanel();
|
||||
},
|
||||
});
|
||||
this.budgetAlertPanel.hide();
|
||||
|
||||
// Footer with key hints
|
||||
this.footerBox = blessed.box({
|
||||
parent: this.screen,
|
||||
|
|
@ -403,7 +419,7 @@ export class FabricTuiApp {
|
|||
*/
|
||||
private getFooterContent(): string {
|
||||
if (this.viewMode === 'default') {
|
||||
let content = ' [Tab] Switch [j/k] Scroll [/] Search [H] Heatmap [D] DAG [E] Errors [I] Git [C] Collisions [N] Narrative [A] Analytics [T] Transcript [X] XRef';
|
||||
let content = ' [Tab] Switch [j/k] Scroll [/] Search [H] Heatmap [D] DAG [E] Errors [I] Git [C] Collisions [N] Narrative [A] Analytics [B] Budget [T] Transcript [X] XRef';
|
||||
|
||||
// Show focus mode status
|
||||
if (this.focusModeEnabled) {
|
||||
|
|
@ -433,7 +449,7 @@ export class FabricTuiApp {
|
|||
}
|
||||
|
||||
// Return default content for other views
|
||||
return ' [Tab] Switch [j/k] Scroll [/] Search [H] Heatmap [D] DAG [E] Errors [C] Collisions [N] Narrative [A] Analytics [T] Transcript [X] XRef [?] Help [q] Quit';
|
||||
return ' [Tab] Switch [j/k] Scroll [/] Search [H] Heatmap [D] DAG [E] Errors [C] Collisions [N] Narrative [A] Analytics [B] Budget [T] Transcript [X] XRef [?] Help [q] Quit';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -524,6 +540,11 @@ export class FabricTuiApp {
|
|||
this.toggleAnalyticsView();
|
||||
});
|
||||
|
||||
// Toggle budget dashboard view
|
||||
this.screen.key(['B'], () => {
|
||||
this.toggleBudgetView();
|
||||
});
|
||||
|
||||
// Toggle conversation transcript view
|
||||
this.screen.key(['T'], () => {
|
||||
this.toggleTranscriptView();
|
||||
|
|
@ -621,6 +642,8 @@ export class FabricTuiApp {
|
|||
this.toggleNarrativeView();
|
||||
} else if (cmd === 'analytics') {
|
||||
this.toggleAnalyticsView();
|
||||
} else if (cmd === 'budget') {
|
||||
this.toggleBudgetView();
|
||||
} else if (cmd.startsWith('filter:worker:')) {
|
||||
const workerId = cmd.replace('filter:worker:', '');
|
||||
this.activityStream.setFilter({ workerId });
|
||||
|
|
@ -787,6 +810,17 @@ export class FabricTuiApp {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle budget dashboard view
|
||||
*/
|
||||
private toggleBudgetView(): void {
|
||||
if (this.viewMode === 'budget') {
|
||||
this.setViewMode('default');
|
||||
} else {
|
||||
this.setViewMode('budget');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle theme between dark and light
|
||||
*/
|
||||
|
|
@ -813,10 +847,20 @@ export class FabricTuiApp {
|
|||
this.collisionAlert.updateAlerts(alerts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update budget panel with current cost data
|
||||
*/
|
||||
private updateBudgetPanel(): void {
|
||||
const tracker = getCostTracker();
|
||||
const summary = tracker.getSummary();
|
||||
this.budgetAlertPanel.setCostSummary(summary);
|
||||
this.budgetAlertPanel.setAlerts(tracker.getAlerts());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set view mode
|
||||
*/
|
||||
private setViewMode(mode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' | 'git' | 'narrative' | 'analytics'): void {
|
||||
private setViewMode(mode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' | 'git' | 'narrative' | 'analytics' | 'transcript' | 'xref' | 'budget'): void {
|
||||
this.viewMode = mode;
|
||||
|
||||
// Hide file context panel when switching views (except default)
|
||||
|
|
@ -1055,6 +1099,8 @@ export class FabricTuiApp {
|
|||
this.gitIntegration.hide();
|
||||
this.semanticNarrativePanel.hide();
|
||||
this.workerAnalyticsPanel.hide();
|
||||
this.crossReferencePanel.hide();
|
||||
this.budgetAlertPanel.hide();
|
||||
|
||||
// Show conversation transcript panel
|
||||
this.conversationTranscript.show();
|
||||
|
|
@ -1077,6 +1123,7 @@ export class FabricTuiApp {
|
|||
this.semanticNarrativePanel.hide();
|
||||
this.workerAnalyticsPanel.hide();
|
||||
this.conversationTranscript.hide();
|
||||
this.budgetAlertPanel.hide();
|
||||
|
||||
// Show cross-reference panel
|
||||
this.crossReferencePanel.show();
|
||||
|
|
@ -1085,6 +1132,30 @@ export class FabricTuiApp {
|
|||
// Update header
|
||||
this.headerBox.setContent(' FABRIC - Cross References');
|
||||
this.footerBox.setContent(' [↑/↓] or [j/k] Navigate [Enter] Follow [s] Stats [r] Refresh [Esc] Back [?] Help [q] Quit');
|
||||
} else if (mode === 'budget') {
|
||||
// Hide other panels
|
||||
this.workerGrid.getElement().hide();
|
||||
this.activityStream.getElement().hide();
|
||||
this.fileHeatmap.getElement().hide();
|
||||
this.dependencyDag.getElement().hide();
|
||||
this.sessionReplay.hide();
|
||||
this.errorGroupPanel.hide();
|
||||
this.sessionDigest.hide();
|
||||
this.collisionAlert.hide();
|
||||
this.gitIntegration.hide();
|
||||
this.semanticNarrativePanel.hide();
|
||||
this.workerAnalyticsPanel.hide();
|
||||
this.conversationTranscript.hide();
|
||||
this.crossReferencePanel.hide();
|
||||
|
||||
// Show budget dashboard panel
|
||||
this.updateBudgetPanel();
|
||||
this.budgetAlertPanel.show();
|
||||
this.budgetAlertPanel.focus();
|
||||
|
||||
// Update header
|
||||
this.headerBox.setContent(' FABRIC - Budget Dashboard');
|
||||
this.footerBox.setContent(' [a] Acknowledge [r] Refresh [s] Settings [Esc] Back [?] Help [q] Quit');
|
||||
} else {
|
||||
// Hide special views
|
||||
this.fileHeatmap.getElement().hide();
|
||||
|
|
@ -1099,6 +1170,7 @@ export class FabricTuiApp {
|
|||
this.fileContextPanel.hide();
|
||||
this.conversationTranscript.hide();
|
||||
this.crossReferencePanel.hide();
|
||||
this.budgetAlertPanel.hide();
|
||||
|
||||
// Show default panels
|
||||
this.workerGrid.getElement().show();
|
||||
|
|
@ -1549,6 +1621,13 @@ Worker Analytics:
|
|||
r - Refresh metrics
|
||||
Esc - Return to default view
|
||||
|
||||
Budget Dashboard:
|
||||
B - Toggle budget dashboard view
|
||||
a - Acknowledge alert
|
||||
r - Refresh cost data
|
||||
s - Open budget settings
|
||||
Esc - Return to default view
|
||||
|
||||
Theme:
|
||||
Ctrl+T - Toggle dark/light theme
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,48 @@ export interface TopConsumer {
|
|||
insight?: string;
|
||||
}
|
||||
|
||||
export interface BeadCost {
|
||||
/** Bead ID */
|
||||
beadId: string;
|
||||
/** Total cost in USD */
|
||||
costUsd: number;
|
||||
/** Token usage */
|
||||
input: number;
|
||||
output: number;
|
||||
/** Number of API calls attributed to this bead */
|
||||
apiCalls: number;
|
||||
/** Worker IDs that worked on this bead */
|
||||
workers: Set<string>;
|
||||
/** First activity timestamp */
|
||||
firstTs: number;
|
||||
/** Last activity timestamp */
|
||||
lastTs: number;
|
||||
/** Duration in minutes */
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
export interface CostTimeSeriesPoint {
|
||||
/** Timestamp (start of bucket) */
|
||||
ts: number;
|
||||
/** Total cost in bucket */
|
||||
cost: number;
|
||||
/** Number of API calls in bucket */
|
||||
apiCalls: number;
|
||||
/** Active workers in bucket */
|
||||
activeWorkers: number;
|
||||
}
|
||||
|
||||
export interface BurnRateHistory {
|
||||
/** Timestamp of measurement */
|
||||
ts: number;
|
||||
/** Raw cost per minute */
|
||||
rawRate: number;
|
||||
/** Smoothed cost per minute (EMA) */
|
||||
smoothedRate: number;
|
||||
/** Number of data points in window */
|
||||
sampleCount: number;
|
||||
}
|
||||
|
||||
export interface CostTrackingOptions {
|
||||
/** Budget limit in USD (0 = no limit) */
|
||||
budgetLimit?: number;
|
||||
|
|
@ -166,6 +208,15 @@ export interface CostTrackingOptions {
|
|||
|
||||
/** High burn rate threshold in USD/min (default: 0.50) */
|
||||
highBurnRateThreshold?: number;
|
||||
|
||||
/** EMA smoothing factor for burn rate (default: 0.3, higher = more responsive) */
|
||||
burnRateSmoothingFactor?: number;
|
||||
|
||||
/** Time-series bucket size in minutes (default: 1) */
|
||||
timeSeriesBucketMinutes?: number;
|
||||
|
||||
/** How long to keep time-series data in minutes (default: 1440 = 24h) */
|
||||
timeSeriesRetentionMinutes?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<CostTrackingOptions> = {
|
||||
|
|
@ -176,6 +227,9 @@ const DEFAULT_OPTIONS: Required<CostTrackingOptions> = {
|
|||
outputCostPerMillion: 15.00, // Claude Sonnet output
|
||||
burnRateWindowMinutes: 5, // Calculate burn rate over last 5 minutes
|
||||
highBurnRateThreshold: 0.50, // High burn rate if > $0.50/min
|
||||
burnRateSmoothingFactor: 0.3, // EMA smoothing factor
|
||||
timeSeriesBucketMinutes: 1, // 1-minute buckets for time-series
|
||||
timeSeriesRetentionMinutes: 1440, // Keep 24h of time-series data
|
||||
};
|
||||
|
||||
// Model pricing (per 1M tokens)
|
||||
|
|
@ -202,7 +256,18 @@ export class CostTracker {
|
|||
private lastEventTs: number | null = null;
|
||||
|
||||
// Burn rate tracking
|
||||
private costHistory: Array<{ ts: number; cost: number; worker: string }> = [];
|
||||
private costHistory: Array<{ ts: number; cost: number; worker: string; bead?: string }> = [];
|
||||
|
||||
// Per-bead cost tracking
|
||||
private beadCosts: Map<string, BeadCost> = new Map();
|
||||
private workerCurrentBead: Map<string, string> = new Map();
|
||||
|
||||
// EMA-smoothed burn rate
|
||||
private smoothedBurnRate: number | null = null;
|
||||
|
||||
// Time-series storage
|
||||
private timeSeries: CostTimeSeriesPoint[] = [];
|
||||
private burnRateHistory: BurnRateHistory[] = [];
|
||||
|
||||
// Alert tracking
|
||||
private alerts: BudgetAlert[] = [];
|
||||
|
|
@ -268,16 +333,92 @@ export class CostTracker {
|
|||
ts: event.ts,
|
||||
cost: incrementalCost,
|
||||
worker: event.worker,
|
||||
bead: event.bead || undefined,
|
||||
});
|
||||
|
||||
// Trim old history (keep last 30 minutes)
|
||||
const cutoffTs = event.ts - (30 * 60 * 1000);
|
||||
this.costHistory = this.costHistory.filter(h => h.ts > cutoffTs);
|
||||
|
||||
// Track per-bead costs
|
||||
if (event.bead) {
|
||||
this.trackBeadCost(event.bead, event.worker, tokens, incrementalCost, event.ts);
|
||||
}
|
||||
|
||||
// Update time-series bucket
|
||||
this.updateTimeSeries(event.ts, incrementalCost);
|
||||
|
||||
// Check for budget alerts
|
||||
this.checkBudgetAlerts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track cost for a specific bead
|
||||
*/
|
||||
private trackBeadCost(
|
||||
beadId: string,
|
||||
workerId: string,
|
||||
tokens: { input: number; output: number },
|
||||
cost: number,
|
||||
ts: number,
|
||||
): void {
|
||||
let beadCost = this.beadCosts.get(beadId);
|
||||
if (!beadCost) {
|
||||
beadCost = {
|
||||
beadId,
|
||||
costUsd: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
apiCalls: 0,
|
||||
workers: new Set(),
|
||||
firstTs: ts,
|
||||
lastTs: ts,
|
||||
durationMinutes: 0,
|
||||
};
|
||||
this.beadCosts.set(beadId, beadCost);
|
||||
}
|
||||
|
||||
beadCost.costUsd += cost;
|
||||
beadCost.input += tokens.input;
|
||||
beadCost.output += tokens.output;
|
||||
beadCost.apiCalls += 1;
|
||||
beadCost.workers.add(workerId);
|
||||
beadCost.lastTs = ts;
|
||||
beadCost.durationMinutes = (ts - beadCost.firstTs) / 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update time-series bucket
|
||||
*/
|
||||
private updateTimeSeries(ts: number, incrementalCost: number): void {
|
||||
const bucketMs = this.options.timeSeriesBucketMinutes * 60 * 1000;
|
||||
const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
|
||||
|
||||
// Find or create the bucket
|
||||
let bucket = this.timeSeries.find(b => b.ts === bucketTs);
|
||||
if (!bucket) {
|
||||
bucket = { ts: bucketTs, cost: 0, apiCalls: 0, activeWorkers: 0 };
|
||||
this.timeSeries.push(bucket);
|
||||
}
|
||||
|
||||
bucket.cost += incrementalCost;
|
||||
bucket.apiCalls += 1;
|
||||
|
||||
// Count active workers in this bucket from recent cost history
|
||||
const bucketEnd = bucketTs + bucketMs;
|
||||
const activeWorkerSet = new Set(
|
||||
this.costHistory
|
||||
.filter(h => h.ts >= bucketTs && h.ts < bucketEnd)
|
||||
.map(h => h.worker)
|
||||
);
|
||||
bucket.activeWorkers = activeWorkerSet.size;
|
||||
|
||||
// Trim old time-series data
|
||||
const retentionMs = this.options.timeSeriesRetentionMinutes * 60 * 1000;
|
||||
const cutoffTs = ts - retentionMs;
|
||||
this.timeSeries = this.timeSeries.filter(b => b.ts > cutoffTs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token counts from event
|
||||
*/
|
||||
|
|
@ -320,17 +461,14 @@ export class CostTracker {
|
|||
getSummary(): CostSummary {
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let totalCostUsd = 0;
|
||||
|
||||
for (const worker of this.workerCosts.values()) {
|
||||
totalInput += worker.input;
|
||||
totalOutput += worker.output;
|
||||
totalCostUsd += worker.costUsd;
|
||||
}
|
||||
|
||||
const totalPrice = MODEL_PRICING['claude-sonnet-4-6']; // Default pricing
|
||||
const totalCostUsd =
|
||||
(totalInput * totalPrice.input / 1_000_000) +
|
||||
(totalOutput * totalPrice.output / 1_000_000);
|
||||
|
||||
const budget = this.calculateBudgetStatus(totalCostUsd);
|
||||
const burnRate = this.calculateBurnRate();
|
||||
|
||||
|
|
@ -352,7 +490,51 @@ export class CostTracker {
|
|||
}
|
||||
|
||||
/**
|
||||
* Calculate burn rate (cost per minute)
|
||||
* Get per-bead cost breakdown, sorted by cost descending
|
||||
*/
|
||||
getBeadCosts(): BeadCost[] {
|
||||
return Array.from(this.beadCosts.values())
|
||||
.sort((a, b) => b.costUsd - a.costUsd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost for a specific bead
|
||||
*/
|
||||
getBeadCost(beadId: string): BeadCost | undefined {
|
||||
return this.beadCosts.get(beadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-worker cost breakdown with bead attribution
|
||||
*/
|
||||
getWorkerCostBreakdown(workerId: string): {
|
||||
worker: WorkerCost;
|
||||
beadCosts: Array<{ beadId: string; costUsd: number; percentOfWorker: number }>;
|
||||
} | undefined {
|
||||
const worker = this.workerCosts.get(workerId);
|
||||
if (!worker) return undefined;
|
||||
|
||||
// Calculate per-bead costs for this worker from cost history
|
||||
const beadCostMap = new Map<string, number>();
|
||||
for (const entry of this.costHistory) {
|
||||
if (entry.worker === workerId && entry.bead) {
|
||||
beadCostMap.set(entry.bead, (beadCostMap.get(entry.bead) || 0) + entry.cost);
|
||||
}
|
||||
}
|
||||
|
||||
const beadCosts = Array.from(beadCostMap.entries())
|
||||
.map(([beadId, costUsd]) => ({
|
||||
beadId,
|
||||
costUsd,
|
||||
percentOfWorker: worker.costUsd > 0 ? (costUsd / worker.costUsd) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.costUsd - a.costUsd);
|
||||
|
||||
return { worker, beadCosts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate burn rate (cost per minute) with EMA smoothing
|
||||
*/
|
||||
private calculateBurnRate(): BurnRate {
|
||||
const now = this.lastEventTs || Date.now();
|
||||
|
|
@ -370,29 +552,43 @@ export class CostTracker {
|
|||
const actualWindowMs = now - oldestInWindow;
|
||||
const actualWindowMinutes = actualWindowMs / 60000;
|
||||
|
||||
// Cost per minute
|
||||
const costPerMinute = actualWindowMinutes > 0
|
||||
// Raw cost per minute
|
||||
const rawCostPerMinute = actualWindowMinutes > 0
|
||||
? totalCostInWindow / actualWindowMinutes
|
||||
: 0;
|
||||
|
||||
// Calculate total cost directly (avoid recursion with getSummary)
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
for (const worker of this.workerCosts.values()) {
|
||||
totalInput += worker.input;
|
||||
totalOutput += worker.output;
|
||||
// Apply EMA smoothing
|
||||
const alpha = this.options.burnRateSmoothingFactor;
|
||||
if (this.smoothedBurnRate === null) {
|
||||
this.smoothedBurnRate = rawCostPerMinute;
|
||||
} else {
|
||||
this.smoothedBurnRate = alpha * rawCostPerMinute + (1 - alpha) * this.smoothedBurnRate;
|
||||
}
|
||||
const totalPrice = MODEL_PRICING['claude-sonnet-4-6'];
|
||||
const currentTotalCost =
|
||||
(totalInput * totalPrice.input / 1_000_000) +
|
||||
(totalOutput * totalPrice.output / 1_000_000);
|
||||
|
||||
// Calculate time to exhaustion
|
||||
const costPerMinute = this.smoothedBurnRate;
|
||||
|
||||
// Record burn rate history (keep last hour)
|
||||
this.burnRateHistory.push({
|
||||
ts: now,
|
||||
rawRate: rawCostPerMinute,
|
||||
smoothedRate: costPerMinute,
|
||||
sampleCount: recentCosts.length,
|
||||
});
|
||||
const burnRateRetentionMs = 60 * 60 * 1000; // 1 hour
|
||||
this.burnRateHistory = this.burnRateHistory.filter(h => h.ts > now - burnRateRetentionMs);
|
||||
|
||||
// Calculate total cost directly from worker totals (avoid recursion)
|
||||
let totalCostUsd = 0;
|
||||
for (const worker of this.workerCosts.values()) {
|
||||
totalCostUsd += worker.costUsd;
|
||||
}
|
||||
|
||||
// Calculate time to exhaustion using smoothed rate
|
||||
let minutesToExhaustion: number | null = null;
|
||||
let timeToExhaustion: string | null = null;
|
||||
|
||||
if (this.options.budgetLimit > 0 && costPerMinute > 0) {
|
||||
const remaining = this.options.budgetLimit - currentTotalCost;
|
||||
const remaining = this.options.budgetLimit - totalCostUsd;
|
||||
if (remaining > 0) {
|
||||
minutesToExhaustion = remaining / costPerMinute;
|
||||
timeToExhaustion = formatTimeToExhaustion(minutesToExhaustion);
|
||||
|
|
@ -403,11 +599,10 @@ export class CostTracker {
|
|||
}
|
||||
|
||||
// Projected total cost at current burn rate for remainder of session
|
||||
// Assume 60-minute session by default if we don't have enough data
|
||||
const sessionDurationMs = (this.lastEventTs || now) - (this.firstEventTs || now);
|
||||
const sessionMinutes = sessionDurationMs / 60000;
|
||||
const projectedTotalCost = sessionMinutes > 0
|
||||
? currentTotalCost + (costPerMinute * Math.max(0, 60 - sessionMinutes))
|
||||
? totalCostUsd + (costPerMinute * Math.max(0, 60 - sessionMinutes))
|
||||
: costPerMinute * 60;
|
||||
|
||||
return {
|
||||
|
|
@ -420,6 +615,50 @@ export class CostTracker {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smoothed burn rate history for trend visualization
|
||||
*/
|
||||
getBurnRateHistory(sinceMinutes: number = 60): BurnRateHistory[] {
|
||||
const cutoffTs = (this.lastEventTs || Date.now()) - (sinceMinutes * 60 * 1000);
|
||||
return this.burnRateHistory.filter(h => h.ts > cutoffTs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-series cost data for trend charts
|
||||
*/
|
||||
getTimeSeries(sinceMinutes: number = 60): CostTimeSeriesPoint[] {
|
||||
const cutoffTs = (this.lastEventTs || Date.now()) - (sinceMinutes * 60 * 1000);
|
||||
return this.timeSeries.filter(b => b.ts > cutoffTs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated time-series for a coarser view (e.g., 5-minute or 15-minute buckets)
|
||||
*/
|
||||
getAggregatedTimeSeries(
|
||||
sinceMinutes: number = 60,
|
||||
bucketMinutes: number = 5,
|
||||
): CostTimeSeriesPoint[] {
|
||||
const rawPoints = this.getTimeSeries(sinceMinutes);
|
||||
if (rawPoints.length === 0) return [];
|
||||
|
||||
const bucketMs = bucketMinutes * 60 * 1000;
|
||||
const buckets = new Map<number, CostTimeSeriesPoint>();
|
||||
|
||||
for (const point of rawPoints) {
|
||||
const bucketTs = Math.floor(point.ts / bucketMs) * bucketMs;
|
||||
let existing = buckets.get(bucketTs);
|
||||
if (!existing) {
|
||||
existing = { ts: bucketTs, cost: 0, apiCalls: 0, activeWorkers: 0 };
|
||||
buckets.set(bucketTs, existing);
|
||||
}
|
||||
existing.cost += point.cost;
|
||||
existing.apiCalls += point.apiCalls;
|
||||
existing.activeWorkers = Math.max(existing.activeWorkers, point.activeWorkers);
|
||||
}
|
||||
|
||||
return Array.from(buckets.values()).sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top consumers by cost
|
||||
*/
|
||||
|
|
@ -616,6 +855,11 @@ export class CostTracker {
|
|||
reset(): void {
|
||||
this.workerCosts.clear();
|
||||
this.costHistory = [];
|
||||
this.beadCosts.clear();
|
||||
this.workerCurrentBead.clear();
|
||||
this.timeSeries = [];
|
||||
this.burnRateHistory = [];
|
||||
this.smoothedBurnRate = null;
|
||||
this.alerts = [];
|
||||
this.firstEventTs = null;
|
||||
this.lastEventTs = null;
|
||||
|
|
|
|||
461
src/web/frontend/src/components/CostDashboard.tsx
Normal file
461
src/web/frontend/src/components/CostDashboard.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ============================================
|
||||
// Cost Dashboard Types
|
||||
// ============================================
|
||||
|
||||
interface BudgetStatus {
|
||||
limit: number;
|
||||
spent: number;
|
||||
percentUsed: number;
|
||||
isOverBudget: boolean;
|
||||
warningLevel: 'none' | 'warning' | 'critical';
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface BurnRate {
|
||||
costPerMinute: number;
|
||||
minutesToExhaustion: number | null;
|
||||
timeToExhaustion: string | null;
|
||||
projectedTotalCost: number;
|
||||
windowMinutes: number;
|
||||
isHighBurnRate: boolean;
|
||||
}
|
||||
|
||||
interface CostSummary {
|
||||
totalCostUsd: number;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
budget: BudgetStatus;
|
||||
burnRate: BurnRate;
|
||||
timeRange: { start: number; end: number };
|
||||
workerCount: number;
|
||||
}
|
||||
|
||||
interface WorkerCostEntry {
|
||||
workerId: string;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
apiCalls: number;
|
||||
currentBead?: string;
|
||||
lastActivityTs?: number;
|
||||
}
|
||||
|
||||
interface BeadCostEntry {
|
||||
beadId: string;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
apiCalls: number;
|
||||
workerCount: number;
|
||||
workers: string[];
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
interface TimeSeriesPoint {
|
||||
ts: number;
|
||||
cost: number;
|
||||
apiCalls: number;
|
||||
activeWorkers: number;
|
||||
}
|
||||
|
||||
interface BudgetAlert {
|
||||
id: string;
|
||||
type: 'warning' | 'critical' | 'exhausted';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
spent: number;
|
||||
limit: number;
|
||||
burnRate: number;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function formatCost(usd: number): string {
|
||||
if (usd < 0.01) return `$${(usd * 100).toFixed(2)}c`;
|
||||
if (usd < 1) return `$${usd.toFixed(3)}`;
|
||||
if (usd < 100) return `$${usd.toFixed(2)}`;
|
||||
return `$${usd.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 1_000_000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return `${(count / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
function formatBurnRate(rate: number): string {
|
||||
if (rate < 0.01) return `$${(rate * 100).toFixed(2)}c/min`;
|
||||
return `$${rate.toFixed(2)}/min`;
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Components
|
||||
// ============================================
|
||||
|
||||
interface BudgetProgressBarProps {
|
||||
spent: number;
|
||||
limit: number;
|
||||
percentUsed: number;
|
||||
warningLevel: string;
|
||||
}
|
||||
|
||||
const BudgetProgressBar: React.FC<BudgetProgressBarProps> = ({ spent, limit, percentUsed, warningLevel }) => {
|
||||
const getColor = () => {
|
||||
if (warningLevel === 'critical') return 'var(--error)';
|
||||
if (warningLevel === 'warning') return 'var(--warning)';
|
||||
return 'var(--success)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="budget-progress-container">
|
||||
<div className="budget-progress-label">
|
||||
<span>{formatCost(spent)} / {formatCost(limit)}</span>
|
||||
<span>{Math.round(percentUsed)}%</span>
|
||||
</div>
|
||||
<div className="budget-progress-bar">
|
||||
<div
|
||||
className="budget-progress-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, percentUsed)}%`,
|
||||
backgroundColor: getColor(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MiniChartProps {
|
||||
data: TimeSeriesPoint[];
|
||||
height?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const MiniChart: React.FC<MiniChartProps> = ({ data, height = 60, color = 'var(--accent)' }) => {
|
||||
if (data.length < 2) {
|
||||
return <div className="mini-chart-empty">No data yet</div>;
|
||||
}
|
||||
|
||||
const maxCost = Math.max(...data.map(d => d.cost), 0.001);
|
||||
const width = 100;
|
||||
const step = width / (data.length - 1);
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = i * step;
|
||||
const y = height - (d.cost / maxCost) * (height - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="mini-chart" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface CostDashboardProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CostDashboard: React.FC<CostDashboardProps> = ({ visible, onClose }) => {
|
||||
const [summary, setSummary] = useState<CostSummary | null>(null);
|
||||
const [workers, setWorkers] = useState<WorkerCostEntry[]>([]);
|
||||
const [beads, setBeads] = useState<BeadCostEntry[]>([]);
|
||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||
const [alerts, setAlerts] = useState<BudgetAlert[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'workers' | 'beads' | 'trends'>('overview');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchCostData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summaryRes, workersRes, beadsRes, historyRes, alertsRes] = await Promise.all([
|
||||
fetch('/api/cost/summary'),
|
||||
fetch('/api/cost/workers'),
|
||||
fetch('/api/cost/beads'),
|
||||
fetch('/api/cost/history?since=60&bucket=5'),
|
||||
fetch('/api/cost/alerts'),
|
||||
]);
|
||||
|
||||
if (summaryRes.ok) setSummary(await summaryRes.json());
|
||||
if (workersRes.ok) {
|
||||
const data = await workersRes.json();
|
||||
setWorkers(data.workers || []);
|
||||
}
|
||||
if (beadsRes.ok) {
|
||||
const data = await beadsRes.json();
|
||||
setBeads(data.beads || []);
|
||||
}
|
||||
if (historyRes.ok) {
|
||||
const data = await historyRes.json();
|
||||
setTimeSeries(data.timeSeries || []);
|
||||
}
|
||||
if (alertsRes.ok) {
|
||||
const data = await alertsRes.json();
|
||||
setAlerts(data.active || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cost data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchCostData();
|
||||
const interval = setInterval(fetchCostData, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [visible, fetchCostData]);
|
||||
|
||||
const handleAcknowledge = useCallback(async (alertId: string) => {
|
||||
try {
|
||||
await fetch(`/api/cost/alerts/${alertId}/acknowledge`, { method: 'POST' });
|
||||
setAlerts(prev => prev.map(a => a.id === alertId ? { ...a, acknowledged: true } : a));
|
||||
} catch (err) {
|
||||
console.error('Failed to acknowledge alert:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as const, label: 'Overview' },
|
||||
{ id: 'workers' as const, label: 'Workers' },
|
||||
{ id: 'beads' as const, label: 'Tasks' },
|
||||
{ id: 'trends' as const, label: 'Trends' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="cost-dashboard-overlay">
|
||||
<div className="cost-dashboard">
|
||||
<div className="cost-dashboard-header">
|
||||
<h3>Budget Dashboard</h3>
|
||||
<div className="cost-dashboard-header-actions">
|
||||
{alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<span className="cost-alert-badge">
|
||||
{alerts.filter(a => !a.acknowledged).length} alert{alerts.filter(a => !a.acknowledged).length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cost-dashboard-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`cost-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cost-dashboard-content">
|
||||
{loading && !summary && (
|
||||
<div className="cost-loading">Loading cost data...</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'overview' && summary && (
|
||||
<div className="cost-overview">
|
||||
{/* Session Cost */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Session Cost</div>
|
||||
<div className="cost-card-value">{formatCost(summary.totalCostUsd)}</div>
|
||||
<div className="cost-card-subtitle">
|
||||
{formatTokens(summary.totalTokens)} tokens ({formatTokens(summary.inputTokens)} in / {formatTokens(summary.outputTokens)} out)
|
||||
</div>
|
||||
{summary.budget.limit > 0 && (
|
||||
<BudgetProgressBar
|
||||
spent={summary.budget.spent}
|
||||
limit={summary.budget.limit}
|
||||
percentUsed={summary.budget.percentUsed}
|
||||
warningLevel={summary.budget.warningLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Burn Rate */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Burn Rate</div>
|
||||
<div className={`cost-card-value ${summary.burnRate.isHighBurnRate ? 'cost-high' : ''}`}>
|
||||
{formatBurnRate(summary.burnRate.costPerMinute)}
|
||||
</div>
|
||||
<div className="cost-card-subtitle">
|
||||
Window: {summary.burnRate.windowMinutes} min avg
|
||||
</div>
|
||||
{summary.burnRate.timeToExhaustion && (
|
||||
<div className="cost-exhaustion">
|
||||
Time to exhaustion: <strong>{summary.burnRate.timeToExhaustion}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="cost-projected">
|
||||
Projected session total: {formatCost(summary.burnRate.projectedTotalCost)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{alerts.filter(a => !a.acknowledged).length > 0 && (
|
||||
<div className="cost-card cost-alerts-card">
|
||||
<div className="cost-card-title">Active Alerts</div>
|
||||
{alerts.filter(a => !a.acknowledged).map(alert => (
|
||||
<div key={alert.id} className={`cost-alert-item cost-alert-${alert.type}`}>
|
||||
<div className="cost-alert-header">
|
||||
<span className="cost-alert-type">{alert.type.toUpperCase()}</span>
|
||||
<span className="cost-alert-time">{new Date(alert.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div className="cost-alert-details">
|
||||
{formatCost(alert.spent)} / {formatCost(alert.limit)} at {formatBurnRate(alert.burnRate)}
|
||||
</div>
|
||||
<button className="cost-alert-ack" onClick={() => handleAcknowledge(alert.id)}>
|
||||
Acknowledge
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Workers Summary */}
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Top Workers ({summary.workerCount} total)</div>
|
||||
{workers.slice(0, 5).map(w => (
|
||||
<div key={w.workerId} className="cost-worker-row">
|
||||
<span className="cost-worker-id">{w.workerId}</span>
|
||||
<span className="cost-worker-cost">{formatCost(w.costUsd)}</span>
|
||||
<span className="cost-worker-tokens">{formatTokens(w.totalTokens)} tok</span>
|
||||
</div>
|
||||
))}
|
||||
{workers.length === 0 && <div className="cost-empty">No cost data yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'workers' && (
|
||||
<div className="cost-workers-view">
|
||||
<table className="cost-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Cost</th>
|
||||
<th>Input Tokens</th>
|
||||
<th>Output Tokens</th>
|
||||
<th>Calls</th>
|
||||
<th>Current Task</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map(w => (
|
||||
<tr key={w.workerId}>
|
||||
<td className="cost-worker-id-cell">{w.workerId}</td>
|
||||
<td className="cost-number">{formatCost(w.costUsd)}</td>
|
||||
<td className="cost-number">{formatTokens(w.inputTokens)}</td>
|
||||
<td className="cost-number">{formatTokens(w.outputTokens)}</td>
|
||||
<td className="cost-number">{w.apiCalls}</td>
|
||||
<td className="cost-bead-cell">{w.currentBead || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{workers.length === 0 && <div className="cost-empty">No worker cost data yet</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'beads' && (
|
||||
<div className="cost-beads-view">
|
||||
<table className="cost-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Cost</th>
|
||||
<th>Tokens</th>
|
||||
<th>Calls</th>
|
||||
<th>Workers</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{beads.map(b => (
|
||||
<tr key={b.beadId}>
|
||||
<td className="cost-bead-id-cell">{b.beadId}</td>
|
||||
<td className="cost-number">{formatCost(b.costUsd)}</td>
|
||||
<td className="cost-number">{formatTokens(b.inputTokens + b.outputTokens)}</td>
|
||||
<td className="cost-number">{b.apiCalls}</td>
|
||||
<td className="cost-number">{b.workerCount}</td>
|
||||
<td className="cost-number">{b.durationMinutes < 1 ? '<1m' : `~${Math.round(b.durationMinutes)}m`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{beads.length === 0 && <div className="cost-empty">No task cost data yet</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trends' && (
|
||||
<div className="cost-trends-view">
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Cost Trend (last 60 min, 5-min buckets)</div>
|
||||
<MiniChart data={timeSeries} height={120} />
|
||||
<div className="cost-trend-summary">
|
||||
{timeSeries.length > 0 ? (
|
||||
<>
|
||||
<span>Latest: {formatCost(timeSeries[timeSeries.length - 1].cost)}/bucket</span>
|
||||
<span>Peak: {formatCost(Math.max(...timeSeries.map(d => d.cost)))}/bucket</span>
|
||||
<span>Buckets: {timeSeries.length}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>No trend data yet</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cost-card">
|
||||
<div className="cost-card-title">Cost History</div>
|
||||
<div className="cost-trend-list">
|
||||
{timeSeries.slice(-12).reverse().map((point, i) => (
|
||||
<div key={i} className="cost-trend-row">
|
||||
<span className="cost-trend-time">{formatTime(point.ts)}</span>
|
||||
<div className="cost-trend-bar-container">
|
||||
<div
|
||||
className="cost-trend-bar"
|
||||
style={{
|
||||
width: `${Math.min(100, (point.cost / (Math.max(...timeSeries.map(d => d.cost)) || 1)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="cost-trend-cost">{formatCost(point.cost)}</span>
|
||||
<span className="cost-trend-workers">{point.activeWorkers}w</span>
|
||||
</div>
|
||||
))}
|
||||
{timeSeries.length === 0 && <div className="cost-empty">No trend data yet</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CostDashboard;
|
||||
|
|
@ -307,3 +307,91 @@ export interface RecoveryStats {
|
|||
avgConfidence: number;
|
||||
topActionTypes: Array<{ type: RecoveryActionType; count: number }>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Fleet Analytics Types
|
||||
// ============================================
|
||||
|
||||
export interface DurationBucket {
|
||||
label: string;
|
||||
range: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ModelMetrics {
|
||||
model: string;
|
||||
beadsCompleted: number;
|
||||
avgDurationMs: number;
|
||||
medianDurationMs: number;
|
||||
minDurationMs: number;
|
||||
maxDurationMs: number;
|
||||
durationBuckets: DurationBucket[];
|
||||
shallowCount: number;
|
||||
shallowPercent: number;
|
||||
}
|
||||
|
||||
export interface StrandMetrics {
|
||||
strand: string;
|
||||
invocations: number;
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
successRate: number;
|
||||
totalDurationMs: number;
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
export interface ShallowCompletion {
|
||||
beadId: string;
|
||||
worker: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
session: string;
|
||||
}
|
||||
|
||||
export interface BeadCompletion {
|
||||
beadId: string;
|
||||
worker: string;
|
||||
model: string;
|
||||
durationMs: number;
|
||||
timestamp: number;
|
||||
session: string;
|
||||
isShallow: boolean;
|
||||
}
|
||||
|
||||
export interface FleetTimePoint {
|
||||
hour: string;
|
||||
activeWorkers: number;
|
||||
beadsCompleted: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
workspace: string;
|
||||
workerCount: number;
|
||||
beadCount: number;
|
||||
}
|
||||
|
||||
export interface ClaimRace {
|
||||
beadId: string;
|
||||
workers: string[];
|
||||
claimCount: number;
|
||||
}
|
||||
|
||||
export interface FleetAnalytics {
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
totalEvents: number;
|
||||
logFiles: string[];
|
||||
modelMetrics: ModelMetrics[];
|
||||
strandMetrics: StrandMetrics[];
|
||||
shallowCompletions: ShallowCompletion[];
|
||||
totalCompletions: number;
|
||||
shallowPercent: number;
|
||||
claimRaces: ClaimRace[];
|
||||
fleetTimeSeries: FleetTimePoint[];
|
||||
workerRelaunchCount: number;
|
||||
workspaceCoverage: WorkspaceEntry[];
|
||||
beadsPerHour: number;
|
||||
beadCompletions: BeadCompletion[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,6 +339,13 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
|
|||
this.lastSnapshotTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying CostTracker instance
|
||||
*/
|
||||
getCostTracker(): CostTracker {
|
||||
return this.costTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics summary as formatted string
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,7 @@ export default defineConfig({
|
|||
plugins: [react()],
|
||||
root: 'src/web/frontend',
|
||||
build: {
|
||||
outDir: '../../../dist/web',
|
||||
outDir: '../../../dist/web/public',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue