/** * ClusteringView — Visualisation WebGL des clusters d'IPs via deck.gl * * Architecture LOD : * - Vue globale : PolygonLayer (hulls) + ScatterplotLayer (centroïdes) * - Sur sélection : ScatterplotLayer dense (toutes les IPs du cluster) * - Sidebar : profil radar, stats, liste IPs paginée * * Rendu WebGL via @deck.gl/react + OrthographicView */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import DeckGL from '@deck.gl/react'; import { OrthographicView } from '@deck.gl/core'; import { ScatterplotLayer, PolygonLayer, TextLayer, LineLayer } from '@deck.gl/layers'; import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, Tooltip } from 'recharts'; import axios from 'axios'; // ─── Types ──────────────────────────────────────────────────────────────────── interface RadarEntry { feature: string; value: number; } interface ClusterNode { id: string; cluster_idx: number; label: string; pca_x: number; pca_y: number; radius: number; color: string; risk_score: number; ip_count: number; hit_count: number; mean_ttl: number; mean_mss: number; mean_score: number; mean_velocity: number; mean_fuzzing: number; mean_headless: number; mean_ua_ch: number; top_threat: string; top_countries: string[]; top_orgs: string[]; sample_ips: string[]; sample_ua: string; radar: RadarEntry[]; hull: [number, number][]; } interface ClusterEdge { id: string; source: string; target: string; similarity: number; } interface ClusterStats { total_clusters: number; total_ips: number; total_hits: number; bot_ips: number; high_risk_ips: number; n_samples: number; k: number; elapsed_s: number; } interface ClusterResult { status: string; nodes: ClusterNode[]; edges: ClusterEdge[]; stats: ClusterStats; feature_names: string[]; message?: string; } interface IPPoint { ip: string; ja4: string; pca_x: number; pca_y: number; risk: number; } interface IPDetail { ip: string; ja4: string; tcp_ttl: number; tcp_mss: number; hits: number; ua: string; avg_score: number; threat_level: string; country_code: string; asn_org: string; } // ─── Coordonnées deck.gl ───────────────────────────────────────────────────── // PCA normalisé [0,1] → world [0, WORLD] const WORLD = 1000; function toWorld(v: number): number { return v * WORLD; } // Couleur hex → [r,g,b,a] function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return [r, g, b, alpha]; } // ─── Composant principal ────────────────────────────────────────────────────── export default function ClusteringView() { const [k, setK] = useState(14); const [hours, setHours] = useState(24); const [sensitivity, setSensitivity] = useState(1.0); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [computing, setComputing] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [clusterPoints, setClusterPoints] = useState([]); const [ipDetails, setIpDetails] = useState([]); const [ipPage, setIpPage] = useState(0); const [ipTotal, setIpTotal] = useState(0); const [showEdges, setShowEdges] = useState(false); const pollRef = useRef | null>(null); // Viewport deck.gl — centré à [WORLD/2, WORLD/2] const [viewState, setViewState] = useState({ target: [WORLD / 2, WORLD / 2, 0] as [number, number, number], zoom: -0.5, // montre légèrement plus que le monde [0,WORLD] minZoom: -3, maxZoom: 6, }); // ── Chargement / polling ───────────────────────────────────────────────── const fetchClusters = useCallback(async (force = false) => { setLoading(true); setError(null); try { const res = await axios.get('/api/clustering/clusters', { params: { k, hours, sensitivity, force }, }); if (res.data.status === 'computing' || res.data.status === 'idle') { setComputing(true); // Polling pollRef.current = setTimeout(() => fetchClusters(), 3000); } else { setComputing(false); setData(res.data); // Fit viewport if (res.data.nodes?.length) { const xs = res.data.nodes.map(n => toWorld(n.pca_x)); const ys = res.data.nodes.map(n => toWorld(n.pca_y)); const minX = Math.min(...xs), maxX = Math.max(...xs); const minY = Math.min(...ys), maxY = Math.max(...ys); const pad = 0.18; // 18% de marge de chaque côté const fitW = (maxX - minX) * (1 + 2 * pad) || WORLD; const fitH = (maxY - minY) * (1 + 2 * pad) || WORLD; const canvasW = window.innerWidth - 288 - (selected ? 384 : 0); // panel + sidebar const canvasH = window.innerHeight - 60; setViewState(v => ({ ...v, target: [(minX + maxX) / 2, (minY + maxY) / 2, 0], zoom: Math.min( Math.log2(canvasW / fitW), Math.log2(canvasH / fitH), ), })); } } } catch (e: unknown) { setError((e as Error).message); setComputing(false); } finally { setLoading(false); } }, [k, hours]); useEffect(() => { fetchClusters(); return () => { if (pollRef.current) clearTimeout(pollRef.current); }; }, []); // eslint-disable-line // ── Drill-down : chargement des points du cluster sélectionné ─────────── const loadClusterPoints = useCallback(async (node: ClusterNode) => { try { const res = await axios.get<{ points: IPPoint[]; total: number }>( `/api/clustering/cluster/${node.id}/points`, { params: { limit: 10000, offset: 0 } } ); setClusterPoints(res.data.points); } catch { setClusterPoints([]); } }, []); const loadClusterIPs = useCallback(async (node: ClusterNode, page = 0) => { try { const res = await axios.get<{ ips: IPDetail[]; total: number }>( `/api/clustering/cluster/${node.id}/ips`, { params: { limit: 50, offset: page * 50 } } ); setIpDetails(res.data.ips); setIpTotal(res.data.total); setIpPage(page); } catch { setIpDetails([]); } }, []); const handleSelectCluster = useCallback((node: ClusterNode) => { setSelected(node); setClusterPoints([]); setIpDetails([]); loadClusterPoints(node); loadClusterIPs(node, 0); }, [loadClusterPoints, loadClusterIPs]); // ── Layers deck.gl ───────────────────────────────────────────────────── const layers = React.useMemo(() => { if (!data?.nodes) return []; const nodes = data.nodes; const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])); const layerList: object[] = []; // 1. Hulls (enveloppes convexes) — toujours visibles const hullData = nodes .filter(n => n.hull && n.hull.length >= 3) .map(n => ({ ...n, polygon: n.hull.map(([x, y]) => [toWorld(x), toWorld(y)]), })); layerList.push(new PolygonLayer({ id: 'hulls', data: hullData, getPolygon: (d: typeof hullData[number]) => d.polygon, getFillColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 55 : 28), getLineColor: (d: typeof hullData[number]) => hexToRgba(d.color, d.id === selected?.id ? 220 : 130), getLineWidth: (d: typeof hullData[number]) => d.id === selected?.id ? 3 : 1.5, lineWidthUnits: 'pixels', stroked: true, filled: true, pickable: true, autoHighlight: true, highlightColor: [255, 255, 255, 30], onClick: ({ object }: { object?: typeof hullData[number] }) => { if (object) handleSelectCluster(object as ClusterNode); }, updateTriggers: { getFillColor: [selected?.id], getLineColor: [selected?.id], getLineWidth: [selected?.id] }, })); // 2. Arêtes inter-clusters (optionnelles) if (showEdges && data.edges) { const edgeData = data.edges .map(e => { const s = nodeMap[e.source]; const t = nodeMap[e.target]; if (!s || !t) return null; return { source: [toWorld(s.pca_x), toWorld(s.pca_y)], target: [toWorld(t.pca_x), toWorld(t.pca_y)], sim: e.similarity }; }) .filter(Boolean) as { source: [number, number]; target: [number, number]; sim: number }[]; layerList.push(new LineLayer({ id: 'edges', data: edgeData, getSourcePosition: d => d.source, getTargetPosition: d => d.target, getColor: [100, 100, 120, 80], getWidth: 1, widthUnits: 'pixels', })); } // 3. Points IPs du cluster sélectionné if (selected && clusterPoints.length > 0) { layerList.push(new ScatterplotLayer({ id: 'ip-points', data: clusterPoints, getPosition: (d: IPPoint) => [toWorld(d.pca_x), toWorld(d.pca_y), 0], getRadius: 3, radiusUnits: 'pixels', getFillColor: (d: IPPoint) => { const r = d.risk; if (r > 0.70) return [220, 38, 38, 200]; if (r > 0.45) return [249, 115, 22, 200]; if (r > 0.25) return [234, 179, 8, 200]; return [34, 197, 94, 180]; }, pickable: false, updateTriggers: { getPosition: [clusterPoints.length] }, })); } // 4. Centroïdes (cercles de taille ∝ ip_count) layerList.push(new ScatterplotLayer({ id: 'centroids', data: nodes, getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0], getRadius: (d: ClusterNode) => d.radius, radiusUnits: 'pixels', getFillColor: (d: ClusterNode) => hexToRgba(d.color, d.id === selected?.id ? 255 : 180), getLineColor: [255, 255, 255, 180], getLineWidth: (d: ClusterNode) => d.id === selected?.id ? 3 : 1, lineWidthUnits: 'pixels', stroked: true, filled: true, pickable: true, autoHighlight: true, highlightColor: [255, 255, 255, 60], onClick: ({ object }: { object?: ClusterNode }) => { if (object) handleSelectCluster(object); }, updateTriggers: { getFillColor: [selected?.id], getLineWidth: [selected?.id] }, })); const stripNonAscii = (s: string) => s.replace(/[\u{0080}-\u{FFFF}]/gu, c => { // Translitérations basiques pour la lisibilité const map: Record = { é:'e',è:'e',ê:'e',ë:'e',à:'a',â:'a',ô:'o',ù:'u',û:'u',î:'i',ï:'i',ç:'c' }; return map[c] ?? ''; }).replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}]/gu, '').trim(); layerList.push(new TextLayer({ id: 'labels', data: nodes, getPosition: (d: ClusterNode) => [toWorld(d.pca_x), toWorld(d.pca_y), 0], getText: (d: ClusterNode) => stripNonAscii(d.label), getSize: 12, sizeUnits: 'pixels', getColor: [255, 255, 255, 200], getAnchor: 'middle', getAlignmentBaseline: 'top', getPixelOffset: (d: ClusterNode) => [0, d.radius + 4], fontFamily: 'monospace', background: true, getBorderColor: [0, 0, 0, 0], backgroundPadding: [3, 1, 3, 1], getBackgroundColor: [15, 20, 30, 180], })); return layerList; }, [data, selected, clusterPoints, showEdges, handleSelectCluster]); // ── Rendering ──────────────────────────────────────────────────────────── return (
{/* ── Panneau gauche ── */}

