Add new alert architecture
This commit is contained in:
304
frontend/src/components/alerts/AlertCard.tsx
Normal file
304
frontend/src/components/alerts/AlertCard.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
// frontend/src/components/alerts/AlertCard.tsx
|
||||
/**
|
||||
* Individual alert/recommendation card component
|
||||
* Displays alert details with appropriate styling and actions
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AlertItem, ItemSeverity, ItemType } from '../../types/alerts';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
interface AlertCardProps {
|
||||
item: AlertItem;
|
||||
onAcknowledge: (itemId: string) => void;
|
||||
onResolve: (itemId: string) => void;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
const getSeverityConfig = (severity: ItemSeverity, itemType: ItemType) => {
|
||||
if (itemType === 'recommendation') {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return {
|
||||
color: 'bg-blue-50 border-blue-200 text-blue-900',
|
||||
icon: '💡',
|
||||
badge: 'bg-blue-100 text-blue-800'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
color: 'bg-blue-50 border-blue-100 text-blue-800',
|
||||
icon: '💡',
|
||||
badge: 'bg-blue-50 text-blue-600'
|
||||
};
|
||||
case 'low':
|
||||
return {
|
||||
color: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||
icon: '💡',
|
||||
badge: 'bg-gray-100 text-gray-600'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-blue-50 border-blue-200 text-blue-900',
|
||||
icon: '💡',
|
||||
badge: 'bg-blue-100 text-blue-800'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
switch (severity) {
|
||||
case 'urgent':
|
||||
return {
|
||||
color: 'bg-red-50 border-red-300 text-red-900',
|
||||
icon: '🚨',
|
||||
badge: 'bg-red-100 text-red-800',
|
||||
pulse: true
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
color: 'bg-orange-50 border-orange-200 text-orange-900',
|
||||
icon: '⚠️',
|
||||
badge: 'bg-orange-100 text-orange-800'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
color: 'bg-yellow-50 border-yellow-200 text-yellow-900',
|
||||
icon: '🔔',
|
||||
badge: 'bg-yellow-100 text-yellow-800'
|
||||
};
|
||||
case 'low':
|
||||
return {
|
||||
color: 'bg-green-50 border-green-200 text-green-900',
|
||||
icon: 'ℹ️',
|
||||
badge: 'bg-green-100 text-green-800'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-50 border-gray-200 text-gray-700',
|
||||
icon: '📋',
|
||||
badge: 'bg-gray-100 text-gray-600'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'acknowledged':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800',
|
||||
label: 'Reconocido'
|
||||
};
|
||||
case 'resolved':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800',
|
||||
label: 'Resuelto'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800',
|
||||
label: 'Activo'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const AlertCard: React.FC<AlertCardProps> = ({
|
||||
item,
|
||||
onAcknowledge,
|
||||
onResolve,
|
||||
compact = false,
|
||||
showActions = true
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const severityConfig = getSeverityConfig(item.severity, item.item_type);
|
||||
const statusConfig = getStatusConfig(item.status);
|
||||
|
||||
const handleAction = async (action: () => void, actionType: string) => {
|
||||
setActionLoading(actionType);
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timeAgo = formatDistanceToNow(new Date(item.timestamp), {
|
||||
addSuffix: true,
|
||||
locale: es
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
rounded-lg border-2 transition-all duration-200 hover:shadow-md
|
||||
${severityConfig.color}
|
||||
${severityConfig.pulse ? 'animate-pulse' : ''}
|
||||
${item.status !== 'active' ? 'opacity-75' : ''}
|
||||
`}>
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
{/* Icon and Type Badge */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">{severityConfig.icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title and Badges */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold truncate">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`
|
||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${severityConfig.badge}
|
||||
`}>
|
||||
{item.item_type === 'alert' ? 'Alerta' : 'Recomendación'} - {item.severity}
|
||||
</span>
|
||||
<span className={`
|
||||
inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
||||
${statusConfig.color}
|
||||
`}>
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{item.service}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand Button */}
|
||||
{!compact && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className={`text-sm ${compact ? 'line-clamp-2' : ''}`}>
|
||||
{item.message}
|
||||
</p>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{timeAgo} • {new Date(item.timestamp).toLocaleString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{showActions && item.status === 'active' && (
|
||||
<div className="flex items-center space-x-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAction(() => onAcknowledge(item.id), 'acknowledge')}
|
||||
disabled={actionLoading === 'acknowledge'}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'acknowledge' ? (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-700" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Reconocer
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction(() => onResolve(item.id), 'resolve')}
|
||||
disabled={actionLoading === 'resolve'}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === 'resolve' ? (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-green-700" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
Resolver
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 px-4 py-3 bg-gray-50 bg-opacity-50">
|
||||
{/* Actions */}
|
||||
{item.actions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Acciones sugeridas:</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{item.actions.map((action, index) => (
|
||||
<li key={index} className="text-sm text-gray-600">
|
||||
{action}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{Object.keys(item.metadata).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Detalles técnicos:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(item.metadata).map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<span className="font-medium text-gray-600">{key}:</span>{' '}
|
||||
<span className="text-gray-800">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acknowledgment/Resolution Info */}
|
||||
{(item.acknowledged_at || item.resolved_at) && (
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{item.acknowledged_at && (
|
||||
<p>
|
||||
Reconocido: {new Date(item.acknowledged_at).toLocaleString('es-ES')}
|
||||
{item.acknowledged_by && ` por ${item.acknowledged_by}`}
|
||||
</p>
|
||||
)}
|
||||
{item.resolved_at && (
|
||||
<p>
|
||||
Resuelto: {new Date(item.resolved_at).toLocaleString('es-ES')}
|
||||
{item.resolved_by && ` por ${item.resolved_by}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
347
frontend/src/components/alerts/AlertDashboard.tsx
Normal file
347
frontend/src/components/alerts/AlertDashboard.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
// frontend/src/components/alerts/AlertDashboard.tsx
|
||||
/**
|
||||
* Main dashboard component for alerts and recommendations
|
||||
* Provides filtering, bulk actions, and real-time updates
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { AlertItem, ItemFilters, ItemType, ItemSeverity, ItemStatus } from '../../types/alerts';
|
||||
import { useAlertStream } from '../../hooks/useAlertStream';
|
||||
import { AlertCard } from './AlertCard';
|
||||
import { AlertFilters } from './AlertFilters';
|
||||
import { AlertStats } from './AlertStats';
|
||||
import { ConnectionStatus } from './ConnectionStatus';
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
|
||||
interface AlertDashboardProps {
|
||||
className?: string;
|
||||
maxItems?: number;
|
||||
autoRequestNotifications?: boolean;
|
||||
}
|
||||
|
||||
export const AlertDashboard: React.FC<AlertDashboardProps> = ({
|
||||
className = '',
|
||||
maxItems = 50,
|
||||
autoRequestNotifications = true
|
||||
}) => {
|
||||
const tenantId = useTenantId();
|
||||
const {
|
||||
items,
|
||||
connectionState,
|
||||
urgentCount,
|
||||
highCount,
|
||||
recCount,
|
||||
acknowledgeItem,
|
||||
resolveItem,
|
||||
notificationPermission,
|
||||
requestNotificationPermission
|
||||
} = useAlertStream({ tenantId });
|
||||
|
||||
const [filters, setFilters] = useState<ItemFilters>({
|
||||
item_type: 'all',
|
||||
severity: 'all',
|
||||
status: 'all',
|
||||
service: 'all',
|
||||
search: ''
|
||||
});
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([]);
|
||||
const [bulkActionsOpen, setBulkActionsOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'compact'>('list');
|
||||
|
||||
// Request notification permission on mount if needed
|
||||
useEffect(() => {
|
||||
if (autoRequestNotifications && notificationPermission === 'default') {
|
||||
// Delay request to avoid immediate popup
|
||||
const timer = setTimeout(() => {
|
||||
requestNotificationPermission();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoRequestNotifications, notificationPermission, requestNotificationPermission]);
|
||||
|
||||
// Filter items based on current filters
|
||||
const filteredItems = useMemo(() => {
|
||||
let filtered = items;
|
||||
|
||||
// Filter by type
|
||||
if (filters.item_type !== 'all') {
|
||||
filtered = filtered.filter(item => item.item_type === filters.item_type);
|
||||
}
|
||||
|
||||
// Filter by severity
|
||||
if (filters.severity !== 'all') {
|
||||
filtered = filtered.filter(item => item.severity === filters.severity);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === filters.status);
|
||||
}
|
||||
|
||||
// Filter by service
|
||||
if (filters.service !== 'all') {
|
||||
filtered = filtered.filter(item => item.service === filters.service);
|
||||
}
|
||||
|
||||
// Filter by search text
|
||||
if (filters.search.trim()) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
filtered = filtered.filter(item =>
|
||||
item.title.toLowerCase().includes(searchLower) ||
|
||||
item.message.toLowerCase().includes(searchLower) ||
|
||||
item.type.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered.slice(0, maxItems);
|
||||
}, [items, filters, maxItems]);
|
||||
|
||||
// Get unique services for filter dropdown
|
||||
const availableServices = useMemo(() => {
|
||||
const services = [...new Set(items.map(item => item.service))].sort();
|
||||
return services;
|
||||
}, [items]);
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAcknowledge = async () => {
|
||||
await Promise.all(selectedItems.map(id => acknowledgeItem(id)));
|
||||
setSelectedItems([]);
|
||||
setBulkActionsOpen(false);
|
||||
};
|
||||
|
||||
const handleBulkResolve = async () => {
|
||||
await Promise.all(selectedItems.map(id => resolveItem(id)));
|
||||
setSelectedItems([]);
|
||||
setBulkActionsOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const selectableItems = filteredItems
|
||||
.filter(item => item.status === 'active')
|
||||
.map(item => item.id);
|
||||
setSelectedItems(selectableItems);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedItems([]);
|
||||
setBulkActionsOpen(false);
|
||||
};
|
||||
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
setSelectedItems(prev =>
|
||||
prev.includes(itemId)
|
||||
? prev.filter(id => id !== itemId)
|
||||
: [...prev, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
const activeItems = filteredItems.filter(item => item.status === 'active');
|
||||
const hasSelection = selectedItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`max-w-7xl mx-auto ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Sistema de Alertas y Recomendaciones
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Monitoreo en tiempo real de operaciones de panadería
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<ConnectionStatus connectionState={connectionState} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<AlertStats
|
||||
urgentCount={urgentCount}
|
||||
highCount={highCount}
|
||||
recCount={recCount}
|
||||
totalItems={items.length}
|
||||
activeItems={activeItems.length}
|
||||
/>
|
||||
|
||||
{/* Notification Permission Banner */}
|
||||
{notificationPermission === 'denied' && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4 mx-6 mt-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Notificaciones bloqueadas
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Las notificaciones del navegador están deshabilitadas. No recibirás alertas urgentes en tiempo real.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and View Controls */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||
<AlertFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
availableServices={availableServices}
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-l-md border ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Lista
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('compact')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-r-md border-l-0 border ${
|
||||
viewMode === 'compact'
|
||||
? 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Compacto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{activeItems.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setBulkActionsOpen(!bulkActionsOpen)}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Acciones masivas
|
||||
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Panel */}
|
||||
{bulkActionsOpen && activeItems.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedItems.length} elementos seleccionados
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Seleccionar todos los activos
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Limpiar selección
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasSelection && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleBulkAcknowledge}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200"
|
||||
>
|
||||
Reconocer seleccionados
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkResolve}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-green-700 bg-green-100 hover:bg-green-200"
|
||||
>
|
||||
Resolver seleccionados
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div className="px-6 py-4">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
{items.length === 0 ? (
|
||||
<div>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
Sistema operativo
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No hay alertas activas en este momento. Todas las operaciones funcionan correctamente.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||
No se encontraron elementos
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Intenta ajustar los filtros para ver más elementos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`space-y-4 ${viewMode === 'compact' ? 'space-y-2' : ''}`}>
|
||||
{filteredItems.map((item) => (
|
||||
<div key={item.id} className="relative">
|
||||
{/* Selection Checkbox */}
|
||||
{bulkActionsOpen && item.status === 'active' && (
|
||||
<div className="absolute left-2 top-4 z-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.includes(item.id)}
|
||||
onChange={() => toggleItemSelection(item.id)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={bulkActionsOpen && item.status === 'active' ? 'ml-8' : ''}>
|
||||
<AlertCard
|
||||
item={item}
|
||||
onAcknowledge={acknowledgeItem}
|
||||
onResolve={resolveItem}
|
||||
compact={viewMode === 'compact'}
|
||||
showActions={!bulkActionsOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
148
frontend/src/components/alerts/AlertFilters.tsx
Normal file
148
frontend/src/components/alerts/AlertFilters.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// frontend/src/components/alerts/AlertFilters.tsx
|
||||
/**
|
||||
* Filter controls for the alert dashboard
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ItemFilters, ItemType, ItemSeverity, ItemStatus } from '../../types/alerts';
|
||||
|
||||
interface AlertFiltersProps {
|
||||
filters: ItemFilters;
|
||||
onFiltersChange: (filters: ItemFilters) => void;
|
||||
availableServices: string[];
|
||||
}
|
||||
|
||||
export const AlertFilters: React.FC<AlertFiltersProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
availableServices
|
||||
}) => {
|
||||
const updateFilter = (key: keyof ItemFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<label htmlFor="search" className="sr-only">
|
||||
Buscar
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Buscar alertas y recomendaciones..."
|
||||
value={filters.search}
|
||||
onChange={(e) => updateFilter('search', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div>
|
||||
<label htmlFor="type-filter" className="sr-only">
|
||||
Tipo
|
||||
</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
value={filters.item_type}
|
||||
onChange={(e) => updateFilter('item_type', e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="all">Todos los tipos</option>
|
||||
<option value="alert">Alertas</option>
|
||||
<option value="recommendation">Recomendaciones</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<div>
|
||||
<label htmlFor="severity-filter" className="sr-only">
|
||||
Severidad
|
||||
</label>
|
||||
<select
|
||||
id="severity-filter"
|
||||
value={filters.severity}
|
||||
onChange={(e) => updateFilter('severity', e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="all">Todas las severidades</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="low">Baja</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label htmlFor="status-filter" className="sr-only">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
id="status-filter"
|
||||
value={filters.status}
|
||||
onChange={(e) => updateFilter('status', e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="all">Todos los estados</option>
|
||||
<option value="active">Activos</option>
|
||||
<option value="acknowledged">Reconocidos</option>
|
||||
<option value="resolved">Resueltos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Service Filter */}
|
||||
{availableServices.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="service-filter" className="sr-only">
|
||||
Servicio
|
||||
</label>
|
||||
<select
|
||||
id="service-filter"
|
||||
value={filters.service}
|
||||
onChange={(e) => updateFilter('service', e.target.value)}
|
||||
className="block w-full pl-3 pr-10 py-2 text-base border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="all">Todos los servicios</option>
|
||||
{availableServices.map((service) => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.search || filters.item_type !== 'all' || filters.severity !== 'all' ||
|
||||
filters.status !== 'all' || filters.service !== 'all') && (
|
||||
<button
|
||||
onClick={() => onFiltersChange({
|
||||
item_type: 'all',
|
||||
severity: 'all',
|
||||
status: 'all',
|
||||
service: 'all',
|
||||
search: ''
|
||||
})}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Limpiar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
frontend/src/components/alerts/AlertStats.tsx
Normal file
102
frontend/src/components/alerts/AlertStats.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// frontend/src/components/alerts/AlertStats.tsx
|
||||
/**
|
||||
* Statistics display for alerts and recommendations
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface AlertStatsProps {
|
||||
urgentCount: number;
|
||||
highCount: number;
|
||||
recCount: number;
|
||||
totalItems: number;
|
||||
activeItems: number;
|
||||
}
|
||||
|
||||
export const AlertStats: React.FC<AlertStatsProps> = ({
|
||||
urgentCount,
|
||||
highCount,
|
||||
recCount,
|
||||
totalItems,
|
||||
activeItems
|
||||
}) => {
|
||||
const stats = [
|
||||
{
|
||||
name: 'Alertas Urgentes',
|
||||
value: urgentCount,
|
||||
icon: '🚨',
|
||||
color: urgentCount > 0 ? 'text-red-600' : 'text-gray-600',
|
||||
bgColor: urgentCount > 0 ? 'bg-red-50' : 'bg-gray-50',
|
||||
borderColor: urgentCount > 0 ? 'border-red-200' : 'border-gray-200'
|
||||
},
|
||||
{
|
||||
name: 'Alertas Altas',
|
||||
value: highCount,
|
||||
icon: '⚠️',
|
||||
color: highCount > 0 ? 'text-orange-600' : 'text-gray-600',
|
||||
bgColor: highCount > 0 ? 'bg-orange-50' : 'bg-gray-50',
|
||||
borderColor: highCount > 0 ? 'border-orange-200' : 'border-gray-200'
|
||||
},
|
||||
{
|
||||
name: 'Recomendaciones',
|
||||
value: recCount,
|
||||
icon: '💡',
|
||||
color: recCount > 0 ? 'text-blue-600' : 'text-gray-600',
|
||||
bgColor: recCount > 0 ? 'bg-blue-50' : 'bg-gray-50',
|
||||
borderColor: recCount > 0 ? 'border-blue-200' : 'border-gray-200'
|
||||
},
|
||||
{
|
||||
name: 'Total Activos',
|
||||
value: activeItems,
|
||||
icon: '📊',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="px-6 py-4">
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.name}
|
||||
className={`relative overflow-hidden rounded-lg border ${stat.borderColor} ${stat.bgColor} p-4 transition-all duration-200 hover:shadow-md`}
|
||||
>
|
||||
<dt className="flex items-center text-sm font-medium text-gray-600">
|
||||
<span className="text-lg mr-2">{stat.icon}</span>
|
||||
{stat.name}
|
||||
</dt>
|
||||
<dd className={`mt-1 text-2xl font-semibold ${stat.color}`}>
|
||||
{stat.value}
|
||||
</dd>
|
||||
|
||||
{/* Pulse animation for urgent alerts */}
|
||||
{stat.name === 'Alertas Urgentes' && urgentCount > 0 && (
|
||||
<div className="absolute inset-0 rounded-lg border-2 border-red-400 animate-pulse opacity-50"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{/* Summary text */}
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
{totalItems === 0 ? (
|
||||
<p className="flex items-center">
|
||||
<span className="text-green-500 mr-2">✅</span>
|
||||
Todos los sistemas funcionan correctamente
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Mostrando {totalItems} elementos total{totalItems !== 1 ? 'es' : ''}
|
||||
{activeItems > 0 && (
|
||||
<>, {activeItems} activo{activeItems !== 1 ? 's' : ''}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/components/alerts/ConnectionStatus.tsx
Normal file
70
frontend/src/components/alerts/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// frontend/src/components/alerts/ConnectionStatus.tsx
|
||||
/**
|
||||
* Displays the current SSE connection status with appropriate styling
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { SSEConnectionState } from '../../types/alerts';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
connectionState: SSEConnectionState;
|
||||
}
|
||||
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
||||
connectionState
|
||||
}) => {
|
||||
const getStatusConfig = (state: SSEConnectionState) => {
|
||||
switch (state.status) {
|
||||
case 'connected':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 border-green-200',
|
||||
icon: '🟢',
|
||||
label: 'Conectado',
|
||||
description: 'Actualizaciones en tiempo real'
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
icon: '🟡',
|
||||
label: 'Conectando...',
|
||||
description: 'Estableciendo conexión'
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 border-red-200',
|
||||
icon: '🔴',
|
||||
label: 'Error de conexión',
|
||||
description: state.reconnectAttempts > 0 ? `Reintento ${state.reconnectAttempts}` : 'Fallo en la conexión'
|
||||
};
|
||||
case 'disconnected':
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
icon: '⚪',
|
||||
label: 'Desconectado',
|
||||
description: 'Sin actualizaciones en tiempo real'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(connectionState);
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center px-3 py-2 rounded-md border text-sm font-medium ${config.color}`}>
|
||||
<span className="mr-2">{config.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
<span className="text-xs opacity-75">{config.description}</span>
|
||||
</div>
|
||||
|
||||
{connectionState.status === 'connecting' && (
|
||||
<div className="ml-2">
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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
|
||||
};
|
||||
};
|
||||
126
frontend/src/types/alerts.ts
Normal file
126
frontend/src/types/alerts.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// frontend/src/types/alerts.ts
|
||||
/**
|
||||
* TypeScript types for the unified alert and recommendation system
|
||||
*/
|
||||
|
||||
export type ItemType = 'alert' | 'recommendation';
|
||||
|
||||
export type ItemSeverity = 'urgent' | 'high' | 'medium' | 'low';
|
||||
|
||||
export type ItemStatus = 'active' | 'acknowledged' | 'resolved';
|
||||
|
||||
export interface AlertItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
item_type: ItemType;
|
||||
type: string; // Specific alert/recommendation type
|
||||
severity: ItemSeverity;
|
||||
status: ItemStatus;
|
||||
service: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actions: string[];
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
acknowledged_at?: string;
|
||||
acknowledged_by?: string;
|
||||
resolved_at?: string;
|
||||
resolved_by?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ItemFilters {
|
||||
item_type: ItemType | 'all';
|
||||
severity: ItemSeverity | 'all';
|
||||
status: ItemStatus | 'all';
|
||||
service: string | 'all';
|
||||
search: string;
|
||||
}
|
||||
|
||||
export interface ItemCounts {
|
||||
total: number;
|
||||
alerts: {
|
||||
urgent: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
recommendations: {
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
};
|
||||
by_status: {
|
||||
active: number;
|
||||
acknowledged: number;
|
||||
resolved: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
browser_notifications: boolean;
|
||||
sound_enabled: boolean;
|
||||
auto_acknowledge_timeout: number; // minutes
|
||||
show_recommendations: boolean;
|
||||
urgent_only: boolean;
|
||||
}
|
||||
|
||||
export interface SSEConnectionState {
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
lastConnected?: Date;
|
||||
reconnectAttempts: number;
|
||||
latency?: number;
|
||||
}
|
||||
|
||||
// Notification permission states
|
||||
export type NotificationPermission = 'default' | 'granted' | 'denied';
|
||||
|
||||
// UI state
|
||||
export interface AlertUIState {
|
||||
filters: ItemFilters;
|
||||
selectedItems: string[];
|
||||
sortBy: 'created_at' | 'severity' | 'type';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
viewMode: 'list' | 'grid' | 'compact';
|
||||
sidebarOpen: boolean;
|
||||
bulkActionsOpen: boolean;
|
||||
}
|
||||
|
||||
// Action types for alert responses
|
||||
export interface AlertAction {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'acknowledge' | 'resolve' | 'custom';
|
||||
icon?: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
requires_confirmation?: boolean;
|
||||
}
|
||||
|
||||
// Metrics for dashboard
|
||||
export interface AlertMetrics {
|
||||
response_time_avg: number; // seconds
|
||||
false_positive_rate: number;
|
||||
recommendation_adoption_rate: number;
|
||||
items_last_24h: number;
|
||||
top_alert_types: Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
}>;
|
||||
service_health: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Template for creating new alerts (development/testing)
|
||||
export interface AlertTemplate {
|
||||
type: string;
|
||||
severity: ItemSeverity;
|
||||
title: string;
|
||||
message: string;
|
||||
actions: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
Reference in New Issue
Block a user