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>
655 lines
27 KiB
TypeScript
655 lines
27 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|