New alert system and panel de control page
This commit is contained in:
238
frontend/src/hooks/useSSE.ts
Normal file
238
frontend/src/hooks/useSSE.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* useSSE Hook
|
||||
*
|
||||
* Wrapper around SSEContext that collects and manages events.
|
||||
* Provides a clean interface for subscribing to SSE events with channel filtering.
|
||||
*
|
||||
* Examples:
|
||||
* const { events } = useSSE(); // All events
|
||||
* const { events } = useSSE({ channels: ['inventory.alerts'] });
|
||||
* const { events } = useSSE({ channels: ['*.notifications'] });
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { SSEContext } from '../contexts/SSEContext';
|
||||
import type { Event, Alert, Notification, Recommendation } from '../types/events';
|
||||
import { convertLegacyAlert } from '../types/events';
|
||||
|
||||
interface UseSSEConfig {
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
interface UseSSEReturn {
|
||||
events: Event[];
|
||||
isConnected: boolean;
|
||||
clearEvents: () => void;
|
||||
}
|
||||
|
||||
const MAX_EVENTS = 200; // Keep last 200 events in memory
|
||||
|
||||
export function useSSEEvents(config: UseSSEConfig = {}): UseSSEReturn {
|
||||
const context = useContext(SSEContext);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSSE must be used within SSEProvider');
|
||||
}
|
||||
|
||||
// Create a stable key for the config channels to avoid unnecessary re-renders
|
||||
// Use JSON.stringify for reliable comparison of channel arrays
|
||||
const channelsKey = JSON.stringify(config.channels || []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
// Listen to 'alert' events (can be Alert or legacy format)
|
||||
const handleAlert = (data: any) => {
|
||||
console.log('🟢 [useSSE] handleAlert triggered', { data });
|
||||
let event: Event;
|
||||
|
||||
// Check if it's new format (has event_class) or legacy format
|
||||
if (data.event_class === 'alert' || data.event_class === 'notification' || data.event_class === 'recommendation') {
|
||||
// New format
|
||||
event = data as Event;
|
||||
} else if (data.item_type === 'alert' || data.item_type === 'recommendation') {
|
||||
// Legacy format - convert
|
||||
event = convertLegacyAlert(data);
|
||||
} else {
|
||||
// Assume it's an alert if no clear classification
|
||||
event = { ...data, event_class: 'alert', event_domain: 'operations' } as Alert;
|
||||
}
|
||||
|
||||
console.log('🟢 [useSSE] Setting events state with new alert', {
|
||||
eventId: event.id,
|
||||
eventClass: event.event_class,
|
||||
eventDomain: event.event_domain,
|
||||
});
|
||||
|
||||
setEvents(prev => {
|
||||
// Check if this event already exists to prevent duplicate processing
|
||||
const existingIndex = prev.findIndex(e => e.id === event.id);
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing event instead of adding duplicate
|
||||
const newEvents = [...prev];
|
||||
newEvents[existingIndex] = event;
|
||||
return newEvents.slice(0, MAX_EVENTS);
|
||||
}
|
||||
|
||||
// Add new event if not duplicate
|
||||
const filtered = prev.filter(e => e.id !== event.id);
|
||||
const newEvents = [event, ...filtered].slice(0, MAX_EVENTS);
|
||||
console.log('🟢 [useSSE] Events array updated', {
|
||||
prevCount: prev.length,
|
||||
newCount: newEvents.length,
|
||||
newEventIds: newEvents.map(e => e.id).join(','),
|
||||
});
|
||||
return newEvents;
|
||||
});
|
||||
};
|
||||
|
||||
unsubscribers.push(context.addEventListener('alert', handleAlert));
|
||||
|
||||
// Listen to 'notification' events
|
||||
const handleNotification = (data: Notification) => {
|
||||
setEvents(prev => {
|
||||
// Check if this notification already exists to prevent duplicate processing
|
||||
const existingIndex = prev.findIndex(e => e.id === data.id);
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing notification instead of adding duplicate
|
||||
const newEvents = [...prev];
|
||||
newEvents[existingIndex] = data;
|
||||
return newEvents.slice(0, MAX_EVENTS);
|
||||
}
|
||||
|
||||
// Add new notification if not duplicate
|
||||
const filtered = prev.filter(e => e.id !== data.id);
|
||||
return [data, ...filtered].slice(0, MAX_EVENTS);
|
||||
});
|
||||
};
|
||||
|
||||
unsubscribers.push(context.addEventListener('notification', handleNotification));
|
||||
|
||||
// Listen to 'recommendation' events
|
||||
const handleRecommendation = (data: any) => {
|
||||
let event: Recommendation;
|
||||
|
||||
// Handle both new and legacy formats
|
||||
if (data.event_class === 'recommendation') {
|
||||
event = data as Recommendation;
|
||||
} else if (data.item_type === 'recommendation') {
|
||||
event = convertLegacyAlert(data) as Recommendation;
|
||||
} else {
|
||||
event = { ...data, event_class: 'recommendation', event_domain: 'operations' } as Recommendation;
|
||||
}
|
||||
|
||||
setEvents(prev => {
|
||||
// Check if this recommendation already exists to prevent duplicate processing
|
||||
const existingIndex = prev.findIndex(e => e.id === event.id);
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing recommendation instead of adding duplicate
|
||||
const newEvents = [...prev];
|
||||
newEvents[existingIndex] = event;
|
||||
return newEvents.slice(0, MAX_EVENTS);
|
||||
}
|
||||
|
||||
// Add new recommendation if not duplicate
|
||||
const filtered = prev.filter(e => e.id !== event.id);
|
||||
return [event, ...filtered].slice(0, MAX_EVENTS);
|
||||
});
|
||||
};
|
||||
|
||||
unsubscribers.push(context.addEventListener('recommendation', handleRecommendation));
|
||||
|
||||
// Listen to 'initial_state' event (batch load on connection)
|
||||
const handleInitialState = (data: any) => {
|
||||
if (Array.isArray(data)) {
|
||||
// Convert each event to proper format
|
||||
const initialEvents = data.map(item => {
|
||||
if (item.event_class) {
|
||||
return item as Event;
|
||||
} else if (item.item_type) {
|
||||
return convertLegacyAlert(item);
|
||||
} else {
|
||||
return { ...item, event_class: 'alert', event_domain: 'operations' } as Event;
|
||||
}
|
||||
});
|
||||
setEvents(initialEvents.slice(0, MAX_EVENTS));
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribers.push(context.addEventListener('initial_state', handleInitialState));
|
||||
|
||||
// Also listen to legacy 'initial_items' event
|
||||
const handleInitialItems = (data: any) => {
|
||||
if (Array.isArray(data)) {
|
||||
const initialEvents = data.map(item => {
|
||||
if (item.event_class) {
|
||||
return item as Event;
|
||||
} else {
|
||||
return convertLegacyAlert(item);
|
||||
}
|
||||
});
|
||||
setEvents(initialEvents.slice(0, MAX_EVENTS));
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribers.push(context.addEventListener('initial_items', handleInitialItems));
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach(unsub => unsub());
|
||||
};
|
||||
}, [context, channelsKey]); // Fixed: Added channelsKey dependency
|
||||
|
||||
const clearEvents = useCallback(() => {
|
||||
setEvents([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
events,
|
||||
isConnected: context.isConnected,
|
||||
clearEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useSSEWithDedupe Hook
|
||||
*
|
||||
* Enhanced version that deduplicates events more aggressively
|
||||
* based on event_type + entity_id for state change notifications.
|
||||
*/
|
||||
export function useSSEEventsWithDedupe(config: UseSSEConfig = {}) {
|
||||
const { events: rawEvents, ...rest } = useSSEEvents(config);
|
||||
const [deduplicatedEvents, setDeduplicatedEvents] = useState<Event[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Deduplicate notifications by event_type + entity_id
|
||||
const seen = new Set<string>();
|
||||
const deduplicated: Event[] = [];
|
||||
|
||||
for (const event of rawEvents) {
|
||||
let key: string;
|
||||
|
||||
if (event.event_class === 'notification') {
|
||||
const notification = event as Notification;
|
||||
// Deduplicate by entity (keep only latest state)
|
||||
if (notification.entity_type && notification.entity_id) {
|
||||
key = `${notification.event_type}:${notification.entity_type}:${notification.entity_id}`;
|
||||
} else {
|
||||
key = event.id;
|
||||
}
|
||||
} else {
|
||||
// For alerts and recommendations, use ID
|
||||
key = event.id;
|
||||
}
|
||||
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
deduplicated.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
setDeduplicatedEvents(deduplicated);
|
||||
}, [rawEvents]);
|
||||
|
||||
return {
|
||||
events: deduplicatedEvents,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user