import { useEffect, useState, useRef } from 'react'; import { format, parseISO } from 'date-fns'; import { fr } from 'date-fns/locale'; interface TimelineEvent { timestamp: string; type: 'detection' | 'escalation' | 'peak' | 'stabilization' | 'classification'; severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; count?: number; description?: string; ip?: string; ja4?: string; } interface InteractiveTimelineProps { ip?: string; events?: TimelineEvent[]; hours?: number; height?: string; } export function InteractiveTimeline({ ip, events: propEvents, hours = 24, height = '300px' }: InteractiveTimelineProps) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [zoom, setZoom] = useState(1); const [selectedEvent, setSelectedEvent] = useState(null); const containerRef = useRef(null); useEffect(() => { const fetchTimelineData = async () => { setLoading(true); try { if (ip) { // Fetch detections for this IP to build timeline const response = await fetch(`/api/detections?search=${encodeURIComponent(ip)}&page_size=100&sort_by=detected_at&sort_order=asc`); if (response.ok) { const data = await response.json(); const timelineEvents = buildTimelineFromDetections(data.items); setEvents(timelineEvents); } } else if (propEvents) { setEvents(propEvents); } } catch (error) { console.error('Error fetching timeline data:', error); } finally { setLoading(false); } }; fetchTimelineData(); }, [ip, propEvents]); const buildTimelineFromDetections = (detections: any[]): TimelineEvent[] => { if (!detections || detections.length === 0) return []; const events: TimelineEvent[] = []; // First detection events.push({ timestamp: detections[0]?.detected_at, type: 'detection', severity: detections[0]?.threat_level, count: 1, description: 'Première détection', ip: detections[0]?.src_ip, }); // Group by time windows (5 minutes) const timeWindows = new Map(); detections.forEach((d: any) => { const window = format(parseISO(d.detected_at), 'yyyy-MM-dd HH:mm', { locale: fr }); if (!timeWindows.has(window)) { timeWindows.set(window, []); } timeWindows.get(window)!.push(d); }); // Find peaks let maxCount = 0; timeWindows.forEach((items) => { if (items.length > maxCount) { maxCount = items.length; } if (items.length >= 10) { events.push({ timestamp: items[0]?.detected_at, type: 'peak', severity: items[0]?.threat_level, count: items.length, description: `Pic d'activité: ${items.length} détections`, }); } }); // Escalation detection const sortedWindows = Array.from(timeWindows.entries()).sort((a, b) => new Date(a[0]).getTime() - new Date(b[0]).getTime() ); for (let i = 1; i < sortedWindows.length; i++) { const prevCount = sortedWindows[i - 1][1].length; const currCount = sortedWindows[i][1].length; if (currCount > prevCount * 2 && currCount >= 5) { events.push({ timestamp: sortedWindows[i][1][0]?.detected_at, type: 'escalation', severity: 'HIGH', count: currCount, description: `Escalade: ${prevCount} → ${currCount} détections`, }); } } // Last detection if (detections.length > 1) { events.push({ timestamp: detections[detections.length - 1]?.detected_at, type: 'detection', severity: detections[detections.length - 1]?.threat_level, count: detections.length, description: 'Dernière détection', }); } // Sort by timestamp return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); }; const getEventTypeColor = (type: string) => { switch (type) { case 'detection': return 'bg-blue-500'; case 'escalation': return 'bg-orange-500'; case 'peak': return 'bg-red-500'; case 'stabilization': return 'bg-green-500'; case 'classification': return 'bg-purple-500'; default: return 'bg-gray-500'; } }; const getEventTypeIcon = (type: string) => { switch (type) { case 'detection': return '🔍'; case 'escalation': return '📈'; case 'peak': return '🔥'; case 'stabilization': return '📉'; case 'classification': return '🏷️'; default: return '📍'; } }; const getSeverityColor = (severity?: string) => { switch (severity) { case 'CRITICAL': return 'text-red-500'; case 'HIGH': return 'text-orange-500'; case 'MEDIUM': return 'text-yellow-500'; case 'LOW': return 'text-green-500'; default: return 'text-gray-400'; } }; const visibleEvents = events.slice( Math.max(0, Math.floor((events.length * (1 - zoom)) / 2)), Math.min(events.length, Math.ceil(events.length * (1 + zoom) / 2)) ); if (loading) { return (
Chargement de la timeline...
); } if (events.length === 0) { return (
📭
Aucun événement dans cette période
); } return (
{/* Controls */}
{events.length} événements sur {hours}h
{/* Timeline */}
{/* Time axis */}
{events.length > 0 && ( <> {format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })} {format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })} )}
{/* Events line */}
{visibleEvents.map((event, idx) => { const position = (idx / (visibleEvents.length - 1 || 1)) * 100; return ( ); })}
{/* Event cards */}
{visibleEvents.slice(0, 12).map((event, idx) => (
setSelectedEvent(event)} className={`bg-background-card rounded-lg p-3 cursor-pointer hover:bg-background-card/80 transition-colors border-l-4 ${ event.severity === 'CRITICAL' ? 'border-threat-critical' : event.severity === 'HIGH' ? 'border-threat-high' : event.severity === 'MEDIUM' ? 'border-threat-medium' : 'border-threat-low' }`} >
{getEventTypeIcon(event.type)}
{event.description}
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
{event.count && (
{event.count}
)}
))}
{/* Selected Event Modal */} {selectedEvent && (
setSelectedEvent(null)}>
e.stopPropagation()}>
{getEventTypeIcon(selectedEvent.type)}

Détails de l'événement

Type
{selectedEvent.type}
Timestamp
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
{selectedEvent.description && (
Description
{selectedEvent.description}
)} {selectedEvent.count && (
Nombre de détections
{selectedEvent.count}
)} {selectedEvent.severity && (
Sévérité
{selectedEvent.severity}
)} {selectedEvent.ip && (
IP
{selectedEvent.ip}
)}
)}
); }