Files
dashboard/frontend/src/components/InteractiveTimeline.tsx
SOC Analyst dc029c54ed feat(phase2): Graph de corrélations, Timeline interactive, Threat Intel
🎯 NOUVELLES FONCTIONNALITÉS:

• 🕸️ Graph de Corrélations (React Flow)
  - Visualisation des relations IP ↔ Subnet ↔ ASN ↔ JA4 ↔ UA ↔ Pays
  - Noeuds interactifs et déplaçables
  - Zoom et pan disponibles
  - Code couleur par type d'entité
  - Intégré dans /investigation/:ip

• 📈 Timeline Interactive
  - Visualisation temporelle des détections
  - Détection automatique des pics et escalades
  - Zoom avant/arrière
  - Tooltips au survol
  - Click pour détails complets
  - Intégré dans /investigation/:ip

• 📚 Threat Intelligence (/threat-intel)
  - Base de connaissances des classifications
  - Statistiques par label (Malicious/Suspicious/Légitime)
  - Filtres par label, tag, recherche texte
  - Tags populaires avec counts
  - Tableau des classifications récentes
  - Confiance affichée en barres de progression

🔧 COMPOSANTS CRÉÉS:
• frontend/src/components/CorrelationGraph.tsx (266 lignes)
  - React Flow pour visualisation graphique
  - Fetch multi-endpoints pour données complètes

• frontend/src/components/InteractiveTimeline.tsx (377 lignes)
  - Détection de patterns temporels
  - Zoom interactif
  - Modal de détails

• frontend/src/components/ThreatIntelView.tsx (330 lignes)
  - Vue complète threat intelligence
  - Filtres multiples
  - Stats en temps réel

📦 DÉPENDANCES AJOUTÉES:
• reactflow: ^11.10.0 - Graph de corrélations

🎨 UI/UX:
• Navigation mise à jour avec lien Threat Intel
• InvestigationView enrichie avec 2 nouveaux panels
• Code couleur cohérent avec le thème SOC

 Build Docker: SUCCESS

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-14 21:47:57 +01:00

377 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TimelineEvent[]>([]);
const [loading, setLoading] = useState(true);
const [zoom, setZoom] = useState(1);
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
const containerRef = useRef<HTMLDivElement>(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<string, any[]>();
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 (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary">Chargement de la timeline...</div>
</div>
);
}
if (events.length === 0) {
return (
<div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary text-center">
<div className="text-4xl mb-2">📭</div>
<div className="text-sm">Aucun événement dans cette période</div>
</div>
</div>
);
}
return (
<div ref={containerRef} className="w-full" style={{ height }}>
{/* Controls */}
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-text-secondary">
{events.length} événements sur {hours}h
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
Zoom
</button>
<button
onClick={() => setZoom(1)}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
100%
</button>
<button
onClick={() => setZoom(Math.min(2, zoom + 0.25))}
className="px-3 py-1 bg-background-card text-text-primary rounded text-xs hover:bg-background-card/80"
>
+ Zoom
</button>
</div>
</div>
{/* Timeline */}
<div className="relative overflow-x-auto">
<div className="min-w-full">
{/* Time axis */}
<div className="flex justify-between mb-4 text-xs text-text-secondary">
{events.length > 0 && (
<>
<span>{format(parseISO(events[0].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
<span>{format(parseISO(events[events.length - 1].timestamp), 'dd/MM HH:mm', { locale: fr })}</span>
</>
)}
</div>
{/* Events line */}
<div className="relative h-24 border-t-2 border-background-card">
{visibleEvents.map((event, idx) => {
const position = (idx / (visibleEvents.length - 1 || 1)) * 100;
return (
<button
key={idx}
onClick={() => setSelectedEvent(event)}
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
style={{ left: `${position}%` }}
>
<div className={`w-4 h-4 rounded-full ${getEventTypeColor(event.type)} border-2 border-background-secondary shadow-lg group-hover:scale-150 transition-transform`}>
<div className="text-xs text-center leading-3">
{getEventTypeIcon(event.type)}
</div>
</div>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block">
<div className="bg-background-secondary border border-background-card rounded-lg p-2 shadow-xl whitespace-nowrap z-10">
<div className="text-xs text-text-primary font-bold">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm:ss', { locale: fr })}
</div>
{event.count && (
<div className="text-xs text-text-secondary mt-1">
{event.count} détections
</div>
)}
</div>
</div>
</button>
);
})}
</div>
{/* Event cards */}
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-64 overflow-y-auto">
{visibleEvents.slice(0, 12).map((event, idx) => (
<div
key={idx}
onClick={() => 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'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">{getEventTypeIcon(event.type)}</span>
<div>
<div className="text-sm text-text-primary font-medium">
{event.description}
</div>
<div className="text-xs text-text-secondary mt-1">
{format(parseISO(event.timestamp), 'dd/MM HH:mm', { locale: fr })}
</div>
</div>
</div>
{event.count && (
<div className="text-xs text-text-primary font-bold bg-background-secondary px-2 py-1 rounded">
{event.count}
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Selected Event Modal */}
{selectedEvent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setSelectedEvent(null)}>
<div className="bg-background-secondary rounded-lg p-6 max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{getEventTypeIcon(selectedEvent.type)}</span>
<h3 className="text-lg font-bold text-text-primary">Détails de l'événement</h3>
</div>
<button onClick={() => setSelectedEvent(null)} className="text-text-secondary hover:text-text-primary">
</button>
</div>
<div className="space-y-3">
<div>
<div className="text-xs text-text-secondary">Type</div>
<div className="text-text-primary capitalize">{selectedEvent.type}</div>
</div>
<div>
<div className="text-xs text-text-secondary">Timestamp</div>
<div className="text-text-primary font-mono">
{format(parseISO(selectedEvent.timestamp), 'dd/MM/yyyy HH:mm:ss', { locale: fr })}
</div>
</div>
{selectedEvent.description && (
<div>
<div className="text-xs text-text-secondary">Description</div>
<div className="text-text-primary">{selectedEvent.description}</div>
</div>
)}
{selectedEvent.count && (
<div>
<div className="text-xs text-text-secondary">Nombre de détections</div>
<div className="text-text-primary font-bold">{selectedEvent.count}</div>
</div>
)}
{selectedEvent.severity && (
<div>
<div className="text-xs text-text-secondary">Sévérité</div>
<div className={`font-bold ${getSeverityColor(selectedEvent.severity)}`}>
{selectedEvent.severity}
</div>
</div>
)}
{selectedEvent.ip && (
<div>
<div className="text-xs text-text-secondary">IP</div>
<div className="text-text-primary font-mono text-sm">{selectedEvent.ip}</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => setSelectedEvent(null)}
className="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-primary/80"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}