feat(web): add ErrorGroupPanel with grouped error cards and similar past errors

Port TUI ErrorGroupPanel to React — groups errors by signature with
occurrence count, affected workers, time span, severity badges, and
expandable detail cards. Links to similar past errors from fabric.db
error_history via /api/errors/history/similar endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-24 06:16:46 -04:00
parent 755669e73a
commit 9938630bdd
12 changed files with 1621 additions and 87 deletions

View file

@ -25,6 +25,12 @@ const MIN_STRENGTH = 0.1;
/** Maximum links to store */
const MAX_LINKS = 5000;
/** Maximum entities to track (prevents unbounded growth from per-event entries) */
const MAX_ENTITIES = 2000;
/** Maximum entries in the event index */
const MAX_EVENT_INDEX = 5000;
/**
* Generate a unique ID for a cross-reference link
*/
@ -65,17 +71,16 @@ interface InternalEntity {
export class CrossReferenceManager {
private links: Map<string, CrossReferenceLink> = new Map();
private entities: Map<string, InternalEntity> = new Map();
private eventIndex: Map<string, string[]> = new Map();
private workerIndex: Map<string, string[]> = new Map();
private fileIndex: Map<string, string[]> = new Map();
private beadIndex: Map<string, string[]> = new Map();
/**
* Process a log event and extract cross-references
* Process a log event and extract cross-references.
* Per-event entities and links are skipped only durable entities
* (worker, file, bead) and cross-entity relationships are tracked.
*/
processEvent(event: LogEvent): void {
this.registerEntity('event', this.getEventId(event), event.ts, this.getEventLabel(event));
if (event.worker) {
this.registerEntity('worker', event.worker, event.ts, `Worker ${event.worker.slice(0, 8)}`);
}
@ -88,23 +93,6 @@ export class CrossReferenceManager {
if (event.bead) {
this.registerEntity('bead', event.bead, event.ts, `Task ${event.bead}`);
}
if (event.worker) {
this.createLink('event', this.getEventId(event), 'worker', event.worker, 'same_worker', 1.0, event.ts);
}
if (event.path) {
this.createLink('event', this.getEventId(event), 'file', event.path, 'same_file', 1.0, event.ts);
}
if (event.bead) {
this.createLink('event', this.getEventId(event), 'bead', event.bead, 'same_bead', 1.0, event.ts);
}
const eventId = this.getEventId(event);
if (!this.eventIndex.has(eventId)) {
this.eventIndex.set(eventId, []);
}
}
/**
@ -130,7 +118,8 @@ export class CrossReferenceManager {
}
/**
* Register an entity in the tracking system
* Register an entity in the tracking system.
* Event-type entities are skipped to avoid unbounded per-event growth.
*/
private registerEntity(
type: CrossReferenceEntityType,
@ -138,6 +127,9 @@ export class CrossReferenceManager {
timestamp: number,
label: string
): void {
// Skip event-type entities — they're transient and create one entry per event
if (type === 'event') return;
const entityId = generateEntityId(type, id);
const existing = this.entities.get(entityId);
@ -145,6 +137,9 @@ export class CrossReferenceManager {
existing.lastSeen = timestamp;
existing.occurrenceCount++;
} else {
if (this.entities.size >= MAX_ENTITIES) {
this.trimEntities();
}
this.entities.set(entityId, {
type,
id,
@ -257,7 +252,6 @@ export class CrossReferenceManager {
*/
private getIndexMap(type: CrossReferenceEntityType): Map<string, string[]> | null {
switch (type) {
case 'event': return this.eventIndex;
case 'worker': return this.workerIndex;
case 'file': return this.fileIndex;
case 'bead': return this.beadIndex;
@ -693,11 +687,23 @@ export class CrossReferenceManager {
this.rebuildIndices();
}
/**
* Trim oldest-seen entities when over the entity cap.
*/
private trimEntities(): void {
const sorted = Array.from(this.entities.entries())
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
const toRemove = sorted.slice(0, Math.floor(MAX_ENTITIES * 0.2));
for (const [entityId] of toRemove) {
this.entities.delete(entityId);
}
}
/**
* Rebuild all indices from current links
*/
private rebuildIndices(): void {
this.eventIndex.clear();
this.workerIndex.clear();
this.fileIndex.clear();
this.beadIndex.clear();
@ -714,7 +720,6 @@ export class CrossReferenceManager {
clear(): void {
this.links.clear();
this.entities.clear();
this.eventIndex.clear();
this.workerIndex.clear();
this.fileIndex.clear();
this.beadIndex.clear();

View file

@ -200,6 +200,9 @@ const DEFAULT_OPTIONS: Required<ErrorGroupingOptions> = {
maxGroups: 100,
};
/** Maximum events retained per error group. */
const MAX_EVENTS_PER_GROUP = 50;
/**
* Categorize an error message
*/
@ -336,9 +339,11 @@ export class ErrorGroupManager {
const existingGroupId = this.hashToGroup.get(fingerprint.hash);
if (existingGroupId) {
// Add to existing group
// Add to existing group (cap events to prevent unbounded growth)
const group = this.groups.get(existingGroupId)!;
group.events.push(event);
if (group.events.length < MAX_EVENTS_PER_GROUP) {
group.events.push(event);
}
group.count++;
group.lastSeen = event.ts;

View file

@ -33,6 +33,9 @@ const DEFAULT_OPTIONS: Required<NarrativeOptions> = {
minEventsPerSegment: 1,
};
/** Maximum events retained per worker narrative context. */
const MAX_CONTEXT_EVENTS = 500;
/**
* Internal tracking for narrative generation
*/
@ -72,8 +75,10 @@ export class SemanticNarrativeGenerator implements SemanticNarrativeManager {
this.contexts.set(event.worker, context);
}
// Add event to context
context.events.push(event);
// Add event to context (bounded to prevent unbounded growth)
if (context.events.length < MAX_CONTEXT_EVENTS) {
context.events.push(event);
}
context.lastEventTime = event.ts;
// Track entities

View file

@ -85,6 +85,33 @@ interface FileModificationTracker {
timestamps: number[];
}
/** Max events stored in collision records before trimming. */
const MAX_COLLISION_EVENTS = 50;
/** Max timestamps retained per file in the heatmap tracker. */
const MAX_FILE_TIMESTAMPS = 200;
/** Max age (ms) for taskStartTimes entries before considering them abandoned. */
const TASK_START_MAX_AGE_MS = 86_400_000; // 24 hours
/** Max age (ms) for inactive collision entries before deletion from the map. */
const STALE_COLLISION_MAX_AGE_MS = 300_000; // 5 minutes
/** Max age (ms) for inactive fileModification entries before deletion. */
const STALE_FILE_MOD_MAX_AGE_MS = 3_600_000; // 1 hour
/** Max files retained per worker in activeFiles/activeDirectories arrays. */
const MAX_WORKER_ACTIVE_FILES = 200;
/** How many events to trim at once (batch trim amortises O(n) splice cost). */
const TRIM_BATCH_SIZE = 100;
/** Max events buffered before batch processing flushes immediately. */
const MAX_BATCH_BUFFER_SIZE = 500;
/** Max age (ms) for inactive workers before pruning from the workers map. */
const STALE_WORKER_MAX_AGE_MS = 3_600_000; // 1 hour
export class InMemoryEventStore implements EventStore {
private events: LogEvent[] = [];
private sequenceIndex: Map<string, LogEvent> = new Map(); // key: `${worker}:${sequence}`
@ -105,6 +132,8 @@ export class InMemoryEventStore implements EventStore {
private batchTimeout: NodeJS.Timeout | null = null;
private sessionStartTime: number = 0;
private taskStartTimes: Map<string, number> = new Map(); // beadId -> startTime
/** Index of file-path → last modification timestamp — used by detectCollision for O(1) lookups. */
private recentFileMods: Map<string, { workerId: string; ts: number }[]> = new Map();
constructor(maxEvents: number = 10000) {
this.maxEvents = maxEvents;
@ -158,12 +187,25 @@ export class InMemoryEventStore implements EventStore {
this.semanticNarrativeManager.processEvent(event);
// Add to batch buffer for relationship detection
this.batchBuffer.push(event);
if (this.batchBuffer.length < MAX_BATCH_BUFFER_SIZE) {
this.batchBuffer.push(event);
}
this.scheduleBatchProcessing();
// Trim if over limit
if (this.events.length > this.maxEvents) {
this.events.shift();
// Trim in batches when over limit (amortises O(n) splice cost)
if (this.events.length > this.maxEvents + TRIM_BATCH_SIZE) {
const removed = this.events.splice(0, this.events.length - this.maxEvents);
// Prune sequenceIndex entries for the evicted events
for (const ev of removed) {
if (ev.sequence != null && ev.sequence >= 0) {
this.sequenceIndex.delete(`${ev.worker}:${ev.sequence}`);
}
}
}
// Periodic cleanup of stale secondary structures (every 10k events)
if (this.events.length % 10_000 === 0) {
this.cleanupStaleSecondaryData();
}
}
@ -177,11 +219,12 @@ export class InMemoryEventStore implements EventStore {
this.batchTimeout = setTimeout(() => {
if (this.batchBuffer.length > 0) {
this.crossReferenceManager.processBatch([...this.batchBuffer]);
const batch = this.batchBuffer;
this.batchBuffer = [];
this.crossReferenceManager.processBatch(batch);
}
this.batchTimeout = null;
}, 1000); // Process batch every 1 second
}, 1000);
}
/**
@ -255,6 +298,7 @@ export class InMemoryEventStore implements EventStore {
this.beadCollisions.clear();
this.taskCollisions.clear();
this.fileModifications.clear();
this.recentFileMods.clear();
this.errorGroupManager.clear();
this.crossReferenceManager.clear();
this.batchBuffer = [];
@ -320,6 +364,72 @@ export class InMemoryEventStore implements EventStore {
}
}
/**
* Periodic cleanup of secondary data structures that can grow stale.
*/
private cleanupStaleSecondaryData(): void {
const now = Date.now();
// Clean up abandoned task start times
for (const [beadId, startTime] of this.taskStartTimes) {
if (now - startTime > TASK_START_MAX_AGE_MS) {
this.taskStartTimes.delete(beadId);
}
}
// Clean up recentFileMods entries that are past the collision window
for (const [path, mods] of this.recentFileMods) {
const cutoff = now - COLLISION_WINDOW_MS;
while (mods.length > 0 && mods[0].ts < cutoff) {
mods.shift();
}
if (mods.length === 0) {
this.recentFileMods.delete(path);
}
}
// Delete inactive collisions past their retention window
const staleCollisionCutoff = now - STALE_COLLISION_MAX_AGE_MS;
for (const [key, c] of this.collisions) {
if (!c.isActive && c.detectedAt < staleCollisionCutoff) {
this.collisions.delete(key);
}
}
for (const [key, c] of this.beadCollisions) {
if (!c.isActive && c.detectedAt < staleCollisionCutoff) {
this.beadCollisions.delete(key);
}
}
for (const [key, c] of this.taskCollisions) {
if (!c.isActive && c.detectedAt < staleCollisionCutoff) {
this.taskCollisions.delete(key);
}
}
// Delete stale fileModification trackers
const staleFileModCutoff = now - STALE_FILE_MOD_MAX_AGE_MS;
for (const [path, tracker] of this.fileModifications) {
if (tracker.lastModified < staleFileModCutoff) {
this.fileModifications.delete(path);
} else {
// Prune per-worker entries for workers no longer in the active set
for (const wId of tracker.workerModifications.keys()) {
if (!this.workers.has(wId)) {
tracker.workerModifications.delete(wId);
}
}
}
}
// Prune workers with no recent activity
const staleWorkerCutoff = now - STALE_WORKER_MAX_AGE_MS;
for (const [workerId, worker] of this.workers) {
if (worker.status !== 'active' && worker.lastActivity < staleWorkerCutoff) {
this.workers.delete(workerId);
}
}
}
/**
* Persist current session to historical store
*/
@ -453,6 +563,11 @@ export class InMemoryEventStore implements EventStore {
return this.errorGroupManager.getStats();
}
/** Expose historical store for error history queries */
get historical(): HistoricalStore {
return this.historicalStore;
}
/**
* Get event count
*/
@ -494,15 +609,21 @@ export class InMemoryEventStore implements EventStore {
worker.activeBead = event.bead;
}
// Track active files
// Track active files (bounded to prevent unbounded growth)
if (event.path && this.isFileModification(event)) {
if (!worker.activeFiles.includes(event.path)) {
worker.activeFiles.push(event.path);
if (worker.activeFiles.length > MAX_WORKER_ACTIVE_FILES) {
worker.activeFiles = worker.activeFiles.slice(-MAX_WORKER_ACTIVE_FILES);
}
}
// Track directory
const directory = event.path.substring(0, event.path.lastIndexOf('/')) || '/';
if (!worker.activeDirectories.includes(directory)) {
worker.activeDirectories.push(directory);
if (worker.activeDirectories.length > MAX_WORKER_ACTIVE_FILES) {
worker.activeDirectories = worker.activeDirectories.slice(-MAX_WORKER_ACTIVE_FILES);
}
}
}
@ -532,6 +653,7 @@ export class InMemoryEventStore implements EventStore {
}
if (needleEvent === 'bead.completed') {
worker.activeFiles = [];
worker.activeDirectories = [];
worker.activeBead = undefined;
}
} else if (
@ -547,16 +669,23 @@ export class InMemoryEventStore implements EventStore {
// Update last event
worker.lastEvent = event;
// Update collision status (check all collision types)
const hasFileCollision = this.getWorkerCollisions(worker.id).length > 0;
const hasBeadCollision = this.getWorkerBeadCollisions(worker.id).length > 0;
const hasTaskCollision = this.getWorkerTaskCollisions(worker.id).length > 0;
worker.hasCollision = hasFileCollision || hasBeadCollision || hasTaskCollision;
// Run gap-based stuck detection (throttled — only every 100 events per worker)
if (worker.eventCount % 100 === 0) {
const stuckPattern = isWorkerStuck(worker, this.events);
worker.stuck = stuckPattern != null;
worker.stuckReason = stuckPattern?.reason ?? undefined;
}
// Run gap-based stuck detection
const stuckPattern = isWorkerStuck(worker, this.events);
worker.stuck = stuckPattern != null;
worker.stuckReason = stuckPattern?.reason ?? undefined;
// Update collision status (throttled — only when a new collision is detected
// or every 500 events per worker to avoid O(collisions × workers) per event)
if (this.collisions.has(event.path || '') ||
(event.bead && this.beadCollisions.has(`bead:${event.bead}`)) ||
worker.eventCount % 500 === 0) {
const hasFileCollision = this.getWorkerCollisions(worker.id).length > 0;
const hasBeadCollision = this.getWorkerBeadCollisions(worker.id).length > 0;
const hasTaskCollision = this.getWorkerTaskCollisions(worker.id).length > 0;
worker.hasCollision = hasFileCollision || hasBeadCollision || hasTaskCollision;
}
}
/**
@ -631,7 +760,8 @@ export class InMemoryEventStore implements EventStore {
}
/**
* Detect collision when a file modification event occurs
* Detect collision when a file modification event occurs.
* Uses recentFileMods index for O(k) lookups instead of scanning all events.
*/
private detectCollision(event: LogEvent): void {
if (!event.path || !this.isFileModification(event)) {
@ -641,49 +771,54 @@ export class InMemoryEventStore implements EventStore {
const path = event.path;
const workerId = event.worker;
// Look for other workers modifying the same file within the time window
const recentEvents = this.events.filter(e => {
if (e.path !== path) return false;
if (e.worker === workerId) return false;
if (!this.isFileModification(e)) return false;
if (Math.abs(e.ts - event.ts) > COLLISION_WINDOW_MS) return false;
return true;
});
// Maintain the per-file recent modifications index
let mods = this.recentFileMods.get(path);
if (!mods) {
mods = [];
this.recentFileMods.set(path, mods);
}
mods.push({ workerId, ts: event.ts });
if (recentEvents.length > 0) {
// Collision detected!
const collisionKey = path;
const workers = new Set<string>([workerId]);
const collisionEvents: LogEvent[] = [event];
// Trim index entries older than the collision window
const cutoff = event.ts - COLLISION_WINDOW_MS;
while (mods.length > 0 && mods[0].ts < cutoff) {
mods.shift();
}
for (const e of recentEvents) {
workers.add(e.worker);
collisionEvents.push(e);
// Check for collisions: other workers modifying same file within window
const otherWorkers = new Set<string>();
for (const m of mods) {
if (m.workerId !== workerId) {
otherWorkers.add(m.workerId);
}
}
if (otherWorkers.size > 0) {
const collisionKey = path;
const workers = new Set<string>([workerId, ...otherWorkers]);
// Update or create collision record
const existing = this.collisions.get(collisionKey);
if (existing) {
// Add new worker if not already tracked
for (const w of workers) {
if (!existing.workers.includes(w)) {
existing.workers.push(w);
}
}
existing.events.push(event);
if (existing.events.length < MAX_COLLISION_EVENTS) {
existing.events.push(event);
}
existing.detectedAt = event.ts;
} else {
const collision: FileCollision = {
path,
workers: Array.from(workers),
detectedAt: event.ts,
events: collisionEvents,
events: [event],
isActive: true,
};
this.collisions.set(collisionKey, collision);
}
// Update collision status for all involved workers
for (const w of workers) {
const workerInfo = this.workers.get(w);
if (workerInfo) {
@ -750,7 +885,9 @@ export class InMemoryEventStore implements EventStore {
// Update modification count
tracker.modifications++;
tracker.lastModified = event.ts;
tracker.timestamps.push(event.ts);
if (tracker.timestamps.length < MAX_FILE_TIMESTAMPS) {
tracker.timestamps.push(event.ts);
}
// Track worker contribution
const workerMods = tracker.workerModifications.get(workerId);
@ -986,7 +1123,8 @@ export class InMemoryEventStore implements EventStore {
// ============================================
/**
* Detect bead collision when multiple workers work on the same bead
* Detect bead collision when multiple workers work on the same bead.
* Uses worker activeBead tracking for O(1) lookup instead of scanning all events.
*/
private detectBeadCollision(event: LogEvent): void {
if (!event.bead) return;
@ -994,26 +1132,20 @@ export class InMemoryEventStore implements EventStore {
const beadId = event.bead;
const workerId = event.worker;
// Look for other workers working on the same bead
const recentEvents = this.events.filter(e => {
if (e.bead !== beadId) return false;
if (e.worker === workerId) return false;
if (Math.abs(e.ts - event.ts) > BEAD_COLLISION_WINDOW_MS) return false;
return true;
});
// Check if any other worker is currently assigned to this bead
const otherWorkersOnBead: string[] = [];
for (const [wId, worker] of this.workers) {
if (wId !== workerId && worker.activeBead === beadId) {
otherWorkersOnBead.push(wId);
}
}
if (recentEvents.length > 0) {
if (otherWorkersOnBead.length > 0) {
// Bead collision detected!
const collisionKey = `bead:${beadId}`;
const workers = new Set<string>([workerId]);
const collisionEvents: LogEvent[] = [event];
for (const e of recentEvents) {
workers.add(e.worker);
collisionEvents.push(e);
}
// Determine severity based on tool usage
const allTools = collisionEvents.map(e => e.tool).filter(Boolean);
const hasWriteTools = allTools.some(t => FILE_MODIFICATION_TOOLS.includes(t || ''));
const severity: 'warning' | 'critical' = hasWriteTools ? 'critical' : 'warning';
@ -1026,7 +1158,9 @@ export class InMemoryEventStore implements EventStore {
existing.workers.push(w);
}
}
existing.events.push(event);
if (existing.events.length < MAX_COLLISION_EVENTS) {
existing.events.push(event);
}
existing.detectedAt = event.ts;
existing.severity = severity;
} else {

View file

@ -14,6 +14,8 @@ import TimelineView from './components/TimelineView';
import SessionReplay from './components/SessionReplay';
import CostDashboard from './components/CostDashboard';
import AnalyticsDashboard from './components/AnalyticsDashboard';
import ErrorGroupPanel from './components/ErrorGroupPanel';
import BudgetAlertPanel, { BudgetBanner } from './components/BudgetAlertPanel';
import CommandPalette from './components/CommandPalette';
import { extractReplayFromUrl, ReplayExport } from './utils/replayExport';
import { FocusPresetManager, createWebPresetManager, FocusPreset } from './utils/focusPresets';
@ -247,6 +249,15 @@ const App: React.FC = () => {
const [showAnalytics, setShowAnalytics] = useState(false);
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [showCostDashboard, setShowCostDashboard] = useState(false);
const [showErrorGroups, setShowErrorGroups] = useState(false);
const [showBudgetAlert, setShowBudgetAlert] = useState(false);
const [budgetBannerDismissed, setBudgetBannerDismissed] = useState(false);
// Budget alert state polled from /api/cost/summary
const [budgetSummary, setBudgetSummary] = useState<{
budget: { limit: number; spent: number; percentUsed: number; isOverBudget: boolean; warningLevel: 'none' | 'warning' | 'critical'; remaining: number };
burnRate: { costPerMinute: number; minutesToExhaustion: number | null; timeToExhaustion: string | null; projectedTotalCost: number; windowMinutes: number; isHighBurnRate: boolean };
} | null>(null);
const [selectedTimelineTime, setSelectedTimelineTime] = useState<number | null>(null);
const [recoverySuggestions, setRecoverySuggestions] = useState<RecoverySuggestion[]>([]);
@ -270,6 +281,24 @@ const App: React.FC = () => {
}
}, []);
// Poll budget status for banner alerts
useEffect(() => {
const pollBudget = async () => {
try {
const res = await fetch('/api/cost/summary');
if (res.ok) {
const data = await res.json();
setBudgetSummary({ budget: data.budget, burnRate: data.burnRate });
}
} catch {
// ignore
}
};
pollBudget();
const interval = setInterval(pollBudget, 15000);
return () => clearInterval(interval);
}, []);
// Fetch recovery suggestions from API
useEffect(() => {
const fetchRecoverySuggestions = async () => {
@ -493,6 +522,10 @@ const App: React.FC = () => {
setShowCrossReference(true);
} else if (action === 'show:cost') {
setShowCostDashboard(true);
} else if (action === 'show:errors') {
setShowErrorGroups(true);
} else if (action === 'show:budget') {
setShowBudgetAlert(true);
} else if (action.startsWith('worker:')) {
const workerId = action.slice('worker:'.length);
setSelectedWorker(workerId);
@ -561,6 +594,14 @@ const App: React.FC = () => {
return (
<div className="app">
{budgetSummary && !budgetBannerDismissed && budgetSummary.budget.warningLevel !== 'none' && (
<BudgetBanner
budget={budgetSummary.budget}
burnRate={budgetSummary.burnRate}
onOpenPanel={() => setShowBudgetAlert(true)}
onDismiss={() => setBudgetBannerDismissed(true)}
/>
)}
<header className="header">
<h1>FABRIC</h1>
<div className="header-actions">
@ -679,6 +720,14 @@ const App: React.FC = () => {
<span className="recovery-toggle-icon">💊</span>
<span className="recovery-toggle-label">Recovery</span>
</button>
<button
className={`error-group-toggle ${showErrorGroups ? 'active' : ''}`}
onClick={() => setShowErrorGroups(!showErrorGroups)}
title="View error groups"
>
<span className="error-group-toggle-icon">🐛</span>
<span className="error-group-toggle-label">Errors</span>
</button>
<button
className="file-heatmap-toggle"
onClick={() => setShowFileHeatmap(!showFileHeatmap)}
@ -719,6 +768,17 @@ const App: React.FC = () => {
<span className="session-replay-toggle-icon">📼</span>
<span className="session-replay-toggle-label">Replay</span>
</button>
<button
className={`budget-toggle ${showBudgetAlert ? 'active' : ''}`}
onClick={() => setShowBudgetAlert(!showBudgetAlert)}
title="Budget alerts"
>
<span className="budget-toggle-icon">%</span>
<span className="budget-toggle-label">Budget</span>
{budgetSummary && budgetSummary.budget.warningLevel !== 'none' && (
<span className="budget-alert-badge">!</span>
)}
</button>
{unacknowledgedAlertCount > 0 && (
<button
className="collision-alert-toggle"
@ -853,6 +913,20 @@ const App: React.FC = () => {
/>
)}
{showBudgetAlert && (
<BudgetAlertPanel
visible={showBudgetAlert}
onClose={() => setShowBudgetAlert(false)}
/>
)}
{showErrorGroups && (
<ErrorGroupPanel
visible={showErrorGroups}
onClose={() => setShowErrorGroups(false)}
/>
)}
{showSessionReplay && (
<div className="session-replay-panel">
<div className="session-replay-header">

View file

@ -35,6 +35,8 @@ const DEFAULT_COMMANDS: CommandSuggestion[] = [
{ id: 'show-replay', label: 'Show session replay', category: 'Commands', action: 'show:replay', icon: '📼' },
{ id: 'show-cross-ref', label: 'Show cross-reference panel', category: 'Commands', action: 'show:crossref', icon: '🔀' },
{ id: 'show-cost', label: 'Show cost dashboard', category: 'Commands', action: 'show:cost', icon: '💰' },
{ id: 'show-budget', label: 'Show budget alerts', category: 'Commands', action: 'show:budget', icon: '%' },
{ id: 'show-errors', label: 'Show error groups', category: 'Commands', action: 'show:errors', icon: '🐛' },
// Focus mode
{ id: 'focus-toggle', label: 'Toggle focus mode', category: 'Commands', action: 'focus:toggle', icon: '📌' },
{ id: 'focus-clear', label: 'Clear pinned items', category: 'Commands', action: 'focus:clear', icon: '📍' },

View file

@ -0,0 +1,347 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
ErrorGroupCard,
ErrorGroupStats,
ErrorCategory,
SimilarError,
} from '../types';
interface ErrorGroupPanelProps {
visible: boolean;
onClose: () => void;
}
type SeverityLevel = 'critical' | 'high' | 'medium' | 'low';
const SEVERITY_ORDER: SeverityLevel[] = ['critical', 'high', 'medium', 'low'];
const SEVERITY_LABELS: Record<SeverityLevel, { icon: string; label: string }> = {
critical: { icon: '!!!', label: 'CRITICAL' },
high: { icon: '!!', label: 'HIGH' },
medium: { icon: '!', label: 'MEDIUM' },
low: { icon: 'i', label: 'LOW' },
};
const CATEGORY_ICONS: Record<ErrorCategory, string> = {
network: '⚡',
permission: '🔒',
validation: '✗',
resource: '💾',
not_found: '?',
timeout: '⏱',
syntax: '⚠',
tool: '🔧',
unknown: '•',
};
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
}
function formatTimeSpan(firstSeen: number, lastSeen: number): string {
const diff = lastSeen - firstSeen;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
}
const ErrorGroupPanel: React.FC<ErrorGroupPanelProps> = ({ visible, onClose }) => {
const [groups, setGroups] = useState<ErrorGroupCard[]>([]);
const [stats, setStats] = useState<ErrorGroupStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeOnly, setActiveOnly] = useState(false);
const [similarErrors, setSimilarErrors] = useState<SimilarError[]>([]);
const [loadingSimilar, setLoadingSimilar] = useState(false);
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams({ activeOnly: String(activeOnly) });
const [groupsRes, statsRes] = await Promise.all([
fetch(`/api/errors/groups?${params}`),
fetch('/api/errors/stats'),
]);
if (!groupsRes.ok || !statsRes.ok) {
throw new Error('Failed to fetch error group data');
}
setGroups(await groupsRes.json());
setStats(await statsRes.json());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [activeOnly]);
useEffect(() => {
if (visible) {
fetchGroups();
const interval = setInterval(fetchGroups, 10000);
return () => clearInterval(interval);
}
}, [visible, fetchGroups]);
const fetchSimilar = useCallback(async (message: string) => {
try {
setLoadingSimilar(true);
const res = await fetch(`/api/errors/history/similar?message=${encodeURIComponent(message)}&limit=5`);
if (res.ok) {
setSimilarErrors(await res.json());
}
} catch {
setSimilarErrors([]);
} finally {
setLoadingSimilar(false);
}
}, []);
const toggleExpand = useCallback((groupId: string, group: ErrorGroupCard) => {
if (expandedId === groupId) {
setExpandedId(null);
setSimilarErrors([]);
} else {
setExpandedId(groupId);
fetchSimilar(group.fingerprint.sampleMessage);
}
}, [expandedId, fetchSimilar]);
if (!visible) return null;
const groupedBySeverity: Record<SeverityLevel, ErrorGroupCard[]> = {
critical: [],
high: [],
medium: [],
low: [],
};
for (const g of groups) {
groupedBySeverity[g.severity].push(g);
}
return (
<div className="error-group-panel">
<div className="error-group-header">
<h3>
Error Groups
{stats && (
<span className="error-group-stats">
{stats.totalGroups} groups | {stats.activeGroups} active | {stats.totalErrors} total
</span>
)}
</h3>
<div className="error-group-actions">
<button
className={`error-group-filter-btn ${activeOnly ? 'active' : ''}`}
onClick={() => setActiveOnly(v => !v)}
title="Show active errors only"
>
Active only
</button>
<button className="error-group-refresh" onClick={fetchGroups} title="Refresh">
Refresh
</button>
<button className="close-button" onClick={onClose}>×</button>
</div>
</div>
{loading && groups.length === 0 && (
<div className="error-group-loading">Loading error groups...</div>
)}
{error && (
<div className="error-group-error">Failed to load: {error}</div>
)}
{!loading && groups.length === 0 && (
<div className="error-group-empty">No errors detected</div>
)}
<div className="error-group-list">
{SEVERITY_ORDER.map(sev => {
const items = groupedBySeverity[sev];
if (items.length === 0) return null;
const meta = SEVERITY_LABELS[sev];
return (
<div key={sev} className={`error-group-severity severity-${sev}`}>
<div className={`severity-header severity-${sev}-header`}>
{meta.icon} {meta.label} ({items.length})
</div>
{items.map(group => {
const isExpanded = expandedId === group.id;
const workers = group.affectedWorkers.length > 2
? `${group.affectedWorkers.length}w`
: group.affectedWorkers.join(', ');
return (
<div
key={group.id}
className={`error-group-card ${isExpanded ? 'expanded' : ''} ${group.isActive ? 'active' : 'inactive'}`}
>
<div
className="error-group-summary"
onClick={() => toggleExpand(group.id, group)}
>
<span className="expand-marker">{isExpanded ? '▼' : '▶'}</span>
<span className={`active-marker ${group.isActive ? 'is-active' : ''}`}>
{group.isActive ? '●' : '○'}
</span>
<span className={`severity-badge severity-${sev}`}>{meta.icon}</span>
<span className="category-icon">{CATEGORY_ICONS[group.fingerprint.category]}</span>
<span className="error-count">x{group.count}</span>
<span className="error-last-seen">{formatRelativeTime(group.lastSeen)}</span>
<span className="error-workers">{workers}</span>
<span className="error-signature" title={group.fingerprint.signature}>
{group.fingerprint.signature.slice(0, 60)}
</span>
</div>
{isExpanded && (
<div className="error-group-details">
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">Category</span>
<span className="detail-value">{group.fingerprint.category}</span>
</div>
<div className="detail-item">
<span className="detail-label">Severity</span>
<span className={`detail-value severity-${sev}`}>{group.severity}</span>
</div>
<div className="detail-item">
<span className="detail-label">Occurrences</span>
<span className="detail-value">{group.count}</span>
</div>
<div className="detail-item">
<span className="detail-label">Status</span>
<span className={`detail-value ${group.isActive ? 'active-text' : 'inactive-text'}`}>
{group.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">First Seen</span>
<span className="detail-value">{new Date(group.firstSeen).toISOString()}</span>
</div>
<div className="detail-item">
<span className="detail-label">Last Seen</span>
<span className="detail-value">{new Date(group.lastSeen).toISOString()} ({formatRelativeTime(group.lastSeen)})</span>
</div>
<div className="detail-item">
<span className="detail-label">Time Span</span>
<span className="detail-value">{formatTimeSpan(group.firstSeen, group.lastSeen)}</span>
</div>
<div className="detail-item">
<span className="detail-label">Workers</span>
<span className="detail-value">{group.affectedWorkers.join(', ')}</span>
</div>
</div>
<div className="detail-section">
<h4>Signature</h4>
<code className="error-signature-full">{group.fingerprint.signature}</code>
</div>
<div className="detail-section">
<h4>Sample Message</h4>
<pre className="error-sample-message">{group.fingerprint.sampleMessage.split('\n')[0]}</pre>
</div>
{group.recentEvents.length > 0 && (
<div className="detail-section">
<h4>Recent Events ({group.recentEvents.length})</h4>
<div className="error-recent-events">
{group.recentEvents.map((evt, i) => {
const ts = evt.ts
? new Date(evt.ts).toISOString().substring(11, 19)
: evt.timestamp.substring(11, 19);
const msg = (evt.error || evt.message).split('\n')[0].slice(0, 100);
return (
<div key={i} className="error-recent-event">
<span className="evt-time">{ts}</span>
<span className="evt-worker">[{evt.worker}]</span>
<span className="evt-msg">{msg}</span>
</div>
);
})}
</div>
</div>
)}
{group.sampleStack && (
<div className="detail-section">
<h4>Stack Trace</h4>
<pre className="error-stack-trace">
{group.sampleStack.split('\n').slice(0, 15).join('\n')}
{group.sampleStack.split('\n').length > 15 && (
<span className="stack-truncated">
{'\n'}... ({group.sampleStack.split('\n').length - 15} more lines)
</span>
)}
</pre>
</div>
)}
{/* Similar Past Errors */}
<div className="detail-section">
<h4>
Similar Past Errors
{loadingSimilar && <span className="loading-spinner">...</span>}
</h4>
{similarErrors.length === 0 && !loadingSimilar && (
<div className="no-similar">No similar past errors found</div>
)}
{similarErrors.length > 0 && (
<div className="similar-errors-list">
{similarErrors.map(se => (
<div key={se.id} className="similar-error-card">
<div className="similar-error-header">
<span className={`similar-error-type cat-${se.error_type}`}>
{CATEGORY_ICONS[se.error_type as ErrorCategory] || '•'} {se.error_type}
</span>
<span className="similar-error-time">
{new Date(se.timestamp).toLocaleDateString()}
</span>
<span className="similar-error-similarity">
{(se.similarity * 100).toFixed(0)}% match
</span>
{se.resolution_successful !== null && (
<span className={`similar-error-resolution ${se.resolution_successful ? 'resolved' : 'failed'}`}>
{se.resolution_successful ? 'Resolved' : 'Unresolved'}
</span>
)}
</div>
<div className="similar-error-message" title={se.error_message}>
{se.error_message.split('\n')[0].slice(0, 120)}
</div>
{se.resolution && (
<div className="similar-error-resolution-text">
Resolution: {se.resolution}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
};
export default ErrorGroupPanel;

View file

@ -124,6 +124,37 @@ body {
font-weight: 600;
}
.error-group-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
background: rgba(244, 67, 54, 0.15);
border: 1px solid var(--error);
color: var(--error);
padding: 0.375rem 0.625rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.error-group-toggle:hover {
background: rgba(244, 67, 54, 0.25);
}
.error-group-toggle.active {
background: var(--error);
color: #fff;
}
.error-group-toggle-icon {
font-size: 0.875rem;
}
.error-group-toggle-label {
font-size: 0.75rem;
font-weight: 600;
}
.dag-toggle-label {
font-size: 0.75rem;
font-weight: 600;
@ -5693,3 +5724,776 @@ body {
}
}
/* ============================================
Error Group Panel
============================================ */
.error-group-panel {
grid-column: 1 / -1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 50vh;
animation: fadeIn 0.15s ease-out;
}
.error-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.error-group-header h3 {
font-size: 0.9375rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--text-primary);
}
.error-group-stats {
font-size: 0.75rem;
font-weight: 400;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.error-group-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.error-group-filter-btn {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.error-group-filter-btn:hover {
border-color: var(--info);
color: var(--info);
}
.error-group-filter-btn.active {
background: var(--info);
color: #fff;
border-color: var(--info);
}
.error-group-refresh {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.error-group-refresh:hover {
border-color: var(--success);
color: var(--success);
}
.error-group-loading,
.error-group-error,
.error-group-empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
}
.error-group-empty {
color: var(--success);
font-weight: 600;
}
.error-group-error {
color: var(--error);
}
.error-group-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Severity section */
.error-group-severity {
margin-bottom: 0.5rem;
}
.severity-header {
font-size: 0.75rem;
font-weight: 700;
padding: 0.375rem 0.75rem;
border-radius: 4px;
margin-bottom: 0.25rem;
letter-spacing: 0.03em;
}
.severity-critical-header {
background: rgba(244, 67, 54, 0.2);
color: #ef5350;
}
.severity-high-header {
background: rgba(244, 67, 54, 0.1);
color: #e57373;
}
.severity-medium-header {
background: rgba(255, 193, 7, 0.15);
color: #ffb74d;
}
.severity-low-header {
background: rgba(33, 150, 243, 0.1);
color: #64b5f6;
}
/* Error group card */
.error-group-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 0.25rem;
transition: border-color 0.15s;
}
.error-group-card:hover {
border-color: var(--text-secondary);
}
.error-group-card.active {
border-left: 3px solid var(--error);
}
.error-group-card.inactive {
opacity: 0.7;
}
.error-group-card.expanded {
border-color: var(--info);
}
/* Summary row */
.error-group-summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
}
.expand-marker {
color: var(--text-secondary);
font-size: 0.625rem;
flex-shrink: 0;
}
.active-marker {
font-size: 0.625rem;
flex-shrink: 0;
}
.active-marker.is-active {
color: var(--success);
}
.severity-badge {
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
}
.severity-badge.severity-critical {
color: #ef5350;
}
.severity-badge.severity-high {
color: #e57373;
}
.severity-badge.severity-medium {
color: #ffb74d;
}
.severity-badge.severity-low {
color: #64b5f6;
}
.category-icon {
font-size: 0.875rem;
flex-shrink: 0;
}
.error-count {
font-weight: 700;
color: var(--text-primary);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
flex-shrink: 0;
}
.error-last-seen {
color: var(--text-secondary);
font-size: 0.75rem;
min-width: 4rem;
flex-shrink: 0;
}
.error-workers {
color: var(--info);
font-size: 0.75rem;
min-width: 3rem;
flex-shrink: 0;
}
.error-signature {
color: var(--text-primary);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
}
/* Detail panel */
.error-group-details {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 0 0 6px 6px;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1rem;
margin-bottom: 0.75rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.detail-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
font-size: 0.8125rem;
color: var(--text-primary);
word-break: break-all;
}
.detail-value.active-text {
color: var(--success);
font-weight: 600;
}
.detail-value.inactive-text {
color: var(--text-secondary);
}
.detail-value.severity-critical {
color: #ef5350;
font-weight: 700;
}
.detail-value.severity-high {
color: #e57373;
font-weight: 600;
}
.detail-value.severity-medium {
color: #ffb74d;
}
.detail-value.severity-low {
color: #64b5f6;
}
.detail-section {
margin-top: 0.75rem;
}
.detail-section h4 {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.375rem;
text-transform: uppercase;
letter-spacing: 0.03em;
display: flex;
align-items: center;
gap: 0.375rem;
}
.error-signature-full {
display: block;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: var(--text-primary);
word-break: break-all;
white-space: pre-wrap;
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
}
.error-sample-message {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: var(--error);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.error-recent-events {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.error-recent-event {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--bg-primary);
border-radius: 3px;
}
.evt-time {
color: var(--text-secondary);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
flex-shrink: 0;
}
.evt-worker {
color: var(--info);
flex-shrink: 0;
}
.evt-msg {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-stack-trace {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem 0.75rem;
font-size: 0.6875rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
max-height: 200px;
overflow-y: auto;
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
}
.stack-truncated {
color: var(--text-secondary);
font-style: italic;
}
/* Similar past errors */
.similar-errors-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.similar-error-card {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.similar-error-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
}
.similar-error-type {
font-weight: 600;
color: var(--text-primary);
}
.similar-error-time {
color: var(--text-secondary);
}
.similar-error-similarity {
color: var(--info);
font-weight: 600;
margin-left: auto;
}
.similar-error-resolution {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-weight: 600;
}
.similar-error-resolution.resolved {
background: rgba(0, 200, 83, 0.15);
color: var(--success);
}
.similar-error-resolution.failed {
background: rgba(244, 67, 54, 0.15);
color: var(--error);
}
.similar-error-message {
font-size: 0.75rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.similar-error-resolution-text {
font-size: 0.6875rem;
color: var(--success);
margin-top: 0.25rem;
}
.no-similar {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.25rem 0;
}
.loading-spinner {
color: var(--text-secondary);
font-size: 0.75rem;
}
/* ============================================ */
/* Budget Alert Banner (persistent, top of page) */
/* ============================================ */
.budget-banner {
position: sticky;
top: 0;
z-index: 90;
animation: slideDown 0.25s ease-out;
}
.budget-banner--warning {
background: linear-gradient(135deg, #332200, #443300);
border-bottom: 2px solid var(--warning);
}
.budget-banner--critical {
background: linear-gradient(135deg, #330a0a, #441111);
border-bottom: 2px solid var(--error);
}
.budget-banner-content {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.85rem;
color: var(--text-primary);
}
.budget-banner-icon {
font-weight: 800;
font-size: 0.9rem;
min-width: 1.2em;
text-align: center;
}
.budget-banner--warning .budget-banner-icon {
color: var(--warning);
}
.budget-banner--critical .budget-banner-icon {
color: var(--error);
}
.budget-banner-text {
flex: 1;
font-weight: 500;
}
.budget-banner-burn {
color: var(--text-secondary);
font-size: 0.78rem;
}
.budget-banner-action {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary);
padding: 0.2rem 0.6rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.78rem;
}
.budget-banner-action:hover {
background: rgba(255, 255, 255, 0.2);
}
.budget-banner-dismiss {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
cursor: pointer;
padding: 0 0.3rem;
line-height: 1;
}
.budget-banner-dismiss:hover {
color: var(--text-primary);
}
.budget-banner-bar {
height: 3px;
background: rgba(255, 255, 255, 0.05);
}
.budget-banner-bar-fill {
height: 100%;
transition: width 0.5s ease;
}
.budget-banner--warning .budget-banner-bar-fill {
background: var(--warning);
}
.budget-banner--critical .budget-banner-bar-fill {
background: var(--error);
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ============================================ */
/* Budget Alert Panel (modal) */
/* ============================================ */
.budget-alert-panel {
max-width: 720px;
}
.budget-alert-summary-row {
display: flex;
gap: 1.25rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.budget-alert-stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 90px;
}
.budget-alert-stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.budget-alert-stat-value {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.cost-warn {
color: var(--warning);
}
.cost-high {
color: var(--error);
}
.budget-burn-warning {
margin-top: 0.4rem;
padding: 0.35rem 0.6rem;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 4px;
color: var(--error);
font-size: 0.78rem;
font-weight: 500;
}
.budget-progress-markers {
position: relative;
height: 1.2em;
margin-top: 0.15rem;
}
.budget-marker {
position: absolute;
transform: translateX(-50%);
font-size: 0.65rem;
color: var(--text-secondary);
}
.budget-consumers-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.budget-consumer-row {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.82rem;
}
.budget-consumer-info {
min-width: 120px;
max-width: 180px;
overflow: hidden;
}
.budget-consumer-id {
display: block;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.budget-consumer-bead {
display: block;
font-size: 0.68rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.budget-consumer-bar-container {
flex: 1;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
overflow: hidden;
}
.budget-consumer-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s ease;
}
.budget-consumer-cost {
font-weight: 600;
color: var(--warning);
min-width: 55px;
text-align: right;
}
.budget-consumer-tokens {
color: var(--text-secondary);
font-size: 0.72rem;
min-width: 100px;
text-align: right;
}
.budget-toggle {
background: none;
border: 1px solid var(--border-color);
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border-radius: 6px;
font-size: 0.8rem;
}
.budget-toggle.active {
border-color: var(--warning);
background: rgba(255, 193, 7, 0.1);
}
.budget-toggle:hover {
background: var(--hover-bg);
}
.budget-toggle-icon {
font-size: 0.9rem;
}
.budget-toggle-label {
font-size: 0.78rem;
}
.budget-alert-badge {
background: var(--error);
color: white;
font-size: 0.65rem;
padding: 0.1rem 0.4rem;
border-radius: 8px;
font-weight: 600;
}

View file

@ -450,3 +450,56 @@ export interface FleetAnalytics {
beadsPerHour: number;
beadCompletions: BeadCompletion[];
}
// ============================================
// Error Group Types
// ============================================
export interface ErrorFingerprint {
signature: string;
category: ErrorCategory;
sampleMessage: string;
hash: string;
}
export interface ErrorGroupCard {
id: string;
fingerprint: ErrorFingerprint;
firstSeen: number;
lastSeen: number;
count: number;
affectedWorkers: string[];
isActive: boolean;
severity: 'low' | 'medium' | 'high' | 'critical';
recentEvents: Array<{
timestamp: string;
level: string;
worker: string;
message: string;
tool?: string;
ts?: number;
error?: string;
}>;
sampleStack?: string;
}
export interface ErrorGroupStats {
totalGroups: number;
activeGroups: number;
totalErrors: number;
byCategory: Record<string, number>;
bySeverity: Record<string, number>;
}
export interface SimilarError {
id: number;
session_id: string;
worker_id: string;
error_type: string;
error_message: string;
file_path: string | null;
timestamp: number;
resolution: string | null;
resolution_successful: boolean | null;
similarity: number;
}

View file

@ -25,6 +25,9 @@ const MAX_PAYLOAD_SIZE = 64 * 1024;
/** Maximum number of events in a batch request */
const MAX_BATCH_SIZE = 100;
/** Maximum buffered bytes per WebSocket client before termination. */
const WS_MAX_BUFFERED_BYTES = 1024 * 1024; // 1 MB
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Send a systemd sd_notify message via the NOTIFY_SOCKET Unix datagram socket. */
@ -644,6 +647,80 @@ export function createWebServer(options: WebServerOptions): WebServer {
res.json({ success: true });
});
// ============================================
// Error Group API Endpoints
// ============================================
// Get all error groups
app.get('/api/errors/groups', (req: Request, res: Response) => {
const activeOnly = req.query.activeOnly === 'true';
const groups = activeOnly
? store.getActiveErrorGroups()
: store.getErrorGroups();
// Serialize for the wire — events can be large, send a trimmed version
const trimmed = groups.map(g => ({
id: g.id,
fingerprint: g.fingerprint,
firstSeen: g.firstSeen,
lastSeen: g.lastSeen,
count: g.count,
affectedWorkers: g.affectedWorkers,
isActive: g.isActive,
severity: g.severity,
recentEvents: g.events.slice(-5).map(e => ({
timestamp: e.timestamp,
level: e.level,
worker: e.worker,
message: e.message,
tool: e.tool,
ts: e.ts,
error: (e as Record<string, unknown>).error as string | undefined,
})),
sampleStack: (() => {
const withStack = g.events.find(e => (e as Record<string, unknown>).error && String((e as Record<string, unknown>).error).includes('\n'));
return withStack ? String((withStack as Record<string, unknown>).error) : undefined;
})(),
}));
res.json(trimmed);
});
// Get error group statistics
app.get('/api/errors/stats', (_req: Request, res: Response) => {
const stats = store.getErrorStats();
res.json(stats);
});
// Find similar past errors from error_history
app.get('/api/errors/history/similar', (req: Request, res: Response) => {
const message = req.query.message as string;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
if (!message) {
res.status(400).json({ error: 'Missing required parameter: message' });
return;
}
const similar = store.historical.findSimilarErrors(message, limit);
res.json(similar);
});
// Get historical error records
app.get('/api/errors/history', (req: Request, res: Response) => {
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const workerId = req.query.worker as string | undefined;
const errorType = req.query.errorType as string | undefined;
const records = store.historical.getErrorHistory({
limit,
workerId,
errorType,
});
res.json(records);
});
// Fleet analytics — reads log files fresh on each request
app.get('/api/analytics', (_req: Request, res: Response) => {
try {
@ -778,6 +855,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
const message = JSON.stringify({ type: 'event', data: event });
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
// Backpressure: terminate clients whose send buffer exceeds the limit
if (client.bufferedAmount > WS_MAX_BUFFERED_BYTES) {
console.warn(`WebSocket client buffer exceeded ${WS_MAX_BUFFERED_BYTES} bytes — terminating`);
client.close(1013, 'Send buffer overflow');
clients.delete(client);
continue;
}
client.send(message);
}
}
@ -794,6 +878,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
});
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
if (client.bufferedAmount > WS_MAX_BUFFERED_BYTES) {
client.close(1013, 'Send buffer overflow');
clients.delete(client);
continue;
}
client.send(message);
}
}

View file

@ -221,6 +221,12 @@ const DEFAULT_OPTIONS: Required<WorkerAnalyticsOptions> = {
timeSeriesInterval: 3600000, // 1 hour
};
/** Maximum entries retained for unbounded-per-worker arrays. */
const MAX_EVENT_TIMESTAMPS = 5000;
const MAX_ERROR_TIMESTAMPS = 500;
const MAX_ACTIVITY_PERIODS = 500;
const MAX_BEAD_COMPLETION_TIMES = 500;
/**
* Internal tracking data for a worker
*/
@ -284,6 +290,9 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
// Update activity tracking
worker.lastSeen = event.ts;
worker.eventTimestamps.push(event.ts);
if (worker.eventTimestamps.length > MAX_EVENT_TIMESTAMPS) {
worker.eventTimestamps = worker.eventTimestamps.slice(-MAX_EVENT_TIMESTAMPS);
}
this.updateActivityPeriods(worker, event.ts);
// Track bead events
@ -295,6 +304,9 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
if (event.level === 'error' || event.error) {
worker.errorCount++;
worker.errorTimestamps.push(event.ts);
if (worker.errorTimestamps.length > MAX_ERROR_TIMESTAMPS) {
worker.errorTimestamps = worker.errorTimestamps.slice(-MAX_ERROR_TIMESTAMPS);
}
}
// Update cost from cost tracker
@ -630,6 +642,9 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
if (startTime) {
const duration = event.ts - startTime;
worker.beadCompletionTimes.push(duration);
if (worker.beadCompletionTimes.length > MAX_BEAD_COMPLETION_TIMES) {
worker.beadCompletionTimes = worker.beadCompletionTimes.slice(-MAX_BEAD_COMPLETION_TIMES);
}
worker.beadsCompleted++;
worker.beadStartTimes.delete(beadId); // Clean up
}
@ -647,11 +662,12 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
const lastPeriod = worker.activityPeriods[worker.activityPeriods.length - 1];
if (timestamp - lastPeriod.end <= ACTIVITY_GAP_MS) {
// Extend current period
lastPeriod.end = timestamp;
} else {
// Start new period
worker.activityPeriods.push({ start: timestamp, end: timestamp });
if (worker.activityPeriods.length > MAX_ACTIVITY_PERIODS) {
worker.activityPeriods = worker.activityPeriods.slice(-MAX_ACTIVITY_PERIODS);
}
}
}

File diff suppressed because one or more lines are too long