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:
jedarden 2026-03-21 01:14:14 -04:00
parent 27d9278cf9
commit 46c51a79c3
10 changed files with 1358 additions and 28 deletions

View file

@ -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",

View 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();
});
});
});

View file

@ -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
*/

View file

@ -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

View file

@ -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;

View 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;

View file

@ -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[];
}

View file

@ -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

View file

@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
root: 'src/web/frontend',
build: {
outDir: '../../../dist/web',
outDir: '../../../dist/web/public',
emptyOutDir: true,
},
server: {