Add new alert architecture
This commit is contained in:
359
frontend/src/hooks/useAlertStream.ts
Normal file
359
frontend/src/hooks/useAlertStream.ts
Normal 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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user