Add new alert architecture

This commit is contained in:
Urtzi Alfaro
2025-08-23 10:19:58 +02:00
parent 1a9839240e
commit 4b4268d640
45 changed files with 6518 additions and 1590 deletions

View File

@@ -0,0 +1,359 @@
// frontend/src/hooks/useAlertStream.ts
/**
* React hook for managing SSE connection to alert and recommendation stream
* Handles connection management, reconnection, and real-time updates
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import { AlertItem, ItemSeverity, ItemType, SSEConnectionState, NotificationPermission } from '../types/alerts';
import { useAuth } from './useAuth';
interface UseAlertStreamProps {
tenantId: string;
autoConnect?: boolean;
maxReconnectAttempts?: number;
}
interface UseAlertStreamReturn {
items: AlertItem[];
connectionState: SSEConnectionState;
urgentCount: number;
highCount: number;
recCount: number;
acknowledgeItem: (itemId: string) => Promise<void>;
resolveItem: (itemId: string) => Promise<void>;
connect: () => void;
disconnect: () => void;
clearItems: () => void;
notificationPermission: NotificationPermission;
requestNotificationPermission: () => Promise<NotificationPermission>;
}
export const useAlertStream = ({
tenantId,
autoConnect = true,
maxReconnectAttempts = 10
}: UseAlertStreamProps): UseAlertStreamReturn => {
const [items, setItems] = useState<AlertItem[]>([]);
const [connectionState, setConnectionState] = useState<SSEConnectionState>({
status: 'disconnected',
reconnectAttempts: 0
});
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>('default');
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const isManuallyDisconnected = useRef(false);
const { token } = useAuth();
// Initialize notification permission state
useEffect(() => {
if ('Notification' in window) {
setNotificationPermission(Notification.permission);
}
}, []);
const requestNotificationPermission = useCallback(async (): Promise<NotificationPermission> => {
if (!('Notification' in window)) {
return 'denied';
}
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
return permission;
}, []);
const showBrowserNotification = useCallback((item: AlertItem) => {
if (notificationPermission !== 'granted') return;
// Only show notifications for urgent/high alerts, not recommendations
if (item.item_type === 'recommendation') return;
if (!['urgent', 'high'].includes(item.severity)) return;
const notification = new Notification(item.title, {
body: item.message,
icon: '/favicon.ico',
badge: '/badge-icon.png',
tag: item.id,
renotify: true,
requireInteraction: item.severity === 'urgent',
data: {
itemId: item.id,
itemType: item.item_type,
severity: item.severity
}
});
// Auto-close non-urgent notifications after 5 seconds
if (item.severity !== 'urgent') {
setTimeout(() => notification.close(), 5000);
}
notification.onclick = () => {
window.focus();
notification.close();
// Could navigate to specific alert details
};
}, [notificationPermission]);
const playAlertSound = useCallback((severity: ItemSeverity) => {
// Only play sounds for urgent alerts
if (severity !== 'urgent') return;
try {
const audio = new Audio('/sounds/alert-urgent.mp3');
audio.volume = 0.5;
audio.play().catch(() => {
// Silently fail if audio can't play (user interaction required)
});
} catch (error) {
console.warn('Could not play alert sound:', error);
}
}, []);
const addAndSortItems = useCallback((newItem: AlertItem) => {
setItems(prev => {
// Prevent duplicates
if (prev.some(i => i.id === newItem.id)) return prev;
const updated = [newItem, ...prev];
// Sort by severity weight, then by timestamp
const severityWeight = { urgent: 4, high: 3, medium: 2, low: 1 };
return updated.sort((a, b) => {
const weightDiff = severityWeight[b.severity] - severityWeight[a.severity];
if (weightDiff !== 0) return weightDiff;
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
}).slice(0, 100); // Keep only latest 100 items
});
}, []);
const connect = useCallback(() => {
if (!token || !tenantId) {
console.warn('Cannot connect to alert stream: missing token or tenantId');
return;
}
// Clean up existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
isManuallyDisconnected.current = false;
setConnectionState(prev => ({ ...prev, status: 'connecting' }));
// Create SSE connection
const url = `${process.env.REACT_APP_NOTIFICATION_SERVICE_URL || 'http://localhost:8002'}/api/v1/sse/alerts/stream/${tenantId}`;
const eventSource = new EventSource(url, {
withCredentials: true
});
// Add auth header (if supported by browser)
if ('headers' in eventSource) {
(eventSource as any).headers = {
'Authorization': `Bearer ${token}`
};
}
eventSource.onopen = () => {
setConnectionState(prev => ({
...prev,
status: 'connected',
lastConnected: new Date(),
reconnectAttempts: 0
}));
console.log('Alert stream connected');
};
eventSource.addEventListener('connected', (event) => {
console.log('Alert stream handshake completed:', event.data);
});
eventSource.addEventListener('initial_items', (event) => {
try {
const initialItems = JSON.parse(event.data);
setItems(initialItems);
console.log(`Loaded ${initialItems.length} initial items`);
} catch (error) {
console.error('Error parsing initial items:', error);
}
});
eventSource.addEventListener('alert', (event) => {
try {
const newItem = JSON.parse(event.data);
addAndSortItems(newItem);
// Show browser notification for urgent/high alerts
showBrowserNotification(newItem);
// Play sound for urgent alerts
if (newItem.severity === 'urgent') {
playAlertSound(newItem.severity);
}
console.log('New alert received:', newItem.type, newItem.severity);
} catch (error) {
console.error('Error processing alert event:', error);
}
});
eventSource.addEventListener('recommendation', (event) => {
try {
const newItem = JSON.parse(event.data);
addAndSortItems(newItem);
console.log('New recommendation received:', newItem.type);
} catch (error) {
console.error('Error processing recommendation event:', error);
}
});
eventSource.addEventListener('ping', (event) => {
// Handle keepalive pings
console.debug('SSE keepalive ping received');
});
eventSource.onerror = (error) => {
console.error('SSE error:', error);
setConnectionState(prev => ({
...prev,
status: 'error'
}));
eventSource.close();
// Attempt reconnection with exponential backoff
if (!isManuallyDisconnected.current &&
connectionState.reconnectAttempts < maxReconnectAttempts) {
const backoffTime = Math.min(1000 * Math.pow(2, connectionState.reconnectAttempts), 30000);
setConnectionState(prev => ({
...prev,
reconnectAttempts: prev.reconnectAttempts + 1
}));
console.log(`Reconnecting in ${backoffTime}ms (attempt ${connectionState.reconnectAttempts + 1})`);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, backoffTime);
}
};
eventSourceRef.current = eventSource;
}, [token, tenantId, connectionState.reconnectAttempts, maxReconnectAttempts, addAndSortItems, showBrowserNotification, playAlertSound]);
const disconnect = useCallback(() => {
isManuallyDisconnected.current = true;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
setConnectionState({
status: 'disconnected',
reconnectAttempts: 0
});
}, []);
const acknowledgeItem = useCallback(async (itemId: string) => {
try {
const response = await fetch(
`${process.env.REACT_APP_NOTIFICATION_SERVICE_URL || 'http://localhost:8002'}/api/v1/sse/items/${itemId}/acknowledge`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
if (response.ok) {
setItems(prev => prev.map(item =>
item.id === itemId
? { ...item, status: 'acknowledged' as const, acknowledged_at: new Date().toISOString() }
: item
));
}
} catch (error) {
console.error('Failed to acknowledge item:', error);
}
}, [token]);
const resolveItem = useCallback(async (itemId: string) => {
try {
const response = await fetch(
`${process.env.REACT_APP_NOTIFICATION_SERVICE_URL || 'http://localhost:8002'}/api/v1/sse/items/${itemId}/resolve`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
if (response.ok) {
setItems(prev => prev.map(item =>
item.id === itemId
? { ...item, status: 'resolved' as const, resolved_at: new Date().toISOString() }
: item
));
}
} catch (error) {
console.error('Failed to resolve item:', error);
}
}, [token]);
const clearItems = useCallback(() => {
setItems([]);
}, []);
// Auto-connect on mount if enabled
useEffect(() => {
if (autoConnect && token && tenantId) {
connect();
}
return () => {
disconnect();
};
}, [autoConnect, token, tenantId]); // Don't include connect/disconnect to avoid loops
// Calculate counts
const urgentCount = items.filter(i =>
i.severity === 'urgent' && i.status === 'active' && i.item_type === 'alert'
).length;
const highCount = items.filter(i =>
i.severity === 'high' && i.status === 'active' && i.item_type === 'alert'
).length;
const recCount = items.filter(i =>
i.item_type === 'recommendation' && i.status === 'active'
).length;
return {
items,
connectionState,
urgentCount,
highCount,
recCount,
acknowledgeItem,
resolveItem,
connect,
disconnect,
clearItems,
notificationPermission,
requestNotificationPermission
};
};