diff --git a/src/web/frontend/src/components/WorkerAnalyticsPanel.tsx b/src/web/frontend/src/components/WorkerAnalyticsPanel.tsx
new file mode 100644
index 0000000..01acdde
--- /dev/null
+++ b/src/web/frontend/src/components/WorkerAnalyticsPanel.tsx
@@ -0,0 +1,559 @@
+import React, { useState, useEffect, useCallback } from 'react';
+
+// ============================================
+// Types (mirror backend types)
+// ============================================
+
+interface WorkerMetrics {
+ workerId: string;
+ periodStart: number;
+ periodEnd: number;
+ beadsCompleted: number;
+ beadsPerHour: number;
+ avgCompletionTimeMs: number;
+ errorCount: number;
+ errorRate: number;
+ totalCostUsd: number;
+ costPerBead: number;
+ activeTimeMs: number;
+ idleTimeMs: number;
+ idlePercentage: number;
+ totalEvents: number;
+ totalTokens: number;
+ tokensPerBead: number;
+ efficiencyScore: number;
+}
+
+interface WorkerComparison {
+ worker1: WorkerMetrics;
+ worker2: WorkerMetrics;
+ differences: {
+ beadsCompleted: number;
+ beadsPerHour: number;
+ avgCompletionTimeMs: number;
+ errorRate: number;
+ costPerBead: number;
+ efficiencyScore: number;
+ };
+ percentDifferences: {
+ beadsCompleted: number;
+ beadsPerHour: number;
+ avgCompletionTimeMs: number;
+ errorRate: number;
+ costPerBead: number;
+ efficiencyScore: number;
+ };
+ betterWorker: {
+ beadsCompleted: 'worker1' | 'worker2' | 'tie';
+ beadsPerHour: 'worker1' | 'worker2' | 'tie';
+ avgCompletionTimeMs: 'worker1' | 'worker2' | 'tie';
+ errorRate: 'worker1' | 'worker2' | 'tie';
+ costPerBead: 'worker1' | 'worker2' | 'tie';
+ efficiencyScore: 'worker1' | 'worker2' | 'tie';
+ };
+ overallWinner: 'worker1' | 'worker2' | 'tie';
+ score: {
+ worker1: number;
+ worker2: number;
+ };
+}
+
+interface SessionRecord {
+ id: string;
+ started_at: number;
+ ended_at: number;
+ worker_count: number;
+ task_count: number;
+ total_cost: number;
+ total_tokens: number;
+ metrics_source?: string;
+}
+
+interface WorkerAnalyticsPanelProps {
+ 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 formatCost(usd: number): string {
+ if (usd < 0.01) return `$${(usd * 100).toFixed(2)}c`;
+ return `$${usd.toFixed(2)}`;
+}
+
+function formatPercent(value: number): string {
+ return `${(value * 100).toFixed(1)}%`;
+}
+
+function formatTimestamp(ts: number): string {
+ return new Date(ts).toLocaleString();
+}
+
+// ============================================
+// Comparison Row Component
+// ============================================
+
+const ComparisonRow: React.FC<{
+ label: string;
+ value1: string | number;
+ value2: string | number;
+ diff: number;
+ percentDiff: number;
+ better: 'worker1' | 'worker2' | 'tie';
+ lowerIsBetter: boolean;
+}> = ({ label, value1, value2, diff, percentDiff, better, lowerIsBetter }) => {
+ const v1Str = String(value1);
+ const v2Str = String(value2);
+
+ const formatDiffValue = (d: number, pd: number): { diff: string; percent: string; color: string } => {
+ if (Math.abs(d) < 0.001 && Math.abs(pd) < 0.1) {
+ return { diff: '0', percent: '0.0%', color: 'text-gray-500' };
+ }
+ const sign = d > 0 ? '+' : '';
+ const color = (lowerIsBetter ? d < 0 : d > 0) ? 'text-green-600' : 'text-red-600';
+ return {
+ diff: `${sign}${d.toFixed(2)}`,
+ percent: `${sign}${pd.toFixed(1)}%`,
+ color,
+ };
+ };
+
+ const { diff: diffStr, percent, color } = formatDiffValue(diff, percentDiff);
+ const winner = better === 'worker1' ? '←' : better === 'worker2' ? '→' : '=';
+
+ return (
+
+ | {label} |
+ {v1Str} |
+ {v2Str} |
+ {diffStr} |
+ {percent} |
+ {winner} |
+
+ );
+};
+
+// ============================================
+// Worker Leaderboard Table Component
+// ============================================
+
+const WorkerLeaderboard: React.FC<{ workers: WorkerMetrics[]; onSelectWorker: (workerId: string) => void }> = ({ workers, onSelectWorker }) => {
+ const [sortBy, setSortBy] = useState
('beadsCompleted');
+ const [sortAsc, setSortAsc] = useState(false);
+
+ const sortedWorkers = [...workers].sort((a, b) => {
+ const aVal = a[sortBy];
+ const bVal = b[sortBy];
+ const lowerIsBetter = ['errorRate', 'costPerBead', 'avgCompletionTimeMs', 'idlePercentage'].includes(sortBy);
+ const comparison = typeof aVal === 'number' && typeof bVal === 'number'
+ ? (lowerIsBetter ? aVal - bVal : bVal - aVal)
+ : String(aVal).localeCompare(String(bVal));
+ return sortAsc ? (comparison > 0 ? 1 : -1) : (comparison > 0 ? -1 : 1);
+ });
+
+ const handleSort = (key: keyof WorkerMetrics) => {
+ if (sortBy === key) {
+ setSortAsc(!sortAsc);
+ } else {
+ setSortBy(key);
+ setSortAsc(false);
+ }
+ };
+
+ const formatCellValue = (key: keyof WorkerMetrics, value: any): string => {
+ if (key === 'avgCompletionTimeMs') return formatDuration(value);
+ if (key === 'totalCostUsd' || key === 'costPerBead') return formatCost(value);
+ if (key === 'errorRate' || key === 'efficiencyScore' || key === 'idlePercentage') return formatPercent(value);
+ if (key === 'beadsPerHour') return value.toFixed(2);
+ if (key === 'tokensPerBead') return value.toFixed(0);
+ return String(value);
+ };
+
+ const columns: { key: keyof WorkerMetrics; label: string; className?: string }[] = [
+ { key: 'workerId', label: 'Worker' },
+ { key: 'beadsCompleted', label: 'Beads' },
+ { key: 'beadsPerHour', label: 'Beads/Hr' },
+ { key: 'avgCompletionTimeMs', label: 'Avg Time' },
+ { key: 'errorRate', label: 'Error Rate' },
+ { key: 'costPerBead', label: 'Cost/Bead' },
+ { key: 'efficiencyScore', label: 'Efficiency' },
+ ];
+
+ return (
+
+
+
+
+ {columns.map(col => (
+ | handleSort(col.key)}
+ className={sortBy === col.key ? `sorted ${sortAsc ? 'asc' : 'desc'}` : ''}
+ >
+ {col.label}
+ {sortBy === col.key && {sortAsc ? ' ↑' : ' ↓'}}
+ |
+ ))}
+
+
+
+ {sortedWorkers.map(worker => (
+ onSelectWorker(worker.workerId)}
+ className="worker-row"
+ >
+ {columns.map(col => (
+ |
+ {formatCellValue(col.key, worker[col.key])}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+};
+
+// ============================================
+// Historical Sessions List Component
+// ============================================
+
+const HistoricalSessions: React.FC<{ sessions: SessionRecord[] }> = ({ sessions }) => {
+ if (sessions.length === 0) {
+ return No historical sessions available
;
+ }
+
+ return (
+
+
+
+
+ | Session ID |
+ Started |
+ Duration |
+ Workers |
+ Tasks |
+ Cost |
+ Tokens |
+
+
+
+ {sessions.map(session => {
+ const duration = session.ended_at - session.started_at;
+ return (
+
+ | {session.id.slice(0, 20)}... |
+ {formatTimestamp(session.started_at)} |
+ {formatDuration(duration)} |
+ {session.worker_count} |
+ {session.task_count} |
+ {formatCost(session.total_cost)} |
+ {session.total_tokens.toLocaleString()} |
+
+ );
+ })}
+
+
+
+ );
+};
+
+// ============================================
+// Main Component
+// ============================================
+
+const WorkerAnalyticsPanel: React.FC = ({ visible, onClose }) => {
+ const [activeTab, setActiveTab] = useState<'leaderboard' | 'comparison' | 'sessions'>('leaderboard');
+ const [workers, setWorkers] = useState([]);
+ const [comparison, setComparison] = useState(null);
+ const [sessions, setSessions] = useState([]);
+ const [selectedWorker1, setSelectedWorker1] = useState('');
+ const [selectedWorker2, setSelectedWorker2] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchWorkers = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/analytics/workers?limit=50&sortBy=beadsCompleted');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setWorkers(data.workers || []);
+ // Auto-select first two workers for comparison
+ if (data.workers && data.workers.length >= 2 && !selectedWorker1) {
+ setSelectedWorker1(data.workers[0].workerId);
+ setSelectedWorker2(data.workers[1].workerId);
+ }
+ } catch (err) {
+ setError(String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedWorker1]);
+
+ const fetchComparison = useCallback(async () => {
+ if (!selectedWorker1 || !selectedWorker2) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/workers/compare?worker1=${encodeURIComponent(selectedWorker1)}&worker2=${encodeURIComponent(selectedWorker2)}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: WorkerComparison = await res.json();
+ setComparison(data);
+ } catch (err) {
+ setError(String(err));
+ setComparison(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedWorker1, selectedWorker2]);
+
+ const fetchSessions = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/analytics/sessions?limit=50');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ setSessions(data.sessions || []);
+ } catch (err) {
+ setError(String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Fetch data when tab changes
+ useEffect(() => {
+ if (!visible) return;
+ if (activeTab === 'leaderboard' && workers.length === 0) {
+ fetchWorkers();
+ } else if (activeTab === 'comparison') {
+ if (workers.length === 0) fetchWorkers();
+ else fetchComparison();
+ } else if (activeTab === 'sessions' && sessions.length === 0) {
+ fetchSessions();
+ }
+ }, [visible, activeTab, fetchWorkers, fetchComparison, fetchSessions, workers.length, sessions.length]);
+
+ // Handle worker selection in leaderboard
+ const handleSelectWorker = useCallback((workerId: string) => {
+ if (!selectedWorker1) {
+ setSelectedWorker1(workerId);
+ } else if (!selectedWorker2 && workerId !== selectedWorker1) {
+ setSelectedWorker2(workerId);
+ } else {
+ // Both selected, swap to comparison tab
+ if (workerId !== selectedWorker1) {
+ setSelectedWorker2(workerId);
+ }
+ setActiveTab('comparison');
+ }
+ }, [selectedWorker1, selectedWorker2]);
+
+ if (!visible) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Worker Analytics
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {loading &&
Loading...
}
+
+ {activeTab === 'leaderboard' && !loading && (
+
+ )}
+
+ {activeTab === 'comparison' && (
+
+
+
+
+
+
+
+
+
+
+
+
+ {comparison && !loading && (
+
+
Comparison Results
+
+
+
+
Worker 1
+
{comparison.worker1.workerId}
+
+
VS
+
+
Worker 2
+
{comparison.worker2.workerId}
+
+
+
+
+
+
+ | Metric |
+ Worker 1 |
+ Worker 2 |
+ Diff |
+ % |
+ Winner |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Score: Worker 1 won {comparison.score.worker1} metrics, Worker 2 won {comparison.score.worker2} metrics.
+
+ Overall: {comparison.overallWinner === 'worker1'
+ ? 'Worker 1 wins!'
+ : comparison.overallWinner === 'worker2'
+ ? 'Worker 2 wins!'
+ : 'It\'s a tie!'}
+
+
+
+ )}
+
+ {!comparison && !loading && selectedWorker1 && selectedWorker2 && (
+
+
Click "Compare" to see the comparison
+
+
+ )}
+
+ )}
+
+ {activeTab === 'sessions' && !loading && (
+
+ )}
+
+
+
+ );
+};
+
+export default WorkerAnalyticsPanel;
diff --git a/src/web/server.ts b/src/web/server.ts
index 2fa1f95..d747bf8 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1387,6 +1387,102 @@ export function createWebServer(options: WebServerOptions): WebServer {
}
});
+ // ============================================
+ // Worker Comparison Analytics API Endpoints
+ // ============================================
+
+ // Compare two workers side-by-side
+ app.get('/api/workers/compare', (req: Request, res: Response) => {
+ try {
+ const worker1 = req.query.worker1 as string;
+ const worker2 = req.query.worker2 as string;
+
+ if (!worker1 || !worker2) {
+ res.status(400).json({ error: 'Missing required parameters: worker1 and worker2' });
+ return;
+ }
+
+ const analytics = store.getWorkerAnalytics();
+ const comparison = analytics.compareWorkers(worker1, worker2);
+
+ if (!comparison) {
+ res.status(404).json({ error: 'One or both workers not found' });
+ return;
+ }
+
+ res.json(comparison);
+ } catch (error) {
+ console.error('Error comparing workers:', error);
+ res.status(500).json({
+ error: 'Failed to compare workers',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
+ // Get per-worker metrics for leaderboard table
+ app.get('/api/analytics/workers', (req: Request, res: Response) => {
+ try {
+ const analytics = store.getWorkerAnalytics();
+ const sortBy = req.query.sortBy as 'beadsCompleted' | 'beadsPerHour' | 'errorRate' | 'costPerBead' | 'efficiencyScore' | undefined;
+ const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined;
+
+ let workers = analytics.getAllWorkerMetrics();
+
+ // Apply sort if specified
+ if (sortBy) {
+ const lowerIsBetter = ['errorRate', 'costPerBead'].includes(sortBy);
+ workers.sort((a, b) => lowerIsBetter
+ ? (a[sortBy] as number) - (b[sortBy] as number)
+ : (b[sortBy] as number) - (a[sortBy] as number)
+ );
+ }
+
+ // Apply limit if specified
+ if (limit && limit > 0) {
+ workers = workers.slice(0, limit);
+ }
+
+ res.json({
+ workers,
+ count: workers.length,
+ });
+ } catch (error) {
+ console.error('Error fetching worker metrics:', error);
+ res.status(500).json({
+ error: 'Failed to fetch worker metrics',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
+ // Get historical sessions for cross-session comparisons
+ app.get('/api/analytics/sessions', (req: Request, res: Response) => {
+ try {
+ const historical = store.getHistoricalStore();
+ const startTime = req.query.start ? parseInt(req.query.start as string) : undefined;
+ const endTime = req.query.end ? parseInt(req.query.end as string) : undefined;
+ const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
+
+ const sessions = historical.getSessions({
+ startTime,
+ endTime,
+ limit,
+ });
+
+ res.json({
+ sessions,
+ count: sessions.length,
+ });
+ } catch (error) {
+ console.error('Error fetching historical sessions:', error);
+ res.status(500).json({
+ error: 'Failed to fetch historical sessions',
+ message: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ });
+
// Serve static frontend files
const staticPath = join(__dirname, 'public');
app.use(express.static(staticPath));