feat(bd-5ny): Add fleet analytics dashboard with model/strand/quality metrics

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) <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-20 07:19:53 -04:00
parent b9a7dcce92
commit 3f5ddb96e0
5 changed files with 2246 additions and 23 deletions

505
src/analytics.ts Normal file
View file

@ -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<string, unknown>;
}
interface ParsedEvent {
ts: number;
event: string;
level: string;
session: string;
worker: string;
model: string;
data: Record<string, unknown>;
}
/** 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-<model>-<id>, claude-anthropic-<model>-<id>
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, unknown>): 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<string, number[]>();
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<string, { invocations: number; successCount: number; failCount: number; durations: number[] }>();
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<string, { workers: Set<string>; 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<string, { workers: Set<string>; beads: Set<string> }>();
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<string, Set<string>>();
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,
};
}

View file

@ -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<number | null>(null);
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
@ -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<Set<string>>(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 (
<div className="app">
<header className="header">
@ -570,6 +597,14 @@ const App: React.FC = () => {
<span className="file-heatmap-icon">🔥</span>
<span className="file-heatmap-label">Heatmap</span>
</button>
<button
className={`analytics-toggle ${showAnalytics ? 'active' : ''}`}
onClick={() => setShowAnalytics(!showAnalytics)}
title={showAnalytics ? 'Hide fleet analytics' : 'Show fleet analytics'}
>
<span className="analytics-toggle-icon">📈</span>
<span className="analytics-toggle-label">Analytics</span>
</button>
<button
className="file-context-toggle"
onClick={() => setShowFileContext(!showFileContext)}
@ -687,6 +722,13 @@ const App: React.FC = () => {
/>
)}
{showAnalytics && (
<AnalyticsDashboard
visible={showAnalytics}
onClose={() => setShowAnalytics(false)}
/>
)}
{showDependencyDag && (
<DependencyDag
visible={showDependencyDag}

View file

@ -0,0 +1,509 @@
import React, { useState, useEffect, useCallback } from 'react';
// ============================================
// Types (mirror backend FleetAnalytics)
// ============================================
interface DurationBucket {
label: string;
range: string;
count: number;
}
interface ModelMetrics {
model: string;
beadsCompleted: number;
avgDurationMs: number;
medianDurationMs: number;
minDurationMs: number;
maxDurationMs: number;
durationBuckets: DurationBucket[];
shallowCount: number;
shallowPercent: number;
}
interface StrandMetrics {
strand: string;
invocations: number;
successCount: number;
failCount: number;
successRate: number;
totalDurationMs: number;
avgDurationMs: number;
}
interface ShallowCompletion {
beadId: string;
worker: string;
model: string;
durationMs: number;
timestamp: number;
session: string;
}
interface FleetTimePoint {
hour: string;
activeWorkers: number;
beadsCompleted: number;
timestamp: number;
}
interface WorkspaceEntry {
workspace: string;
workerCount: number;
beadCount: number;
}
interface ClaimRace {
beadId: string;
workers: string[];
claimCount: number;
}
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;
}
interface AnalyticsDashboardProps {
visible: boolean;
onClose: () => void;
}
// ============================================
// Utility Functions
// ============================================
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleString();
}
function formatHour(isoHour: string): string {
const d = new Date(isoHour);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:00`;
}
// ============================================
// Sparkline Component (pure CSS, no library)
// ============================================
const Sparkline: React.FC<{ values: number[]; width?: number; height?: number; color?: string; label?: string }> = ({
values,
width = 120,
height = 30,
color = 'var(--accent-color, #6366f1)',
label,
}) => {
if (values.length === 0) return <span className="sparkline-empty">no data</span>;
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const points = values.map((v, i) => {
const x = (i / (values.length - 1 || 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x},${y}`;
}).join(' ');
const areaPoints = `0,${height} ${points} ${width},${height}`;
return (
<span className="sparkline" title={label || `${values.length} points`}>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<polygon points={areaPoints} fill={color} opacity="0.15" />
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
</span>
);
};
// ============================================
// Duration Histogram Bar
// ============================================
const DurationHistogram: React.FC<{ buckets: DurationBucket[]; total: number }> = ({ buckets, total }) => {
const maxCount = Math.max(...buckets.map(b => b.count), 1);
return (
<div className="duration-histogram">
{buckets.map((b) => (
<div key={b.range} className="duration-bar-row">
<span className="duration-bar-label">{b.range}</span>
<div className="duration-bar-track">
<div
className="duration-bar-fill"
style={{ width: `${(b.count / maxCount) * 100}%` }}
title={`${b.count} beads (${total > 0 ? Math.round((b.count / total) * 100) : 0}%)`}
/>
</div>
<span className="duration-bar-count">{b.count}</span>
</div>
))}
</div>
);
};
// ============================================
// Section Component
// ============================================
const Section: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => (
<div className={`analytics-section ${className || ''}`}>
<h3 className="analytics-section-title">{title}</h3>
<div className="analytics-section-body">{children}</div>
</div>
);
// ============================================
// Main Component
// ============================================
const AnalyticsDashboard: React.FC<AnalyticsDashboardProps> = ({ visible, onClose }) => {
const [analytics, setAnalytics] = useState<FleetAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'models' | 'strands' | 'quality' | 'fleet'>('models');
const fetchAnalytics = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/analytics');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: FleetAnalytics = await res.json();
setAnalytics(data);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (visible) fetchAnalytics();
}, [visible, fetchAnalytics]);
if (!visible) return null;
return (
<div className="analytics-panel">
<div className="analytics-header">
<h3>
Fleet Analytics
{analytics && (
<span className="analytics-subtitle">
{analytics.totalEvents.toLocaleString()} events | {analytics.totalCompletions} beads | {analytics.logFiles.length} logs
</span>
)}
</h3>
<div className="analytics-header-actions">
<button className="analytics-refresh" onClick={fetchAnalytics} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
<button className="close-button" onClick={onClose}>x</button>
</div>
</div>
{error && <div className="analytics-error">{error}</div>}
{analytics && (
<>
{/* Summary Stats */}
<div className="analytics-summary">
<div className="analytics-stat">
<span className="analytics-stat-value">{analytics.beadsPerHour}</span>
<span className="analytics-stat-label">Beads/Hour</span>
</div>
<div className="analytics-stat">
<span className="analytics-stat-value">{analytics.totalCompletions}</span>
<span className="analytics-stat-label">Beads Done</span>
</div>
<div className="analytics-stat">
<span className="analytics-stat-value analytics-stat-warning">{analytics.shallowPercent}%</span>
<span className="analytics-stat-label">Shallow (&lt;10s)</span>
</div>
<div className="analytics-stat">
<span className="analytics-stat-value">{analytics.workerRelaunchCount}</span>
<span className="analytics-stat-label">Relaunches</span>
</div>
<div className="analytics-stat">
<span className="analytics-stat-value">{analytics.workspaceCoverage.length}</span>
<span className="analytics-stat-label">Workspaces</span>
</div>
<div className="analytics-stat">
<span className="analytics-stat-value">{analytics.claimRaces.length}</span>
<span className="analytics-stat-label">Claim Races</span>
</div>
</div>
{/* Tabs */}
<div className="analytics-tabs">
<button className={`analytics-tab ${activeTab === 'models' ? 'active' : ''}`} onClick={() => setActiveTab('models')}>
Models
</button>
<button className={`analytics-tab ${activeTab === 'strands' ? 'active' : ''}`} onClick={() => setActiveTab('strands')}>
Strands
</button>
<button className={`analytics-tab ${activeTab === 'quality' ? 'active' : ''}`} onClick={() => setActiveTab('quality')}>
Quality
</button>
<button className={`analytics-tab ${activeTab === 'fleet' ? 'active' : ''}`} onClick={() => setActiveTab('fleet')}>
Fleet
</button>
</div>
{/* Tab Content */}
<div className="analytics-content">
{activeTab === 'models' && (
<>
{analytics.modelMetrics.length === 0 ? (
<div className="analytics-empty">No bead completions found.</div>
) : (
analytics.modelMetrics.map(model => (
<Section key={model.model} title={model.model} className="analytics-model-section">
<div className="analytics-model-stats">
<div className="analytics-model-stat">
<span className="analytics-model-stat-value">{model.beadsCompleted}</span>
<span className="analytics-model-stat-label">Beads</span>
</div>
<div className="analytics-model-stat">
<span className="analytics-model-stat-value">{formatDuration(model.avgDurationMs)}</span>
<span className="analytics-model-stat-label">Avg Duration</span>
</div>
<div className="analytics-model-stat">
<span className="analytics-model-stat-value">{formatDuration(model.medianDurationMs)}</span>
<span className="analytics-model-stat-label">Median</span>
</div>
<div className="analytics-model-stat">
<span className="analytics-model-stat-value analytics-stat-warning">{model.shallowPercent}%</span>
<span className="analytics-model-stat-label">Shallow</span>
</div>
<div className="analytics-model-stat">
<span className="analytics-model-stat-value">{formatDuration(model.minDurationMs)}</span>
<span className="analytics-model-stat-label">Min</span>
</div>
<div className="analytics-model-stat">
<span className="analytics-model-stat-value">{formatDuration(model.maxDurationMs)}</span>
<span className="analytics-model-stat-label">Max</span>
</div>
</div>
<div className="analytics-histogram-container">
<span className="analytics-histogram-title">Duration Distribution</span>
<DurationHistogram buckets={model.durationBuckets} total={model.beadsCompleted} />
</div>
</Section>
))
)}
</>
)}
{activeTab === 'strands' && (
<>
{analytics.strandMetrics.length === 0 ? (
<div className="analytics-empty">No strand events found.</div>
) : (
<div className="analytics-strand-table-wrapper">
<table className="analytics-strand-table">
<thead>
<tr>
<th>Strand</th>
<th>Invocations</th>
<th>Success</th>
<th>Fail</th>
<th>Success Rate</th>
<th>Avg Duration</th>
<th>Total Time</th>
</tr>
</thead>
<tbody>
{analytics.strandMetrics.map(s => (
<tr key={s.strand}>
<td className="analytics-strand-name">{s.strand}</td>
<td>{s.invocations}</td>
<td>{s.successCount}</td>
<td>{s.failCount}</td>
<td>
<span className={`analytics-rate ${s.successRate >= 80 ? 'rate-good' : s.successRate >= 50 ? 'rate-warn' : 'rate-bad'}`}>
{s.successRate}%
</span>
</td>
<td>{s.avgDurationMs > 0 ? formatDuration(s.avgDurationMs) : '-'}</td>
<td>{s.totalDurationMs > 0 ? formatDuration(s.totalDurationMs) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
{activeTab === 'quality' && (
<>
{/* Shallow Completions */}
<Section title={`Suspicious Shallow Completions (${analytics.shallowCompletions.length})`} className="analytics-quality-section">
{analytics.shallowCompletions.length === 0 ? (
<div className="analytics-empty">No shallow completions detected.</div>
) : (
<>
<div className="analytics-shallow-summary">
{analytics.shallowPercent}% of all completions were under 10 seconds.
</div>
<div className="analytics-shallow-list">
{analytics.shallowCompletions.slice(0, 50).map(sc => (
<div key={`${sc.beadId}-${sc.worker}-${sc.timestamp}`} className="analytics-shallow-item">
<span className="analytics-shallow-bead">{sc.beadId}</span>
<span className="analytics-shallow-worker">{sc.worker}</span>
<span className="analytics-shallow-model">{sc.model}</span>
<span className="analytics-shallow-duration">{formatDuration(sc.durationMs)}</span>
<span className="analytics-shallow-time">{formatTime(sc.timestamp)}</span>
</div>
))}
{analytics.shallowCompletions.length > 50 && (
<div className="analytics-shallow-more">
... and {analytics.shallowCompletions.length - 50} more
</div>
)}
</div>
</>
)}
</Section>
{/* Claim Races */}
<Section title={`Claim Races (${analytics.claimRaces.length})`}>
{analytics.claimRaces.length === 0 ? (
<div className="analytics-empty">No claim races detected.</div>
) : (
<div className="analytics-shallow-list">
{analytics.claimRaces.slice(0, 30).map(cr => (
<div key={cr.beadId} className="analytics-shallow-item">
<span className="analytics-shallow-bead">{cr.beadId}</span>
<span className="analytics-shallow-workers">
{cr.workers.join(', ')}
</span>
<span className="analytics-shallow-claims">{cr.claimCount} claims</span>
</div>
))}
{analytics.claimRaces.length > 30 && (
<div className="analytics-shallow-more">
... and {analytics.claimRaces.length - 30} more
</div>
)}
</div>
)}
</Section>
</>
)}
{activeTab === 'fleet' && (
<>
{/* Fleet Time Series */}
<Section title="Worker Activity Over Time">
{analytics.fleetTimeSeries.length === 0 ? (
<div className="analytics-empty">No time series data.</div>
) : (
<>
<div className="analytics-fleet-sparklines">
<div className="analytics-sparkline-row">
<span className="analytics-sparkline-label">Active Workers</span>
<Sparkline
values={analytics.fleetTimeSeries.map(p => p.activeWorkers)}
color="var(--success-color, #22c55e)"
label="Active workers over time"
/>
</div>
<div className="analytics-sparkline-row">
<span className="analytics-sparkline-label">Beads Completed</span>
<Sparkline
values={analytics.fleetTimeSeries.map(p => p.beadsCompleted)}
color="var(--accent-color, #6366f1)"
label="Beads completed per hour"
/>
</div>
</div>
<div className="analytics-fleet-table-wrapper">
<table className="analytics-strand-table">
<thead>
<tr>
<th>Hour</th>
<th>Active Workers</th>
<th>Beads Completed</th>
</tr>
</thead>
<tbody>
{[...analytics.fleetTimeSeries].reverse().map(p => (
<tr key={p.hour}>
<td>{formatHour(p.hour)}</td>
<td>{p.activeWorkers}</td>
<td>{p.beadsCompleted}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Section>
{/* Workspace Coverage */}
<Section title={`Workspace Coverage (${analytics.workspaceCoverage.length} workspaces)`}>
{analytics.workspaceCoverage.length === 0 ? (
<div className="analytics-empty">No workspace data.</div>
) : (
<div className="analytics-shallow-list">
{analytics.workspaceCoverage.slice(0, 30).map(ws => (
<div key={ws.workspace} className="analytics-shallow-item">
<span className="analytics-shallow-bead">{ws.workspace}</span>
<span className="analytics-shallow-workers">{ws.workerCount} workers</span>
<span className="analytics-shallow-claims">{ws.beadCount} beads</span>
</div>
))}
{analytics.workspaceCoverage.length > 30 && (
<div className="analytics-shallow-more">
... and {analytics.workspaceCoverage.length - 30} more
</div>
)}
</div>
)}
</Section>
{/* Period Info */}
<Section title="Period Info">
<div className="analytics-period-info">
<div><strong>Start:</strong> {formatTime(analytics.periodStart)}</div>
<div><strong>End:</strong> {formatTime(analytics.periodEnd)}</div>
<div><strong>Duration:</strong> {formatDuration(analytics.periodEnd - analytics.periodStart)}</div>
<div><strong>Total Events:</strong> {analytics.totalEvents.toLocaleString()}</div>
<div><strong>Log Files:</strong> {analytics.logFiles.length}</div>
<div><strong>Worker Relaunches:</strong> {analytics.workerRelaunchCount}</div>
</div>
</Section>
</>
)}
</div>
</>
)}
</div>
);
};
export default AnalyticsDashboard;

File diff suppressed because it is too large Load diff

View file

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