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:
parent
755669e73a
commit
9938630bdd
12 changed files with 1621 additions and 87 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
244
src/store.ts
244
src/store.ts
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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: '📍' },
|
||||
|
|
|
|||
347
src/web/frontend/src/components/ErrorGroupPanel.tsx
Normal file
347
src/web/frontend/src/components/ErrorGroupPanel.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue