From 3f5ddb96e05f30838c567a6827e54a9060d875c4 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 20 Mar 2026 07:19:53 -0400 Subject: [PATCH] feat(bd-5ny): Add fleet analytics dashboard with model/strand/quality metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse NEEDLE worker log JSONL files to compute fleet-wide analytics: - Model performance: beads completed, avg/median duration, distribution histogram - Strand utilization: invocations, success rates, time spent per strand - Completion quality: shallow detection (<10s), claim races, flagged beads - Fleet overview: hourly time series with sparklines, workspace coverage, relaunch count Adds /api/analytics endpoint and AnalyticsDashboard React component with tabbed UI (Models/Strands/Quality/Fleet). No persistent DB needed — reads logs fresh on each request. Co-Authored-By: Claude Code (glm-5-turbo) --- src/analytics.ts | 505 ++++++++ src/web/frontend/src/App.tsx | 86 +- .../src/components/AnalyticsDashboard.tsx | 509 ++++++++ src/web/frontend/src/index.css | 1022 +++++++++++++++++ src/web/server.ts | 147 ++- 5 files changed, 2246 insertions(+), 23 deletions(-) create mode 100644 src/analytics.ts create mode 100644 src/web/frontend/src/components/AnalyticsDashboard.tsx diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..fa7cb7c --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,505 @@ +/** + * Fleet Analytics Aggregation + * + * Parses NEEDLE worker log files and computes metrics by model, strand, and completion quality. + * Designed to be called on each page load — no persistent state needed. + */ + +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; + +// ============================================ +// Types +// ============================================ + +export interface NeedleLogEntry { + ts: string; + event: string; + level?: string; + session: string; + worker: string; + data?: Record; +} + +interface ParsedEvent { + ts: number; + event: string; + level: string; + session: string; + worker: string; + model: string; + data: Record; +} + +/** Duration distribution bucket */ +export interface DurationBucket { + label: string; + range: string; // e.g., "<5s" + count: number; +} + +/** Metrics for a single model */ +export interface ModelMetrics { + model: string; + beadsCompleted: number; + avgDurationMs: number; + medianDurationMs: number; + minDurationMs: number; + maxDurationMs: number; + durationBuckets: DurationBucket[]; + shallowCount: number; // <10s + shallowPercent: number; +} + +/** Metrics for a single strand */ +export interface StrandMetrics { + strand: string; + invocations: number; + successCount: number; + failCount: number; + successRate: number; + totalDurationMs: number; + avgDurationMs: number; +} + +/** A suspicious shallow completion */ +export interface ShallowCompletion { + beadId: string; + worker: string; + model: string; + durationMs: number; + timestamp: number; + session: string; +} + +/** A bead completed event with metadata */ +export interface BeadCompletion { + beadId: string; + worker: string; + model: string; + durationMs: number; + timestamp: number; + session: string; + isShallow: boolean; +} + +/** Fleet worker time-series point */ +export interface FleetTimePoint { + hour: string; // ISO hour label + activeWorkers: number; + beadsCompleted: number; + timestamp: number; +} + +/** Workspace coverage entry */ +export interface WorkspaceEntry { + workspace: string; + workerCount: number; + beadCount: number; +} + +/** A bead claimed by multiple workers */ +export interface ClaimRace { + beadId: string; + workers: string[]; + claimCount: number; +} + +/** The full analytics response */ +export interface FleetAnalytics { + periodStart: number; + periodEnd: number; + totalEvents: number; + logFiles: string[]; + + // Model performance + modelMetrics: ModelMetrics[]; + + // Strand utilization + strandMetrics: StrandMetrics[]; + + // Completion quality + shallowCompletions: ShallowCompletion[]; + totalCompletions: number; + shallowPercent: number; + claimRaces: ClaimRace[]; + + // Fleet overview + fleetTimeSeries: FleetTimePoint[]; + workerRelaunchCount: number; + workspaceCoverage: WorkspaceEntry[]; + beadsPerHour: number; + + // Raw bead completions for deeper analysis + beadCompletions: BeadCompletion[]; +} + +// ============================================ +// Log Parsing +// ============================================ + +const NEEDLE_LOG_DIR = join(homedir(), '.needle', 'logs'); + +/** Extract model name from worker ID (e.g., "claude-code-glm-5-alpha" -> "glm-5") */ +function extractModel(workerId: string): string { + // Patterns: claude-code--, claude-anthropic-- + const match = workerId.match(/claude-(?:code|anthropic)-([a-z0-9.]+(?:-[a-z0-9.]+)?)-/); + if (match) return match[1]; + // Fallback: strip last segment if it looks like an identifier + const parts = workerId.split('-'); + if (parts.length > 2) return parts.slice(1, -1).join('-'); + return workerId; +} + +/** Extract workspace from a data object */ +function extractWorkspace(data: Record): string { + return (data.workspace as string) || ''; +} + +/** Parse a single JSONL line into a ParsedEvent */ +function parseLine(line: string): ParsedEvent | null { + try { + const entry: NeedleLogEntry = JSON.parse(line); + if (!entry.ts || !entry.event) return null; + + return { + ts: new Date(entry.ts).getTime(), + event: entry.event, + level: entry.level || 'info', + session: entry.session || '', + worker: entry.worker || '', + model: extractModel(entry.worker || ''), + data: entry.data || {}, + }; + } catch { + return null; + } +} + +/** Read all events from a single log file */ +function readLogFile(filePath: string): ParsedEvent[] { + if (!existsSync(filePath)) return []; + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + const events: ParsedEvent[] = []; + + for (const line of lines) { + const evt = parseLine(line); + if (evt) events.push(evt); + } + + return events; +} + +/** Read all NEEDLE log files and return combined events */ +function readAllLogs(): { events: ParsedEvent[]; files: string[] } { + if (!existsSync(NEEDLE_LOG_DIR)) return { events: [], files: [] }; + + const files = readdirSync(NEEDLE_LOG_DIR) + .filter(f => f.startsWith('needle-') && f.endsWith('.log')) + .map(f => join(NEEDLE_LOG_DIR, f)) + .filter(f => existsSync(f)); + + const allEvents: ParsedEvent[] = []; + for (const file of files) { + const events = readLogFile(file); + allEvents.push(...events); + } + + // Sort by timestamp + allEvents.sort((a, b) => a.ts - b.ts); + + return { events: allEvents, files: files.map(f => f.split('/').pop() || f) }; +} + +// ============================================ +// Metric Computation +// ============================================ + +const DURATION_BUCKETS: { label: string; range: string; maxMs: number }[] = [ + { label: '<5s', range: '<5s', maxMs: 5000 }, + { label: '5-30s', range: '5-30s', maxMs: 30000 }, + { label: '30s-2m', range: '30s-2m', maxMs: 120000 }, + { label: '2-10m', range: '2-10m', maxMs: 600000 }, + { label: '10m+', range: '10m+', maxMs: Infinity }, +]; + +function computeMedian(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function bucketDurations(durations: number[]): DurationBucket[] { + return DURATION_BUCKETS.map((bucket, i) => { + const minMs = i === 0 ? 0 : DURATION_BUCKETS[i - 1].maxMs; + const count = durations.filter(d => d >= minMs && d < bucket.maxMs).length; + return { label: bucket.label, range: bucket.range, count }; + }); +} + +/** Extract strand name from event (e.g., "explore.workspace_pluck" -> "explore") */ +function extractStrand(event: string): string { + const dotIndex = event.indexOf('.'); + return dotIndex > 0 ? event.substring(0, dotIndex) : event; +} + +/** Compute model metrics from bead.completed events */ +function computeModelMetrics(completions: BeadCompletion[]): ModelMetrics[] { + const byModel = new Map(); + + for (const c of completions) { + if (!byModel.has(c.model)) byModel.set(c.model, []); + byModel.get(c.model)!.push(c.durationMs); + } + + const metrics: ModelMetrics[] = []; + + for (const [model, durations] of byModel) { + const shallow = durations.filter(d => d < 10000); + const avg = durations.reduce((s, d) => s + d, 0) / durations.length; + const median = computeMedian(durations); + + metrics.push({ + model, + beadsCompleted: durations.length, + avgDurationMs: Math.round(avg), + medianDurationMs: Math.round(median), + minDurationMs: Math.min(...durations), + maxDurationMs: Math.max(...durations), + durationBuckets: bucketDurations(durations), + shallowCount: shallow.length, + shallowPercent: durations.length > 0 ? Math.round((shallow.length / durations.length) * 100) : 0, + }); + } + + return metrics.sort((a, b) => b.beadsCompleted - a.beadsCompleted); +} + +/** Compute strand metrics from strand.* events */ +function computeStrandMetrics(events: ParsedEvent[]): StrandMetrics[] { + const strandEvents = events.filter(e => + e.event.startsWith('explore.') || + e.event.startsWith('pulse.') || + e.event.startsWith('hook.') || + e.event.startsWith('weave.') || + e.event.startsWith('unravel.') || + e.event.startsWith('mend.') + ); + + const byStrand = new Map(); + + for (const evt of strandEvents) { + const strand = extractStrand(evt.event); + if (!byStrand.has(strand)) { + byStrand.set(strand, { invocations: 0, successCount: 0, failCount: 0, durations: [] }); + } + const s = byStrand.get(strand)!; + s.invocations++; + + // Determine success/fail from event suffix + if (evt.event.endsWith('_completed') || evt.event.endsWith('_success') || evt.event.endsWith('_pluck') || evt.event.endsWith('_switch') || evt.event.endsWith('_created')) { + s.successCount++; + } + if (evt.event.endsWith('_failed') || evt.event.endsWith('_error') || (evt.level === 'error' && evt.event.includes('failed'))) { + s.failCount++; + } + + const dur = evt.data.duration_ms as number | undefined; + if (typeof dur === 'number' && dur > 0) { + s.durations.push(dur); + } + } + + const metrics: StrandMetrics[] = []; + + for (const [strand, data] of byStrand) { + metrics.push({ + strand, + invocations: data.invocations, + successCount: data.successCount, + failCount: data.failCount, + successRate: data.invocations > 0 ? Math.round((data.successCount / data.invocations) * 100) : 0, + totalDurationMs: data.durations.reduce((s, d) => s + d, 0), + avgDurationMs: data.durations.length > 0 ? Math.round(data.durations.reduce((s, d) => s + d, 0) / data.durations.length) : 0, + }); + } + + return metrics.sort((a, b) => b.invocations - a.invocations); +} + +/** Compute fleet time series (hourly active workers + completions) */ +function computeFleetTimeSeries(events: ParsedEvent[]): FleetTimePoint[] { + const hourBuckets = new Map; completions: number; timestamp: number }>(); + + for (const evt of events) { + const date = new Date(evt.ts); + date.setMinutes(0, 0, 0); + const hourKey = date.toISOString(); + const timestamp = date.getTime(); + + if (!hourBuckets.has(hourKey)) { + hourBuckets.set(hourKey, { workers: new Set(), completions: 0, timestamp }); + } + const bucket = hourBuckets.get(hourKey)!; + bucket.workers.add(evt.worker); + + if (evt.event === 'bead.completed') { + bucket.completions++; + } + } + + const points: FleetTimePoint[] = []; + for (const [hour, data] of hourBuckets) { + points.push({ + hour, + activeWorkers: data.workers.size, + beadsCompleted: data.completions, + timestamp: data.timestamp, + }); + } + + return points.sort((a, b) => a.timestamp - b.timestamp); +} + +/** Compute workspace coverage */ +function computeWorkspaceCoverage(events: ParsedEvent[]): WorkspaceEntry[] { + const workspaces = new Map; beads: Set }>(); + + for (const evt of events) { + const ws = extractWorkspace(evt.data); + if (!ws) continue; + + if (!workspaces.has(ws)) { + workspaces.set(ws, { workers: new Set(), beads: new Set() }); + } + const entry = workspaces.get(ws)!; + entry.workers.add(evt.worker); + + const beadId = evt.data.bead_id as string | undefined; + if (beadId && evt.event === 'bead.completed') { + entry.beads.add(beadId); + } + } + + return Array.from(workspaces.entries()) + .map(([workspace, data]) => ({ + workspace, + workerCount: data.workers.size, + beadCount: data.beads.size, + })) + .sort((a, b) => b.beadCount - a.beadCount); +} + +/** Find claim races (beads claimed by multiple workers) */ +function computeClaimRaces(events: ParsedEvent[]): ClaimRace[] { + const beadClaimers = new Map>(); + + for (const evt of events) { + if (evt.event !== 'bead.claimed') continue; + const beadId = evt.data.bead_id as string | undefined; + if (!beadId) continue; + + if (!beadClaimers.has(beadId)) { + beadClaimers.set(beadId, new Set()); + } + beadClaimers.get(beadId)!.add(evt.worker); + } + + const races: ClaimRace[] = []; + for (const [beadId, workers] of beadClaimers) { + if (workers.size > 1) { + races.push({ + beadId, + workers: Array.from(workers), + claimCount: workers.size, + }); + } + } + + return races.sort((a, b) => b.claimCount - a.claimCount); +} + +// ============================================ +// Main Analytics Function +// ============================================ + +/** + * Compute full fleet analytics from NEEDLE log files. + * Reads all log files fresh on each call — no caching, no persistent DB. + */ +export function computeFleetAnalytics(): FleetAnalytics { + const { events, files } = readAllLogs(); + + if (events.length === 0) { + return { + periodStart: 0, + periodEnd: 0, + totalEvents: 0, + logFiles: [], + modelMetrics: [], + strandMetrics: [], + shallowCompletions: [], + totalCompletions: 0, + shallowPercent: 0, + claimRaces: [], + fleetTimeSeries: [], + workerRelaunchCount: 0, + workspaceCoverage: [], + beadsPerHour: 0, + beadCompletions: [], + }; + } + + const periodStart = events[0].ts; + const periodEnd = events[events.length - 1].ts; + + // Extract bead completions + const beadCompletions: BeadCompletion[] = []; + for (const evt of events) { + if (evt.event !== 'bead.completed') continue; + const durationMs = (evt.data.duration_ms as number) || 0; + const beadId = (evt.data.bead_id as string) || ''; + beadCompletions.push({ + beadId, + worker: evt.worker, + model: evt.model, + durationMs, + timestamp: evt.ts, + session: evt.session, + isShallow: durationMs > 0 && durationMs < 10000, + }); + } + + const shallowCompletions = beadCompletions.filter(c => c.isShallow); + const totalCompletions = beadCompletions.length; + + // Worker relaunch count (worker.started events minus unique workers) + const uniqueWorkers = new Set(events.map(e => e.worker)); + const workerStartedCount = events.filter(e => e.event === 'worker.started').length; + const workerRelaunchCount = Math.max(0, workerStartedCount - uniqueWorkers.size); + + // Beads per hour + const hoursSpan = Math.max((periodEnd - periodStart) / 3600000, 0.001); + const beadsPerHour = Math.round(totalCompletions / hoursSpan * 10) / 10; + + return { + periodStart, + periodEnd, + totalEvents: events.length, + logFiles: files, + modelMetrics: computeModelMetrics(beadCompletions), + strandMetrics: computeStrandMetrics(events), + shallowCompletions, + totalCompletions, + shallowPercent: totalCompletions > 0 ? Math.round((shallowCompletions.length / totalCompletions) * 100) : 0, + claimRaces: computeClaimRaces(events), + fleetTimeSeries: computeFleetTimeSeries(events), + workerRelaunchCount, + workspaceCoverage: computeWorkspaceCoverage(events), + beadsPerHour, + beadCompletions, + }; +} diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index 35baea2..eb8d684 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -8,9 +8,12 @@ import CollisionAlert from './components/CollisionAlert'; import FileHeatmap from './components/FileHeatmap'; import DependencyDag from './components/DependencyDag'; import RecoveryPanel from './components/RecoveryPanel'; +import CrossReferencePanel from './components/CrossReferencePanel'; import FileContextPanel from './components/FileContextPanel'; import TimelineView from './components/TimelineView'; import SessionReplay from './components/SessionReplay'; +import CostDashboard from './components/CostDashboard'; +import AnalyticsDashboard from './components/AnalyticsDashboard'; import { extractReplayFromUrl, ReplayExport } from './utils/replayExport'; import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets'; @@ -236,8 +239,10 @@ const App: React.FC = () => { const [showFileHeatmap, setShowFileHeatmap] = useState(false); const [showDependencyDag, setShowDependencyDag] = useState(false); const [showRecoveryPanel, setShowRecoveryPanel] = useState(false); + const [showCrossReference, setShowCrossReference] = useState(false); const [showFileContext, setShowFileContext] = useState(false); const [showTimeline, setShowTimeline] = useState(true); + const [showAnalytics, setShowAnalytics] = useState(false); const [selectedTimelineTime, setSelectedTimelineTime] = useState(null); const [recoverySuggestions, setRecoverySuggestions] = useState([]); @@ -261,6 +266,28 @@ const App: React.FC = () => { } }, []); + // Fetch recovery suggestions from API + useEffect(() => { + const fetchRecoverySuggestions = async () => { + try { + const response = await fetch('/api/recovery/suggestions'); + if (response.ok) { + const suggestions = await response.json(); + setRecoverySuggestions(suggestions); + } + } catch (err) { + console.error('Failed to fetch recovery suggestions:', err); + } + }; + + // Fetch immediately + fetchRecoverySuggestions(); + + // Poll every 30 seconds for updates + const interval = setInterval(fetchRecoverySuggestions, 30000); + return () => clearInterval(interval); + }, []); + // Focus Mode state const [focusModeEnabled, setFocusModeEnabled] = useState(false); const [pinnedWorkers, setPinnedWorkers] = useState>(new Set()); @@ -355,28 +382,6 @@ const App: React.FC = () => { // Use the auto-reconnect hook const { reconnectState, resetAndReconnect } = useWebSocketReconnect(handleWebSocketMessage); - const filteredEvents = selectedWorker - ? filteredEventsByFocusMode.filter(e => e.worker === selectedWorker) - : filteredEventsByFocusMode; - - const selectedWorkerInfo = selectedWorker - ? filteredWorkers.find(w => w.id === selectedWorker) - : null; - - const handleAcknowledgeAlert = useCallback((alertId: string) => { - setCollisionAlerts(prev => - prev.map(a => a.id === alertId ? { ...a, acknowledged: true } : a) - ); - }, []); - - const handleAcknowledgeAllAlerts = useCallback(() => { - setCollisionAlerts(prev => - prev.map(a => ({ ...a, acknowledged: true })) - ); - }, []); - - const unacknowledgedAlertCount = collisionAlerts.filter(a => !a.acknowledged).length; - // Focus Mode callbacks const toggleFocusMode = useCallback(() => { setFocusModeEnabled(prev => !prev); @@ -450,6 +455,28 @@ const App: React.FC = () => { }) : events; + const filteredEvents = selectedWorker + ? filteredEventsByFocusMode.filter(e => e.worker === selectedWorker) + : filteredEventsByFocusMode; + + const selectedWorkerInfo = selectedWorker + ? filteredWorkers.find(w => w.id === selectedWorker) + : null; + + const handleAcknowledgeAlert = useCallback((alertId: string) => { + setCollisionAlerts(prev => + prev.map(a => a.id === alertId ? { ...a, acknowledged: true } : a) + ); + }, []); + + const handleAcknowledgeAllAlerts = useCallback(() => { + setCollisionAlerts(prev => + prev.map(a => ({ ...a, acknowledged: true })) + ); + }, []); + + const unacknowledgedAlertCount = collisionAlerts.filter(a => !a.acknowledged).length; + return (
@@ -570,6 +597,14 @@ const App: React.FC = () => { 🔥 Heatmap + + +
+ + + {error &&
{error}
} + + {analytics && ( + <> + {/* Summary Stats */} +
+
+ {analytics.beadsPerHour} + Beads/Hour +
+
+ {analytics.totalCompletions} + Beads Done +
+
+ {analytics.shallowPercent}% + Shallow (<10s) +
+
+ {analytics.workerRelaunchCount} + Relaunches +
+
+ {analytics.workspaceCoverage.length} + Workspaces +
+
+ {analytics.claimRaces.length} + Claim Races +
+
+ + {/* Tabs */} +
+ + + + +
+ + {/* Tab Content */} +
+ {activeTab === 'models' && ( + <> + {analytics.modelMetrics.length === 0 ? ( +
No bead completions found.
+ ) : ( + analytics.modelMetrics.map(model => ( +
+
+
+ {model.beadsCompleted} + Beads +
+
+ {formatDuration(model.avgDurationMs)} + Avg Duration +
+
+ {formatDuration(model.medianDurationMs)} + Median +
+
+ {model.shallowPercent}% + Shallow +
+
+ {formatDuration(model.minDurationMs)} + Min +
+
+ {formatDuration(model.maxDurationMs)} + Max +
+
+
+ Duration Distribution + +
+
+ )) + )} + + )} + + {activeTab === 'strands' && ( + <> + {analytics.strandMetrics.length === 0 ? ( +
No strand events found.
+ ) : ( +
+ + + + + + + + + + + + + + {analytics.strandMetrics.map(s => ( + + + + + + + + + + ))} + +
StrandInvocationsSuccessFailSuccess RateAvg DurationTotal Time
{s.strand}{s.invocations}{s.successCount}{s.failCount} + = 80 ? 'rate-good' : s.successRate >= 50 ? 'rate-warn' : 'rate-bad'}`}> + {s.successRate}% + + {s.avgDurationMs > 0 ? formatDuration(s.avgDurationMs) : '-'}{s.totalDurationMs > 0 ? formatDuration(s.totalDurationMs) : '-'}
+
+ )} + + )} + + {activeTab === 'quality' && ( + <> + {/* Shallow Completions */} +
+ {analytics.shallowCompletions.length === 0 ? ( +
No shallow completions detected.
+ ) : ( + <> +
+ {analytics.shallowPercent}% of all completions were under 10 seconds. +
+
+ {analytics.shallowCompletions.slice(0, 50).map(sc => ( +
+ {sc.beadId} + {sc.worker} + {sc.model} + {formatDuration(sc.durationMs)} + {formatTime(sc.timestamp)} +
+ ))} + {analytics.shallowCompletions.length > 50 && ( +
+ ... and {analytics.shallowCompletions.length - 50} more +
+ )} +
+ + )} +
+ + {/* Claim Races */} +
+ {analytics.claimRaces.length === 0 ? ( +
No claim races detected.
+ ) : ( +
+ {analytics.claimRaces.slice(0, 30).map(cr => ( +
+ {cr.beadId} + + {cr.workers.join(', ')} + + {cr.claimCount} claims +
+ ))} + {analytics.claimRaces.length > 30 && ( +
+ ... and {analytics.claimRaces.length - 30} more +
+ )} +
+ )} +
+ + )} + + {activeTab === 'fleet' && ( + <> + {/* Fleet Time Series */} +
+ {analytics.fleetTimeSeries.length === 0 ? ( +
No time series data.
+ ) : ( + <> +
+
+ Active Workers + p.activeWorkers)} + color="var(--success-color, #22c55e)" + label="Active workers over time" + /> +
+
+ Beads Completed + p.beadsCompleted)} + color="var(--accent-color, #6366f1)" + label="Beads completed per hour" + /> +
+
+
+ + + + + + + + + + {[...analytics.fleetTimeSeries].reverse().map(p => ( + + + + + + ))} + +
HourActive WorkersBeads Completed
{formatHour(p.hour)}{p.activeWorkers}{p.beadsCompleted}
+
+ + )} +
+ + {/* Workspace Coverage */} +
+ {analytics.workspaceCoverage.length === 0 ? ( +
No workspace data.
+ ) : ( +
+ {analytics.workspaceCoverage.slice(0, 30).map(ws => ( +
+ {ws.workspace} + {ws.workerCount} workers + {ws.beadCount} beads +
+ ))} + {analytics.workspaceCoverage.length > 30 && ( +
+ ... and {analytics.workspaceCoverage.length - 30} more +
+ )} +
+ )} +
+ + {/* Period Info */} +
+
+
Start: {formatTime(analytics.periodStart)}
+
End: {formatTime(analytics.periodEnd)}
+
Duration: {formatDuration(analytics.periodEnd - analytics.periodStart)}
+
Total Events: {analytics.totalEvents.toLocaleString()}
+
Log Files: {analytics.logFiles.length}
+
Worker Relaunches: {analytics.workerRelaunchCount}
+
+
+ + )} +
+ + )} + + ); +}; + +export default AnalyticsDashboard; diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 2464574..14db48c 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -3817,3 +3817,1025 @@ body { border-color: var(--accent); color: var(--accent); } + +/* ── Main layout responsive ── */ + +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + } + + .header-actions { + flex-wrap: wrap; + gap: 0.375rem; + justify-content: center; + } + + .header-actions .dag-toggle-label, + .header-actions .recovery-toggle-label, + .header-actions .file-heatmap-toggle-label, + .header-actions .file-context-toggle-label, + .header-actions .timeline-toggle-label, + .header-actions .session-replay-toggle-label, + .header-actions .focus-mode-label, + .header-actions .theme-toggle-label, + .header-actions .preset-label { + display: none; + } + + .main-content { + grid-template-columns: 1fr; + } + + .worker-grid { + border-right: none; + border-bottom: 1px solid var(--bg-tertiary); + max-height: 40vh; + padding: 0.5rem; + } + + .worker-card { + padding: 0.5rem; + } + + .event-item { + padding: 0.375rem 0.5rem; + } + + .event-time { + font-size: 0.65rem; + min-width: 5rem; + } + + .event-worker { + font-size: 0.65rem; + } + + .event-message { + font-size: 0.7rem; + } +} + +@media (max-width: 480px) { + .header h1 { + font-size: 1rem; + } + + .header-actions { + gap: 0.25rem; + } + + .header-actions button { + padding: 0.25rem 0.375rem; + } + + .worker-card { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .worker-card-header { + width: 100%; + } + + .event-time { + display: none; + } +} + +/* ============================================ + Cost Dashboard Styles + ============================================ */ + +.cost-dashboard-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s ease-out; +} + +.cost-dashboard { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + width: 90vw; + max-width: 800px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px var(--shadow-color); +} + +.cost-dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color); +} + +.cost-dashboard-header h3 { + font-size: 1rem; + font-weight: 600; +} + +.cost-dashboard-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.cost-alert-badge { + background: var(--error); + color: white; + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-weight: 600; +} + +.cost-dashboard-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + padding: 0 1rem; +} + +.cost-tab { + background: none; + border: none; + color: var(--text-secondary); + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.85rem; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +.cost-tab:hover { + color: var(--text-primary); +} + +.cost-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.cost-dashboard-content { + padding: 1rem 1.25rem; + overflow-y: auto; + flex: 1; +} + +.cost-loading { + text-align: center; + color: var(--text-secondary); + padding: 2rem; +} + +.cost-overview { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.cost-card { + background: var(--bg-tertiary); + border-radius: 8px; + padding: 1rem; + border: 1px solid var(--border-color); +} + +.cost-card-title { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.5rem; +} + +.cost-card-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); +} + +.cost-card-value.cost-high { + color: var(--warning); +} + +.cost-card-subtitle { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.cost-progress-container { + margin-top: 0.75rem; +} + +.cost-progress-label { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 0.35rem; +} + +.cost-progress-bar { + height: 8px; + background: var(--bg-primary); + border-radius: 4px; + overflow: hidden; +} + +.cost-progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease, background-color 0.3s ease; +} + +.cost-exhaustion { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--info); +} + +.cost-projected { + margin-top: 0.25rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.cost-alerts-card { + grid-column: 1 / -1; +} + +.cost-alert-item { + background: var(--bg-primary); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.5rem; + border-left: 3px solid; +} + +.cost-alert-warning { + border-left-color: var(--warning); +} + +.cost-alert-critical, +.cost-alert-exhausted { + border-left-color: var(--error); +} + +.cost-alert-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.25rem; +} + +.cost-alert-type { + font-weight: 700; + font-size: 0.8rem; + text-transform: uppercase; +} + +.cost-alert-warning .cost-alert-type { + color: var(--warning); +} + +.cost-alert-critical .cost-alert-type, +.cost-alert-exhausted .cost-alert-type { + color: var(--error); +} + +.cost-alert-time { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.cost-alert-details { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.cost-alert-ack { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; +} + +.cost-alert-ack:hover { + background: var(--hover-bg); +} + +.cost-worker-row { + display: flex; + align-items: center; + padding: 0.3rem 0; + font-size: 0.85rem; + gap: 0.5rem; +} + +.cost-worker-id { + flex: 1; + font-weight: 500; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cost-worker-cost { + font-weight: 600; + color: var(--warning); + min-width: 60px; + text-align: right; +} + +.cost-worker-tokens { + color: var(--text-secondary); + font-size: 0.75rem; + min-width: 60px; + text-align: right; +} + +.cost-empty { + color: var(--text-secondary); + font-size: 0.85rem; + text-align: center; + padding: 1rem; +} + +/* Cost Table Styles */ +.cost-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.cost-table th { + text-align: left; + padding: 0.5rem; + color: var(--text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-color); +} + +.cost-table td { + padding: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.cost-table tr:hover td { + background: var(--hover-bg); +} + +.cost-number { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.cost-worker-id-cell, +.cost-bead-id-cell { + font-family: monospace; + font-size: 0.8rem; +} + +.cost-bead-cell { + font-size: 0.8rem; + color: var(--info); +} + +/* Trends */ +.cost-trends-view { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mini-chart { + width: 100%; + height: 120px; +} + +.mini-chart-empty { + height: 120px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 0.85rem; + border: 1px dashed var(--border-color); + border-radius: 4px; +} + +.cost-trend-summary { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.5rem; +} + +.cost-trend-list { + max-height: 300px; + overflow-y: auto; +} + +.cost-trend-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: 0.8rem; +} + +.cost-trend-time { + min-width: 50px; + color: var(--text-secondary); +} + +.cost-trend-bar-container { + flex: 1; + height: 6px; + background: var(--bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.cost-trend-bar { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s ease; +} + +.cost-trend-cost { + min-width: 60px; + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.cost-trend-workers { + min-width: 25px; + text-align: right; + color: var(--text-secondary); + font-size: 0.75rem; +} + +/* Cost dashboard header button */ +.header-actions .cost-toggle { + background: none; + border: 1px solid var(--border-color); + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border-radius: 6px; + font-size: 0.8rem; +} + +.header-actions .cost-toggle:hover { + background: var(--hover-bg); + border-color: var(--warning); +} + +.header-actions .cost-toggle .cost-toggle-icon { + font-size: 1rem; +} + +@media (max-width: 768px) { + .cost-overview { + grid-template-columns: 1fr; + } + + .cost-dashboard { + width: 95vw; + max-height: 90vh; + } + + .cost-trend-summary { + flex-direction: column; + gap: 0.25rem; + } +} + +/* ============================================ + Analytics Dashboard + ============================================ */ + +.analytics-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 1rem; + max-height: 80vh; + overflow-y: auto; + width: 900px; + margin: 0.5rem auto; + box-shadow: 0 4px 20px var(--shadow-color); +} + +.analytics-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.analytics-header h3 { + margin: 0; + font-size: 1.1rem; + color: var(--text-primary); +} + +.analytics-subtitle { + font-size: 0.75rem; + color: var(--text-secondary); + margin-left: 0.75rem; + font-weight: normal; +} + +.analytics-header-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.analytics-refresh { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 0.3rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.analytics-refresh:hover { + background: var(--hover-bg); +} + +.analytics-refresh:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.analytics-error { + background: rgba(244, 67, 54, 0.15); + color: var(--error); + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +/* Summary Stats Row */ +.analytics-summary { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.analytics-stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 80px; +} + +.analytics-stat-value { + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); +} + +.analytics-stat-label { + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.analytics-stat-warning { + color: var(--warning); +} + +/* Tabs */ +.analytics-tabs { + display: flex; + gap: 0.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0; +} + +.analytics-tab { + background: none; + color: var(--text-secondary); + border: none; + border-bottom: 2px solid transparent; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.85rem; + transition: color 0.15s, border-color 0.15s; +} + +.analytics-tab:hover { + color: var(--text-primary); +} + +.analytics-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* Content Area */ +.analytics-content { + min-height: 200px; +} + +.analytics-empty { + color: var(--text-secondary); + text-align: center; + padding: 2rem; + font-style: italic; +} + +/* Sections */ +.analytics-section { + margin-bottom: 1.25rem; +} + +.analytics-section-title { + font-size: 0.9rem; + color: var(--text-primary); + margin: 0 0 0.5rem 0; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--border-color); +} + +.analytics-section-body { + padding: 0.25rem 0; +} + +/* Model Section */ +.analytics-model-stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.analytics-model-stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 70px; +} + +.analytics-model-stat-value { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.analytics-model-stat-label { + font-size: 0.65rem; + color: var(--text-secondary); + text-transform: uppercase; +} + +.analytics-model-section { + background: var(--bg-tertiary); + border-radius: 6px; + padding: 0.75rem; +} + +/* Duration Histogram */ +.analytics-histogram-container { + margin-top: 0.5rem; +} + +.analytics-histogram-title { + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: 0.3rem; + display: block; +} + +.duration-histogram { + display: flex; + flex-direction: column; + gap: 2px; +} + +.duration-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.duration-bar-label { + font-size: 0.7rem; + color: var(--text-secondary); + width: 50px; + text-align: right; + font-family: monospace; +} + +.duration-bar-track { + flex: 1; + height: 14px; + background: var(--bg-primary); + border-radius: 2px; + overflow: hidden; +} + +.duration-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + min-width: 2px; + transition: width 0.3s ease; +} + +.duration-bar-count { + font-size: 0.7rem; + color: var(--text-primary); + width: 30px; + font-family: monospace; +} + +/* Strand Table */ +.analytics-strand-table-wrapper { + overflow-x: auto; +} + +.analytics-strand-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.analytics-strand-table th { + text-align: left; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.analytics-strand-table td { + padding: 0.35rem 0.6rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); +} + +.analytics-strand-name { + font-weight: 600; + font-family: monospace; +} + +.analytics-rate { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-weight: 600; + font-size: 0.75rem; +} + +.rate-good { + background: rgba(0, 200, 83, 0.15); + color: var(--success); +} + +.rate-warn { + background: rgba(255, 193, 7, 0.15); + color: var(--warning); +} + +.rate-bad { + background: rgba(244, 67, 54, 0.15); + color: var(--error); +} + +/* Quality Section */ +.analytics-quality-section { + background: var(--bg-tertiary); + border-radius: 6px; + padding: 0.75rem; +} + +.analytics-shallow-summary { + font-size: 0.8rem; + color: var(--warning); + margin-bottom: 0.5rem; + padding: 0.3rem 0.5rem; + background: rgba(255, 193, 7, 0.08); + border-radius: 4px; +} + +.analytics-shallow-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.analytics-shallow-item { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 3px; + font-size: 0.75rem; + font-family: monospace; +} + +.analytics-shallow-item:hover { + background: var(--hover-bg); +} + +.analytics-shallow-bead { + color: var(--accent); + min-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-shallow-worker { + color: var(--text-secondary); + min-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-shallow-model { + color: var(--info); + min-width: 80px; +} + +.analytics-shallow-duration { + color: var(--warning); + font-weight: 600; + min-width: 50px; +} + +.analytics-shallow-time { + color: var(--text-secondary); + font-size: 0.7rem; + margin-left: auto; +} + +.analytics-shallow-workers { + color: var(--text-secondary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-shallow-claims { + color: var(--warning); + font-weight: 600; + min-width: 60px; + text-align: right; +} + +.analytics-shallow-more { + color: var(--text-secondary); + font-size: 0.75rem; + text-align: center; + padding: 0.5rem; + font-style: italic; +} + +/* Fleet Section */ +.analytics-fleet-sparklines { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.analytics-sparkline-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.analytics-sparkline-label { + font-size: 0.75rem; + color: var(--text-secondary); + min-width: 100px; + text-align: right; +} + +.sparkline-empty { + color: var(--text-secondary); + font-size: 0.75rem; + font-style: italic; +} + +.analytics-fleet-table-wrapper { + overflow-x: auto; + max-height: 300px; + overflow-y: auto; +} + +.analytics-period-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.analytics-period-info strong { + color: var(--text-primary); +} + +/* Analytics toggle button */ +.analytics-toggle { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 0.3rem 0.6rem; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + transition: all 0.15s; +} + +.analytics-toggle:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.analytics-toggle.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +/* Responsive */ +@media (max-width: 1000px) { + .analytics-panel { + width: 95vw; + max-height: 85vh; + } + + .analytics-summary { + gap: 0.5rem; + } + + .analytics-stat { + min-width: 60px; + } + + .analytics-model-stats { + gap: 0.5rem; + } + + .analytics-shallow-item { + font-size: 0.65rem; + gap: 0.3rem; + } +} diff --git a/src/web/server.ts b/src/web/server.ts index 63f1f77..fa5118f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -14,6 +14,7 @@ import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelation import { InMemoryEventStore } from '../store.js'; import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js'; import { parseEventObject } from '../parser.js'; +import { computeFleetAnalytics } from '../analytics.js'; /** Maximum payload size for POST requests (64KB) */ const MAX_PAYLOAD_SIZE = 64 * 1024; @@ -344,6 +345,29 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); + // ============================================ + // Recovery API Endpoints + // ============================================ + + // Get all recovery suggestions + app.get('/api/recovery/suggestions', (_req: Request, res: Response) => { + const suggestions = store.getRecoverySuggestions(); + res.json(suggestions); + }); + + // Get recovery statistics + app.get('/api/recovery/stats', (_req: Request, res: Response) => { + const stats = store.getRecoveryStats(); + res.json(stats); + }); + + // Get recovery suggestions for a specific worker + app.get('/api/recovery/workers/:id', (req: Request, res: Response) => { + const workerId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const suggestions = store.getWorkerRecoverySuggestions(workerId); + res.json(suggestions); + }); + // ============================================ // Cross-Reference API Endpoints // ============================================ @@ -432,8 +456,129 @@ export function createWebServer(options: WebServerOptions): WebServer { res.json(path); }); + // ============================================ + // Cost & Budget API Endpoints + // ============================================ + + // Get cost summary + app.get('/api/cost/summary', (_req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const summary = costTracker.getSummary(); + + res.json({ + totalCostUsd: summary.totalCostUsd, + totalTokens: summary.total, + inputTokens: summary.total.input, + outputTokens: summary.total.output, + budget: summary.budget, + burnRate: summary.burnRate, + timeRange: summary.timeRange, + workerCount: summary.byWorker.size, + }); + }); + + // Get burn rate details + app.get('/api/cost/burn-rate', (req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const sinceMinutes = parseInt(req.query.since as string) || 60; + const history = costTracker.getBurnRateHistory(sinceMinutes); + + res.json({ + current: costTracker.getSummary().burnRate, + history, + }); + }); + + // Get per-worker cost breakdown + app.get('/api/cost/workers', (_req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const summary = costTracker.getSummary(); + const workers = Array.from(summary.byWorker.values()) + .sort((a, b) => b.costUsd - a.costUsd) + .map(w => ({ + workerId: w.workerId, + costUsd: w.costUsd, + inputTokens: w.input, + outputTokens: w.output, + totalTokens: w.total, + apiCalls: w.apiCalls, + currentBead: w.currentBead, + lastActivityTs: w.lastActivityTs, + })); + + res.json({ + workers, + totalCostUsd: summary.totalCostUsd, + }); + }); + + // Get per-bead cost breakdown + app.get('/api/cost/beads', (_req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const beads = costTracker.getBeadCosts() + .map(b => ({ + beadId: b.beadId, + costUsd: b.costUsd, + inputTokens: b.input, + outputTokens: b.output, + apiCalls: b.apiCalls, + workerCount: b.workers.size, + workers: Array.from(b.workers), + durationMinutes: b.durationMinutes, + firstTs: b.firstTs, + lastTs: b.lastTs, + })); + + res.json({ beads }); + }); + + // Get cost time-series for trend charts + app.get('/api/cost/history', (req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const sinceMinutes = parseInt(req.query.since as string) || 60; + const bucketMinutes = parseInt(req.query.bucket as string) || 5; + + const timeSeries = costTracker.getAggregatedTimeSeries(sinceMinutes, bucketMinutes); + + res.json({ + timeSeries, + sinceMinutes, + bucketMinutes, + }); + }); + + // Get budget alerts + app.get('/api/cost/alerts', (_req: Request, res: Response) => { + const costTracker = store.getCostTracker(); + const alerts = costTracker.getAlerts(); + const allAlerts = costTracker.getAllAlerts(); + + res.json({ + active: alerts, + all: allAlerts, + }); + }); + + // Acknowledge a budget alert + app.post('/api/cost/alerts/:id/acknowledge', (req: Request, res: Response) => { + const alertId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const costTracker = store.getCostTracker(); + costTracker.acknowledgeAlert(alertId); + res.json({ success: true }); + }); + + // Fleet analytics — reads log files fresh on each request + app.get('/api/analytics', (_req: Request, res: Response) => { + try { + const analytics = computeFleetAnalytics(); + res.json(analytics); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + // Serve static frontend files - const staticPath = join(__dirname, '..', 'web'); + const staticPath = join(__dirname, 'public'); app.use(express.static(staticPath)); // Fallback to index.html for SPA routing