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:
parent
b9a7dcce92
commit
3f5ddb96e0
5 changed files with 2246 additions and 23 deletions
505
src/analytics.ts
Normal file
505
src/analytics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
509
src/web/frontend/src/components/AnalyticsDashboard.tsx
Normal file
509
src/web/frontend/src/components/AnalyticsDashboard.tsx
Normal 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 (<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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue