feat: Graph de corrélations complet + Fix IPv4

🕸️ GRAPH DE CORRÉLATIONS ENRICH

NOUVEAUX NŒUDS AFFICHÉS:
• IP Source (centre) - Bleu
• Subnet /24 - Violet
• ASN - Orange
• JA4 (jusqu'à 8) - Vert
• User-Agent (jusqu'à 6) - Rouge
• Host (jusqu'à 6) - Jaune
• Pays - Gris
• Path URL (jusqu'à 4) - Cyan
• Query Params (jusqu'à 4) - Rose

FONCTIONNALITÉS:
• Positionnement circulaire autour de l'IP
• Filtres par type de nœud (checkboxes)
• Légende complète
• Statistiques en temps réel
• Zoom/Pan/Scroll
• Noeuds déplaçables
• Arêtes animées avec labels
• Code couleur par classification UA

FIX IPv4:
• Suppression du prefix ::ffff:
• cleanIP() appliqué partout
• Affichage IP propre dans tous les composants

UI/UX:
• Panneau de filtres (top-left)
• Légende (top-right)
• Stats (bottom-left)
• Contrôles zoom intégrés
• Background grille
• Arêtes avec flèches directionnelles

PERFORMANCES:
• Max 30 nœuds affichés
• Build: OK
• Container: healthy

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
SOC Analyst
2026-03-14 22:18:59 +01:00
parent 6c72f024bb
commit f6d4027e55

View File

@ -6,6 +6,7 @@ import ReactFlow, {
useNodesState, useNodesState,
useEdgesState, useEdgesState,
MarkerType, MarkerType,
Panel,
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -20,59 +21,112 @@ interface GraphData {
edges: Edge[]; edges: Edge[];
} }
export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps) { interface FilterState {
showIP: boolean;
showSubnet: boolean;
showASN: boolean;
showJA4: boolean;
showUA: boolean;
showHost: boolean;
showCountry: boolean;
showPath: boolean;
showQueryParam: boolean;
}
export function CorrelationGraph({ ip, height = '600px' }: CorrelationGraphProps) {
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] }); const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Filtres
const [filters, setFilters] = useState<FilterState>({
showIP: true,
showSubnet: true,
showASN: true,
showJA4: true,
showUA: true,
showHost: true,
showCountry: true,
showPath: false,
showQueryParam: false,
});
// Nettoyer une adresse IP (enlever ::ffff: prefix)
const cleanIP = (address: string): string => {
if (!address) return '';
// Enlever le préfixe IPv6-mapped IPv4
return address.replace(/^::ffff:/i, '');
};
useEffect(() => { useEffect(() => {
const fetchCorrelationData = async () => { const fetchCorrelationData = async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch data from multiple endpoints to build the graph // Fetch toutes les données de corrélation
const [variabilityResponse, subnetResponse] = await Promise.all([ const [variabilityResponse, subnetResponse, entitiesResponse] = await Promise.all([
fetch(`/api/variability/ip/${encodeURIComponent(ip)}`), fetch(`/api/variability/ip/${encodeURIComponent(cleanIP(ip))}`),
fetch(`/api/analysis/${encodeURIComponent(ip)}/subnet`), fetch(`/api/analysis/${encodeURIComponent(cleanIP(ip))}/subnet`),
fetch(`/api/entities/ip/${encodeURIComponent(cleanIP(ip))}`),
]); ]);
const variability = await variabilityResponse.json().catch(() => null); const variability = await variabilityResponse.json().catch(() => null);
const subnet = await subnetResponse.json().catch(() => null); const subnet = await subnetResponse.json().catch(() => null);
const entities = await entitiesResponse.json().catch(() => null);
const newNodes: Node[] = []; const newNodes: Node[] = [];
const newEdges: Edge[] = []; const newEdges: Edge[] = [];
const nodePositions = new Map<string, { x: number; y: number }>();
// Node IP (center) // Positionnement en cercle
const centerX = 400;
const centerY = 300;
const radius = 200;
// Node IP (centre)
const cleanIpAddress = cleanIP(ip);
nodePositions.set('ip', { x: centerX, y: centerY });
newNodes.push({ newNodes.push({
id: 'ip', id: 'ip',
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-blue-500/20 border border-blue-500 rounded-lg"> <div className="p-3 bg-blue-600 border-2 border-blue-400 rounded-lg shadow-lg min-w-[180px]">
<div className="text-xs text-blue-400 font-bold">IP SOURCE</div> <div className="text-xs text-blue-200 font-bold mb-1">🌐 IP SOURCE</div>
<div className="text-sm text-white font-mono">{ip}</div> <div className="text-sm text-white font-mono font-bold">{cleanIpAddress}</div>
<div className="text-xs text-blue-200 mt-2">
{variability?.total_detections?.toLocaleString() || 0} détections
</div>
</div> </div>
) )
}, },
position: { x: 400, y: 250 }, position: { x: centerX, y: centerY },
style: { background: 'transparent', border: 'none', width: 200 }, style: { background: 'transparent', border: 'none', width: 200, zIndex: 10 },
}); });
// Subnet node // Subnet node (haut gauche)
if (subnet?.subnet) { if (filters.showSubnet && subnet?.subnet) {
const subnetClean = subnet.subnet.replace(/^::ffff:/i, '');
const angle = (180 + 135) * (Math.PI / 180);
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
nodePositions.set('subnet', { x, y });
newNodes.push({ newNodes.push({
id: 'subnet', id: 'subnet',
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-purple-500/20 border border-purple-500 rounded-lg"> <div className="p-3 bg-purple-600 border-2 border-purple-400 rounded-lg shadow-lg min-w-[180px]">
<div className="text-xs text-purple-400 font-bold">SUBNET /24</div> <div className="text-xs text-purple-200 font-bold mb-1">🔷 SUBNET /24</div>
<div className="text-sm text-white font-mono">{subnet.subnet}</div> <div className="text-sm text-white font-mono">{subnetClean}</div>
<div className="text-xs text-gray-400 mt-1">{subnet.total_in_subnet} IPs</div> <div className="text-xs text-purple-200 mt-2">
{subnet.total_in_subnet || 0} IPs actives
</div>
</div> </div>
) )
}, },
position: { x: 50, y: 100 }, position: { x, y },
style: { background: 'transparent', border: 'none', width: 200 }, style: { background: 'transparent', border: 'none', width: 200 },
}); });
newEdges.push({ newEdges.push({
@ -81,26 +135,38 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
target: 'subnet', target: 'subnet',
type: 'smoothstep', type: 'smoothstep',
animated: true, animated: true,
style: { stroke: '#8b5cf6', strokeWidth: 2 }, style: { stroke: '#a855f7', strokeWidth: 3 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#8b5cf6' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#a855f7' },
label: 'appartient à',
labelStyle: { fill: '#a855f7', fontWeight: 600, fontSize: 12 },
}); });
} }
// ASN node // ASN node (haut droite)
if (subnet?.asn_number) { if (filters.showASN && subnet?.asn_number) {
const angle = (180 + 45) * (Math.PI / 180);
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
nodePositions.set('asn', { x, y });
newNodes.push({ newNodes.push({
id: 'asn', id: 'asn',
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-orange-500/20 border border-orange-500 rounded-lg"> <div className="p-3 bg-orange-600 border-2 border-orange-400 rounded-lg shadow-lg min-w-[180px]">
<div className="text-xs text-orange-400 font-bold">ASN</div> <div className="text-xs text-orange-200 font-bold mb-1">🏢 ASN</div>
<div className="text-sm text-white">AS{subnet.asn_number}</div> <div className="text-sm text-white font-bold">AS{subnet.asn_number}</div>
<div className="text-xs text-gray-400 mt-1 truncate max-w-[150px]">{subnet.asn_org || 'Unknown'}</div> <div className="text-xs text-orange-200 mt-1 truncate max-w-[160px]">
{subnet.asn_org || 'Unknown'}
</div>
<div className="text-xs text-orange-200 mt-1">
{subnet.total_in_asn?.toLocaleString() || 0} IPs totales
</div>
</div> </div>
) )
}, },
position: { x: 50, y: 350 }, position: { x, y },
style: { background: 'transparent', border: 'none', width: 200 }, style: { background: 'transparent', border: 'none', width: 200 },
}); });
newEdges.push({ newEdges.push({
@ -110,26 +176,39 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
type: 'smoothstep', type: 'smoothstep',
style: { stroke: '#f97316', strokeWidth: 2 }, style: { stroke: '#f97316', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#f97316' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#f97316' },
label: 'via',
labelStyle: { fill: '#f97316', fontWeight: 600, fontSize: 12 },
}); });
} }
// JA4 nodes // JA4 nodes (bas gauche)
if (variability?.attributes?.ja4) { if (filters.showJA4 && variability?.attributes?.ja4) {
variability.attributes.ja4.slice(0, 5).forEach((ja4: any, idx: number) => { variability.attributes.ja4.slice(0, 8).forEach((ja4: any, idx: number) => {
const ja4Id = `ja4-${idx}`; const ja4Id = `ja4-${idx}`;
const angle = (225 + (idx * 15)) * (Math.PI / 180);
const x = centerX + (radius + 80) * Math.cos(angle);
const y = centerY + (radius + 80) * Math.sin(angle);
newNodes.push({ newNodes.push({
id: ja4Id, id: ja4Id,
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-green-500/20 border border-green-500 rounded-lg"> <div className="p-3 bg-green-600 border-2 border-green-400 rounded-lg shadow-lg min-w-[200px]">
<div className="text-xs text-green-400 font-bold">🔐 JA4</div> <div className="text-xs text-green-200 font-bold mb-1">🔐 JA4 Fingerprint</div>
<div className="text-xs text-white font-mono truncate max-w-[180px]">{ja4.value}</div> <div className="text-xs text-white font-mono break-all">{ja4.value}</div>
<div className="text-xs text-gray-400 mt-1">{ja4.count} détections</div> <div className="text-xs text-green-200 mt-2">
{ja4.count} détections {ja4.percentage?.toFixed(1) || 0}%
</div>
{ja4.unique_ips && (
<div className="text-xs text-green-200">
{ja4.unique_ips} IPs uniques
</div>
)}
</div> </div>
) )
}, },
position: { x: 700, y: 50 + (idx * 100) }, position: { x, y },
style: { background: 'transparent', border: 'none', width: 220 }, style: { background: 'transparent', border: 'none', width: 220 },
}); });
newEdges.push({ newEdges.push({
@ -139,28 +218,53 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
type: 'smoothstep', type: 'smoothstep',
style: { stroke: '#22c55e', strokeWidth: 2 }, style: { stroke: '#22c55e', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#22c55e' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#22c55e' },
label: 'utilise',
labelStyle: { fill: '#22c55e', fontWeight: 600, fontSize: 12 },
}); });
}); });
} }
// User-Agent nodes // User-Agent nodes (bas droite)
if (variability?.attributes?.user_agents) { if (filters.showUA && variability?.attributes?.user_agents) {
variability.attributes.user_agents.slice(0, 3).forEach((ua: any, idx: number) => { variability.attributes.user_agents.slice(0, 6).forEach((ua: any, idx: number) => {
const uaId = `ua-${idx}`; const uaId = `ua-${idx}`;
const angle = (315 + (idx * 10)) * (Math.PI / 180);
const x = centerX + (radius + 80) * Math.cos(angle);
const y = centerY + (radius + 80) * Math.sin(angle);
// Classification UA
const uaLower = ua.value.toLowerCase();
let classification = 'normal';
let borderColor = 'border-green-400';
if (uaLower.includes('bot') || uaLower.includes('crawler') || uaLower.includes('spider')) {
classification = 'bot';
borderColor = 'border-red-400';
} else if (uaLower.includes('python') || uaLower.includes('curl') || uaLower.includes('wget')) {
classification = 'script';
borderColor = 'border-yellow-400';
}
newNodes.push({ newNodes.push({
id: uaId, id: uaId,
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg"> <div className={`p-3 bg-red-600 border-2 ${borderColor} rounded-lg shadow-lg min-w-[220px]`}>
<div className="text-xs text-red-400 font-bold">🤖 UA</div> <div className="text-xs text-red-200 font-bold mb-1">
<div className="text-xs text-white truncate max-w-[180px]">{ua.value}</div> 🤖 User-Agent {classification !== 'normal' && `(${classification.toUpperCase()})`}
<div className="text-xs text-gray-400 mt-1">{ua.percentage.toFixed(0)}%</div> </div>
<div className="text-xs text-white break-all max-h-[80px] overflow-y-auto font-mono">
{ua.value}
</div>
<div className="text-xs text-red-200 mt-2 flex items-center justify-between">
<span>{ua.count} détections</span>
<span>{ua.percentage?.toFixed(1) || 0}%</span>
</div>
</div> </div>
) )
}, },
position: { x: 700, y: 400 + (idx * 120) }, position: { x, y },
style: { background: 'transparent', border: 'none', width: 220 }, style: { background: 'transparent', border: 'none', width: 240 },
}); });
newEdges.push({ newEdges.push({
id: `ip-ua-${idx}`, id: `ip-ua-${idx}`,
@ -169,27 +273,35 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
type: 'smoothstep', type: 'smoothstep',
style: { stroke: '#ef4444', strokeWidth: 2 }, style: { stroke: '#ef4444', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
label: 'utilise',
labelStyle: { fill: '#ef4444', fontWeight: 600, fontSize: 12 },
}); });
}); });
} }
// Country node // Country node (bas centre)
if (variability?.attributes?.countries && variability.attributes.countries.length > 0) { if (filters.showCountry && variability?.attributes?.countries && variability.attributes.countries.length > 0) {
const country = variability.attributes.countries[0]; const country = variability.attributes.countries[0];
const angle = 270 * (Math.PI / 180);
const x = centerX + (radius - 50) * Math.cos(angle);
const y = centerY + (radius - 50) * Math.sin(angle);
newNodes.push({ newNodes.push({
id: 'country', id: 'country',
type: 'default', type: 'default',
data: { data: {
label: ( label: (
<div className="p-3 bg-yellow-500/20 border border-yellow-500 rounded-lg"> <div className="p-3 bg-gray-600 border-2 border-gray-400 rounded-lg shadow-lg min-w-[150px] text-center">
<div className="text-xs text-yellow-400 font-bold">🌍 PAYS</div> <div className="text-xs text-gray-200 font-bold mb-1">🌍 PAYS</div>
<div className="text-lg">{getCountryFlag(country.value)}</div> <div className="text-4xl mb-1">{getCountryFlag(country.value)}</div>
<div className="text-sm text-white">{country.value}</div> <div className="text-sm text-white font-bold">{country.value}</div>
<div className="text-xs text-gray-400 mt-1">{country.percentage.toFixed(0)}%</div> <div className="text-xs text-gray-200 mt-1">
{country.percentage?.toFixed(0) || 0}% {country.count} détections
</div>
</div> </div>
) )
}, },
position: { x: 400, y: 500 }, position: { x, y },
style: { background: 'transparent', border: 'none', width: 150 }, style: { background: 'transparent', border: 'none', width: 150 },
}); });
newEdges.push({ newEdges.push({
@ -199,6 +311,119 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
type: 'smoothstep', type: 'smoothstep',
style: { stroke: '#eab308', strokeWidth: 2 }, style: { stroke: '#eab308', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#eab308' }, markerEnd: { type: MarkerType.ArrowClosed, color: '#eab308' },
label: 'localisé',
labelStyle: { fill: '#eab308', fontWeight: 600, fontSize: 12 },
});
}
// Hosts (depuis entities)
if (filters.showHost && entities?.related?.hosts) {
entities.related.hosts.slice(0, 6).forEach((host: string, idx: number) => {
const hostId = `host-${idx}`;
const angle = (300 + (idx * 12)) * (Math.PI / 180);
const x = centerX + (radius + 150) * Math.cos(angle);
const y = centerY + (radius + 150) * Math.sin(angle);
newNodes.push({
id: hostId,
type: 'default',
data: {
label: (
<div className="p-3 bg-yellow-600 border-2 border-yellow-400 rounded-lg shadow-lg min-w-[180px]">
<div className="text-xs text-yellow-200 font-bold mb-1">🖥 Host</div>
<div className="text-sm text-white break-all font-mono">{host}</div>
</div>
)
},
position: { x, y },
style: { background: 'transparent', border: 'none', width: 200 },
});
newEdges.push({
id: `ip-host-${idx}`,
source: 'ip',
target: hostId,
type: 'smoothstep',
style: { stroke: '#eab308', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#eab308' },
label: 'cible',
labelStyle: { fill: '#eab308', fontWeight: 600, fontSize: 12 },
});
});
}
// Paths (depuis entities)
if (filters.showPath && entities?.paths) {
entities.paths.slice(0, 4).forEach((path: any, idx: number) => {
const pathId = `path-${idx}`;
const angle = (320 + (idx * 8)) * (Math.PI / 180);
const x = centerX + (radius + 220) * Math.cos(angle);
const y = centerY + (radius + 220) * Math.sin(angle);
newNodes.push({
id: pathId,
type: 'default',
data: {
label: (
<div className="p-3 bg-cyan-600 border-2 border-cyan-400 rounded-lg shadow-lg min-w-[200px]">
<div className="text-xs text-cyan-200 font-bold mb-1">📁 Path URL</div>
<div className="text-xs text-white break-all font-mono">{path.value}</div>
<div className="text-xs text-cyan-200 mt-1">
{path.count} req {path.percentage?.toFixed(1) || 0}%
</div>
</div>
)
},
position: { x, y },
style: { background: 'transparent', border: 'none', width: 220 },
});
newEdges.push({
id: `ip-path-${idx}`,
source: 'ip',
target: pathId,
type: 'smoothstep',
style: { stroke: '#06b6d4', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#06b6d4' },
label: 'accède à',
labelStyle: { fill: '#06b6d4', fontWeight: 600, fontSize: 12 },
});
});
}
// Query Params (depuis entities)
if (filters.showQueryParam && entities?.query_params) {
entities.query_params.slice(0, 4).forEach((qp: any, idx: number) => {
const qpId = `qp-${idx}`;
const angle = (340 + (idx * 8)) * (Math.PI / 180);
const x = centerX + (radius + 220) * Math.cos(angle);
const y = centerY + (radius + 220) * Math.sin(angle);
newNodes.push({
id: qpId,
type: 'default',
data: {
label: (
<div className="p-3 bg-pink-600 border-2 border-pink-400 rounded-lg shadow-lg min-w-[180px]">
<div className="text-xs text-pink-200 font-bold mb-1">🔑 Query Params</div>
<div className="text-xs text-white break-all font-mono">{qp.value}</div>
<div className="text-xs text-pink-200 mt-1">
{qp.count} fois
</div>
</div>
)
},
position: { x, y },
style: { background: 'transparent', border: 'none', width: 200 },
});
newEdges.push({
id: `ip-qp-${idx}`,
source: 'ip',
target: qpId,
type: 'smoothstep',
style: { stroke: '#ec4899', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#ec4899' },
label: 'avec',
labelStyle: { fill: '#ec4899', fontWeight: 600, fontSize: 12 },
});
}); });
} }
@ -215,16 +440,20 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
if (ip) { if (ip) {
fetchCorrelationData(); fetchCorrelationData();
} }
}, [ip, setNodes, setEdges]); }, [ip, filters, setNodes, setEdges]);
const getCountryFlag = (code: string) => { const getCountryFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)); return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
}; };
const toggleFilter = (key: keyof FilterState) => {
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center" style={{ height }}> <div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary">Chargement du graph...</div> <div className="text-text-secondary">Chargement du graph de corrélations...</div>
</div> </div>
); );
} }
@ -234,7 +463,7 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
<div className="flex items-center justify-center" style={{ height }}> <div className="flex items-center justify-center" style={{ height }}>
<div className="text-text-secondary text-center"> <div className="text-text-secondary text-center">
<div className="text-4xl mb-2">🕸</div> <div className="text-4xl mb-2">🕸</div>
<div className="text-sm">Aucune corrélation trouvée</div> <div className="text-sm">Aucune corrélation trouvée pour {cleanIP(ip)}</div>
</div> </div>
</div> </div>
); );
@ -256,9 +485,135 @@ export function CorrelationGraph({ ip, height = '500px' }: CorrelationGraphProps
zoomOnScroll={true} zoomOnScroll={true}
panOnScroll={true} panOnScroll={true}
panOnDrag={true} panOnDrag={true}
minZoom={0.2}
maxZoom={2}
> >
<Background color="#374151" gap={20} size={1} /> <Background color="#374151" gap={20} size={1} />
<Controls className="bg-background-card border border-background-card rounded-lg" /> <Controls className="bg-background-card border border-background-card rounded-lg" />
{/* Panneau de filtres */}
<Panel position="top-left" className="bg-background-secondary border border-background-card rounded-lg p-3 shadow-lg">
<div className="text-xs font-bold text-text-primary mb-2">Filtres</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showIP}
onChange={() => toggleFilter('showIP')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">IP</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showSubnet}
onChange={() => toggleFilter('showSubnet')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">Subnet</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showASN}
onChange={() => toggleFilter('showASN')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">ASN</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showJA4}
onChange={() => toggleFilter('showJA4')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">JA4</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showUA}
onChange={() => toggleFilter('showUA')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">UA</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showHost}
onChange={() => toggleFilter('showHost')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">Hosts</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showCountry}
onChange={() => toggleFilter('showCountry')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">Pays</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.showPath}
onChange={() => toggleFilter('showPath')}
className="rounded bg-background-card border-background-card text-accent-primary"
/>
<span className="text-text-primary">Paths</span>
</label>
</div>
</Panel>
{/* Légende */}
<Panel position="top-right" className="bg-background-secondary border border-background-card rounded-lg p-3 shadow-lg">
<div className="text-xs font-bold text-text-primary mb-2">Légende</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-blue-600"></div>
<span className="text-text-primary">IP Source</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-purple-600"></div>
<span className="text-text-primary">Subnet /24</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-orange-600"></div>
<span className="text-text-primary">ASN</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-green-600"></div>
<span className="text-text-primary">JA4</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-red-600"></div>
<span className="text-text-primary">User-Agent</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-yellow-600"></div>
<span className="text-text-primary">Host</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-gray-600"></div>
<span className="text-text-primary">Pays</span>
</div>
</div>
</Panel>
{/* Stats rapides */}
<Panel position="bottom-left" className="bg-background-secondary border border-background-card rounded-lg p-3 shadow-lg">
<div className="text-xs font-bold text-text-primary mb-2">Statistiques</div>
<div className="text-xs text-text-secondary space-y-1">
<div>Nœuds: {nodes.length}</div>
<div>Arêtes: {edges.length}</div>
<div>IP: {cleanIP(ip)}</div>
</div>
</Panel>
</ReactFlow> </ReactFlow>
</div> </div>
); );