🔬 Clustering IPs

Rendu WebGL · K-means++ sur toutes les IPs

{/* Paramètres */}
{/* Sensibilité */}
Sensibilité {sensitivity === 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 1.5 ? 'Fine' : sensitivity <= 2.0 ? 'Très fine' : 'Maximum'} {' '}({Math.round(k * sensitivity)} clusters)
setSensitivity(+e.target.value)} className="w-full accent-accent-primary" />
GrossièreMaximum
{/* k avancé */}
Paramètres avancés
{/* Stats globales */} {data?.stats && (
Résultats
)} {/* Message computing */} {computing && (
⏳ Calcul en cours sur {data?.stats?.n_samples?.toLocaleString() ?? '…'} IPs…
Mise à jour automatique toutes les 3s
)} {error && (
❌ {error}
)} {/* Liste clusters */} {data?.nodes && (
Clusters
{[...data.nodes] .sort((a, b) => b.risk_score - a.risk_score) .map(n => ( ))}
)}
{/* ── Canvas WebGL (deck.gl) ── */}
{!data && !loading && !computing && (
Cliquez sur Recalculer pour démarrer
)} setViewState(vs as typeof viewState)} layers={layers as any} style={{ width: '100%', height: '100%' }} controller={true} > {/* Légende overlay */}
{[['#dc2626', 'CRITICAL'], ['#f97316', 'HIGH'], ['#eab308', 'MEDIUM'], ['#22c55e', 'LOW']].map(([c, l]) => (
{l}
))}
{/* Tooltip zoom hint */}
Scroll pour zoomer · Drag pour déplacer · Click sur un cluster
{/* ── Sidebar droite (sélection) ── */} {selected && ( { setSelected(null); setClusterPoints([]); setIpDetails([]); }} onPageChange={(p) => loadClusterIPs(selected, p)} /> )}
); } // ─── Stat helper ───────────────────────────────────────────────────────────── function Stat({ label, value, color }: { label: string; value: string | number; color?: string }) { return (
{label} {value}
); } // ─── Sidebar détaillée ─────────────────────────────────────────────────────── function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClose, onPageChange }: { node: ClusterNode; ipDetails: IPDetail[]; ipTotal: number; ipPage: number; clusterPoints: IPPoint[]; onClose: () => void; onPageChange: (p: number) => void; }) { const riskLabel = (r: number) => r > 0.70 ? 'CRITICAL' : r > 0.45 ? 'HIGH' : r > 0.25 ? 'MEDIUM' : 'LOW'; const riskClass = (r: number) => r > 0.70 ? 'text-red-500' : r > 0.45 ? 'text-orange-500' : r > 0.25 ? 'text-yellow-400' : 'text-green-500'; const totalPages = Math.ceil(ipTotal / 50); const exportCSV = () => { const header = 'IP,JA4,TTL,MSS,Hits,Score,Menace,Pays,ASN\n'; const rows = ipDetails.map(ip => [ip.ip, ip.ja4, ip.tcp_ttl, ip.tcp_mss, ip.hits, ip.avg_score, ip.threat_level, ip.country_code, ip.asn_org].join(',') ).join('\n'); const blob = new Blob([header + rows], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `cluster_${node.id}.csv`; a.click(); }; return (
{/* Header */}
{node.label}
{node.ip_count.toLocaleString()} IPs · {node.hit_count.toLocaleString()} hits
{riskLabel(node.risk_score)}
{/* Score risque */}
Score de risque
{(node.risk_score * 100).toFixed(0)}%
{/* Radar chart */} {node.radar?.length > 0 && (
Profil 21 features
[`${(v * 100).toFixed(1)}%`, '']} />
)} {/* TCP stack */}
Stack TCP
{/* Contexte */} {(node.top_countries?.length > 0 || node.top_orgs?.length > 0) && (
Géographie & AS
{node.top_countries?.length > 0 && (
{node.top_countries.map(c => ( {c} ))}
)} {node.top_orgs?.length > 0 && (
{node.top_orgs.map(o => (
{o}
))}
)}
)} {/* IPs paginées */}
IPs ({ipTotal.toLocaleString()})
{ipDetails.length === 0 ? (
Chargement…
) : (
{ipDetails.map(ip => (
0.45 ? 'bg-red-500' : ip.avg_score > 0.25 ? 'bg-orange-400' : 'bg-green-500' }`} /> {ip.ip} {ip.country_code} {ip.hits}
))}
)} {/* Pagination */} {totalPages > 1 && (
{ipPage + 1} / {totalPages}
)}
{/* Points info */} {clusterPoints.length > 0 && (
{clusterPoints.length.toLocaleString()} IPs affichées en WebGL
)}
); }