Files
dashboard/frontend/src/components/ClusteringView.tsx
SOC Analyst fc3392779b feat: slider sensibilité + z-score standardization pour clustering plus précis
Sensibilité (0.5x–3.0x) :
- Multiplie k : sensibilité=2x avec k=14 → 28 clusters effectifs
- Labels UI : Grossière / Normale / Fine / Très fine / Maximum
- Paramètres avancés (k, fenêtre) masqués dans un <details>
- Cache invalidé si sensibilité change

Z-score standardisation (Bishop 2006 PRML §9.1) :
- Normalise par variance de chaque feature avant K-means
- Features discriminantes (forte std) pèsent plus
- Résultat : risque 0→1.00 sur clusters bots vs 0→0.27 avant
- Bots détectés : 4 337 IPs vs 1 604 (2.7x plus)
- Nouveaux clusters : Bot agressif, Tunnel réseau, UA-CH Mismatch distincts

Fix TextLayer deck.gl :
- Translittération des accents (é→e, à→a, ç→c…) + strip emojis
- Évite les warnings 'Missing character' sur caractères non-ASCII

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 10:07:23 +01:00

655 lines
27 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.

/**
* 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<ClusterResult | null>(null);
const [loading, setLoading] = useState(false);
const [computing, setComputing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<ClusterNode | null>(null);
const [clusterPoints, setClusterPoints] = useState<IPPoint[]>([]);
const [ipDetails, setIpDetails] = useState<IPDetail[]>([]);
const [ipPage, setIpPage] = useState(0);
const [ipTotal, setIpTotal] = useState(0);
const [showEdges, setShowEdges] = useState(false);
const pollRef = useRef<ReturnType<typeof setTimeout> | 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<ClusterResult>('/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<string, string> = { é:'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 (
<div className="flex h-full overflow-hidden bg-background text-text-primary">
{/* ── Panneau gauche ── */}
<div className="flex flex-col w-72 flex-shrink-0 border-r border-gray-700 overflow-y-auto p-4 gap-4 z-10">
<div>
<h2 className="text-lg font-bold mb-1">🔬 Clustering IPs</h2>
<p className="text-xs text-text-secondary">Rendu WebGL · K-means++ sur toutes les IPs</p>
</div>
{/* Paramètres */}
<div className="bg-background-card rounded-lg p-3 space-y-3">
{/* Sensibilité */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-text-secondary">
<span>Sensibilité</span>
<span className="font-mono text-white">
{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)
</span>
</div>
<input type="range" min={0.5} max={3.0} step={0.5} value={sensitivity}
onChange={e => setSensitivity(+e.target.value)}
className="w-full accent-accent-primary" />
<div className="flex justify-between text-xs text-text-disabled">
<span>Grossière</span><span>Maximum</span>
</div>
</div>
{/* k avancé */}
<details className="text-xs text-text-secondary">
<summary className="cursor-pointer hover:text-white">Paramètres avancés</summary>
<div className="mt-2 space-y-2">
<label className="block">
Clusters de base (k)
<input type="range" min={4} max={30} value={k}
onChange={e => setK(+e.target.value)}
className="w-full mt-1 accent-accent-primary" />
<span className="font-mono text-white">{k}</span>
</label>
<label className="block">
Fenêtre
<select value={hours} onChange={e => setHours(+e.target.value)}
className="w-full mt-1 bg-background border border-gray-600 rounded px-2 py-1">
<option value={6}>6h</option>
<option value={12}>12h</option>
<option value={24}>24h</option>
<option value={48}>48h</option>
<option value={168}>7j</option>
</select>
</label>
</div>
</details>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer">
<input type="checkbox" checked={showEdges} onChange={e => setShowEdges(e.target.checked)}
className="accent-accent-primary" />
Afficher les arêtes
</label>
<button onClick={() => fetchClusters(true)}
disabled={loading || computing}
className="w-full py-2 bg-accent-primary text-white rounded text-sm font-medium hover:opacity-90 disabled:opacity-50">
{computing ? '⏳ Calcul…' : loading ? '⏳ Chargement…' : '🔄 Recalculer'}
</button>
</div>
{/* Stats globales */}
{data?.stats && (
<div className="bg-background-card rounded-lg p-3 space-y-1 text-xs">
<div className="font-semibold text-sm mb-2">Résultats</div>
<Stat label="Clusters" value={data.stats.total_clusters} />
<Stat label="IPs totales" value={data.stats.total_ips.toLocaleString()} />
<Stat label="IPs bots 🤖" value={data.stats.bot_ips.toLocaleString()} color="text-red-400" />
<Stat label="Risque élevé" value={data.stats.high_risk_ips.toLocaleString()} color="text-orange-400" />
<Stat label="Hits totaux" value={data.stats.total_hits.toLocaleString()} />
<Stat label="Calcul" value={`${data.stats.elapsed_s}s`} />
</div>
)}
{/* Message computing */}
{computing && (
<div className="bg-yellow-900/30 border border-yellow-600/40 rounded-lg p-3 text-xs text-yellow-300">
Calcul en cours sur {data?.stats?.n_samples?.toLocaleString() ?? '…'} IPs
<br />Mise à jour automatique toutes les 3s
</div>
)}
{error && (
<div className="bg-red-900/30 border border-red-600/40 rounded p-3 text-xs text-red-300">
{error}
</div>
)}
{/* Liste clusters */}
{data?.nodes && (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-semibold uppercase tracking-wide">Clusters</div>
{[...data.nodes]
.sort((a, b) => b.risk_score - a.risk_score)
.map(n => (
<button key={n.id} onClick={() => handleSelectCluster(n)}
className={`w-full text-left px-3 py-2 rounded text-xs flex items-center gap-2 transition-colors
${selected?.id === n.id ? 'bg-accent-primary/20 ring-1 ring-accent-primary' : 'hover:bg-background-secondary'}`}>
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: n.color }} />
<span className="flex-1 truncate">{n.label}</span>
<span className="text-text-disabled">{n.ip_count.toLocaleString()}</span>
</button>
))}
</div>
)}
</div>
{/* ── Canvas WebGL (deck.gl) ── */}
<div className="flex-1 relative">
{!data && !loading && !computing && (
<div className="absolute inset-0 flex items-center justify-center text-text-secondary">
Cliquez sur <strong className="mx-1 text-white">Recalculer</strong> pour démarrer
</div>
)}
<DeckGL
views={new OrthographicView({ id: 'ortho', controller: true })}
viewState={viewState}
onViewStateChange={({ viewState: vs }) => setViewState(vs as typeof viewState)}
layers={layers as any}
style={{ width: '100%', height: '100%' }}
controller={true}
>
{/* Légende overlay */}
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1">
{[['#dc2626', 'CRITICAL'], ['#f97316', 'HIGH'], ['#eab308', 'MEDIUM'], ['#22c55e', 'LOW']].map(([c, l]) => (
<div key={l} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full" style={{ background: c }} />
<span className="text-white/80">{l}</span>
</div>
))}
</div>
</div>
{/* Tooltip zoom hint */}
<div style={{ position: 'absolute', bottom: 16, right: selected ? 320 : 16, pointerEvents: 'none' }}>
<div className="text-xs text-white/40">Scroll pour zoomer · Drag pour déplacer · Click sur un cluster</div>
</div>
</DeckGL>
</div>
{/* ── Sidebar droite (sélection) ── */}
{selected && (
<ClusterSidebar
node={selected}
ipDetails={ipDetails}
ipTotal={ipTotal}
ipPage={ipPage}
clusterPoints={clusterPoints}
onClose={() => { setSelected(null); setClusterPoints([]); setIpDetails([]); }}
onPageChange={(p) => loadClusterIPs(selected, p)}
/>
)}
</div>
);
}
// ─── Stat helper ─────────────────────────────────────────────────────────────
function Stat({ label, value, color }: { label: string; value: string | number; color?: string }) {
return (
<div className="flex justify-between items-center">
<span className="text-text-secondary">{label}</span>
<span className={`font-mono font-semibold ${color ?? 'text-white'}`}>{value}</span>
</div>
);
}
// ─── 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 (
<div className="w-96 flex-shrink-0 border-l border-gray-700 bg-background-secondary flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<div>
<div className="font-bold text-sm">{node.label}</div>
<div className="text-xs text-text-secondary">{node.ip_count.toLocaleString()} IPs · {node.hit_count.toLocaleString()} hits</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${riskClass(node.risk_score)}`}>{riskLabel(node.risk_score)}</span>
<button onClick={onClose} className="text-text-secondary hover:text-white text-lg leading-none">×</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Score risque */}
<div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2">Score de risque</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-3 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${node.risk_score * 100}%`, background: node.color }} />
</div>
<span className={`text-sm font-bold ${riskClass(node.risk_score)}`}>
{(node.risk_score * 100).toFixed(0)}%
</span>
</div>
</div>
{/* Radar chart */}
{node.radar?.length > 0 && (
<div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2">Profil 21 features</div>
<ResponsiveContainer width="100%" height={200}>
<RadarChart data={node.radar}>
<PolarGrid stroke="#374151" />
<PolarAngleAxis dataKey="feature" tick={{ fill: '#9ca3af', fontSize: 8 }} />
<Radar dataKey="value" stroke={node.color} fill={node.color} fillOpacity={0.25} />
<Tooltip
contentStyle={{ background: '#1f2937', border: '1px solid #374151', borderRadius: 8, fontSize: 11 }}
formatter={(v: number) => [`${(v * 100).toFixed(1)}%`, '']}
/>
</RadarChart>
</ResponsiveContainer>
</div>
)}
{/* TCP stack */}
<div className="bg-background-card rounded-lg p-3 text-xs space-y-1">
<div className="font-semibold mb-2">Stack TCP</div>
<Stat label="TTL moyen" value={node.mean_ttl} />
<Stat label="MSS moyen" value={node.mean_mss} />
<Stat label="Score ML" value={`${(node.mean_score * 100).toFixed(1)}%`} />
<Stat label="Vélocité" value={node.mean_velocity?.toFixed ? `${node.mean_velocity.toFixed(2)} rps` : '-'} />
<Stat label="Headless" value={node.mean_headless ? `${(node.mean_headless * 100).toFixed(0)}%` : '-'} />
<Stat label="UA-CH Mismatch" value={node.mean_ua_ch ? `${(node.mean_ua_ch * 100).toFixed(0)}%` : '-'} />
</div>
{/* Contexte */}
{(node.top_countries?.length > 0 || node.top_orgs?.length > 0) && (
<div className="bg-background-card rounded-lg p-3 text-xs space-y-2">
<div className="font-semibold">Géographie & AS</div>
{node.top_countries?.length > 0 && (
<div className="flex flex-wrap gap-1">
{node.top_countries.map(c => (
<span key={c} className="bg-blue-900/40 border border-blue-700/40 rounded px-2 py-0.5">{c}</span>
))}
</div>
)}
{node.top_orgs?.length > 0 && (
<div className="space-y-1">
{node.top_orgs.map(o => (
<div key={o} className="truncate text-text-secondary">{o}</div>
))}
</div>
)}
</div>
)}
{/* IPs paginées */}
<div className="bg-background-card rounded-lg p-3 text-xs">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold">IPs ({ipTotal.toLocaleString()})</span>
<button onClick={exportCSV} className="text-accent-primary hover:underline text-xs">CSV </button>
</div>
{ipDetails.length === 0 ? (
<div className="text-text-disabled">Chargement</div>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto font-mono">
{ipDetails.map(ip => (
<div key={ip.ip + ip.ja4} className="flex items-center gap-2 py-0.5 border-b border-gray-800/50">
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
ip.avg_score > 0.45 ? 'bg-red-500' : ip.avg_score > 0.25 ? 'bg-orange-400' : 'bg-green-500'
}`}
/>
<a href={`/investigation/ip/${ip.ip}`}
className="text-blue-400 hover:underline flex-1 truncate">{ip.ip}</a>
<span className="text-text-disabled">{ip.country_code}</span>
<span className="text-text-disabled">{ip.hits}</span>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-2">
<button onClick={() => onPageChange(ipPage - 1)} disabled={ipPage === 0}
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30"></button>
<span className="text-text-disabled">{ipPage + 1} / {totalPages}</span>
<button onClick={() => onPageChange(ipPage + 1)} disabled={ipPage >= totalPages - 1}
className="px-2 py-0.5 bg-gray-700 rounded disabled:opacity-30"></button>
</div>
)}
</div>
{/* Points info */}
{clusterPoints.length > 0 && (
<div className="text-xs text-text-secondary text-center pb-2">
{clusterPoints.length.toLocaleString()} IPs affichées en WebGL
</div>
)}
</div>
</div>
);
}