feat: clustering multi-métriques + TCP fingerprinting amélioré
- TCP fingerprinting: 20 signatures OS (p0f-style), scoring multi-signal
TTL/MSS/scale/fenêtre, détection Masscan 97% confiance, réseau path
(Ethernet/PPPoE/VPN/Tunnel), estimation hop-count
- Clustering IPs: K-means++ (Arthur & Vassilvitskii 2007) sur 21 features
TCP stack + anomalie ML + TLS/protocole + navigateur + temporel
PCA-2D par puissance itérative (Hotelling) pour positionnement
- Visualisation redesign: 2 vues lisibles
- Tableau de bord: grille de cartes groupées par niveau de risque
(Bots / Suspects / Légitimes), métriques clés + mini-barres
- Graphe de relations: ReactFlow avec nœuds-cartes en colonnes
par niveau de menace, arêtes colorées par similarité, légende
- Sidebar: RadarChart comportemental + toutes métriques + export CSV
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
847
frontend/src/components/ClusteringView.tsx
Normal file
847
frontend/src/components/ClusteringView.tsx
Normal file
@ -0,0 +1,847 @@
|
||||
/**
|
||||
* Clustering IPs — visualisation multi-métriques
|
||||
*
|
||||
* Deux vues :
|
||||
* 1. "Cartes" (défaut) — grille de cartes triées par risque, toujours lisibles
|
||||
* 2. "Graphe" — ReactFlow avec nœuds-cartes et disposition par colonne de menace
|
||||
*
|
||||
* Chaque cluster affiche :
|
||||
* • Label + emoji de menace
|
||||
* • Compteur IPs / hits
|
||||
* • Score de risque (barre colorée)
|
||||
* • 4 métriques clés (barres horizontales)
|
||||
* • Top pays + ASN
|
||||
* • Radar dans la sidebar
|
||||
*/
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import ReactFlow, {
|
||||
Background, Controls, MiniMap, ReactFlowProvider,
|
||||
useNodesState, useEdgesState, useReactFlow,
|
||||
Node, Edge, Handle, Position, NodeProps,
|
||||
Panel,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import {
|
||||
RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis,
|
||||
ResponsiveContainer, Tooltip as RechartsTooltip,
|
||||
} from 'recharts';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ClusterNode {
|
||||
id: string;
|
||||
label: string;
|
||||
cluster_idx: number;
|
||||
x: number; y: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
risk_score: number;
|
||||
ip_count: number;
|
||||
hit_count: number;
|
||||
mean_score: number;
|
||||
mean_ua_ch: number;
|
||||
mean_ua_rotating: number;
|
||||
mean_fuzzing: number;
|
||||
mean_headless: number;
|
||||
mean_velocity: number;
|
||||
mean_ttl: number;
|
||||
mean_mss: number;
|
||||
mean_scale: number;
|
||||
mean_alpn_mismatch: number;
|
||||
mean_ip_id_zero: number;
|
||||
mean_browser_score: number;
|
||||
mean_entropy: number;
|
||||
mean_ja4_diversity: number;
|
||||
top_threat: string;
|
||||
top_countries: string[];
|
||||
top_orgs: string[];
|
||||
sample_ips: string[];
|
||||
sample_ua: string;
|
||||
radar: { feature: string; value: number }[];
|
||||
}
|
||||
|
||||
interface ClusteringData {
|
||||
nodes: ClusterNode[];
|
||||
edges: { id: string; source: string; target: string; similarity: number; weight: number }[];
|
||||
stats: {
|
||||
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 ClusterIP {
|
||||
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;
|
||||
fuzzing: number; velocity: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const THREAT_BADGE_CLASS: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-600', HIGH: 'bg-orange-500',
|
||||
MEDIUM: 'bg-yellow-500', LOW: 'bg-green-600',
|
||||
};
|
||||
|
||||
const RADAR_FEATURES = [
|
||||
'Score Anomalie', 'Vélocité (rps)', 'Fuzzing', 'Headless',
|
||||
'ALPN Mismatch', 'H2 Multiplexing', 'UA-CH Mismatch', 'UA Rotatif',
|
||||
'IP-ID Zéro', 'Entropie Temporelle',
|
||||
];
|
||||
|
||||
function ThreatBadge({ level }: { level: string }) {
|
||||
if (!level) return null;
|
||||
return (
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full text-white ${THREAT_BADGE_CLASS[level] || 'bg-gray-600'}`}>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniBar({ value, color = '#6366f1', label }: { value: number; color?: string; label?: string }) {
|
||||
const pct = Math.round(Math.min(1, Math.max(0, value)) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{label && <span className="text-text-disabled text-[10px] w-24 flex-shrink-0 truncate">{label}</span>}
|
||||
<div className="flex-1 bg-gray-700/60 rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-text-secondary w-8 text-right">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function riskColor(risk: number): string {
|
||||
if (risk >= 0.45) return '#dc2626';
|
||||
if (risk >= 0.30) return '#f97316';
|
||||
if (risk >= 0.15) return '#eab308';
|
||||
return '#22c55e';
|
||||
}
|
||||
|
||||
function riskLabel(risk: number): string {
|
||||
if (risk >= 0.45) return 'CRITIQUE';
|
||||
if (risk >= 0.30) return 'ÉLEVÉ';
|
||||
if (risk >= 0.15) return 'MODÉRÉ';
|
||||
return 'SAIN';
|
||||
}
|
||||
|
||||
// ─── Carte cluster (réutilisée dans les 2 vues) ────────────────────────────
|
||||
|
||||
function ClusterCard({
|
||||
node, selected, onClick,
|
||||
}: {
|
||||
node: ClusterNode;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const rc = riskColor(node.risk_score);
|
||||
const rl = riskLabel(node.risk_score);
|
||||
|
||||
// Normalisation anomaly_score pour la barre (valeurs ~0.3 max → étirer sur /0.5)
|
||||
const scoreN = Math.min(1, node.mean_score / 0.5);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left rounded-xl border transition-all duration-150 overflow-hidden
|
||||
${selected
|
||||
? 'ring-2 ring-offset-1 ring-offset-background-card shadow-lg scale-[1.01]'
|
||||
: 'hover:border-gray-500 hover:shadow-md'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: selected ? rc : '#374151',
|
||||
'--tw-ring-color': rc,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* Bande de couleur en haut */}
|
||||
<div
|
||||
className="h-1.5 w-full"
|
||||
style={{ backgroundColor: rc }}
|
||||
/>
|
||||
|
||||
<div className="p-3 bg-background-card space-y-2.5">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-sm text-text-primary leading-tight">{node.label}</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5">
|
||||
<span className="font-semibold text-text-primary">{node.ip_count.toLocaleString()}</span> IPs
|
||||
{' · '}
|
||||
<span>{node.hit_count.toLocaleString()}</span> req
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<span
|
||||
className="text-xs font-bold px-2 py-0.5 rounded-full text-white"
|
||||
style={{ backgroundColor: rc }}
|
||||
>
|
||||
{rl}
|
||||
</span>
|
||||
<p className="text-[10px] text-text-disabled mt-0.5">
|
||||
risque {Math.round(node.risk_score * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de risque */}
|
||||
<div className="w-full bg-gray-700/60 rounded-full h-1">
|
||||
<div
|
||||
className="h-1 rounded-full transition-all"
|
||||
style={{ width: `${node.risk_score * 100}%`, backgroundColor: rc }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4 métriques clés */}
|
||||
<div className="space-y-1">
|
||||
<MiniBar
|
||||
value={scoreN}
|
||||
color={scoreN > 0.5 ? '#dc2626' : '#f97316'}
|
||||
label="Score anomalie"
|
||||
/>
|
||||
<MiniBar
|
||||
value={node.mean_ua_ch}
|
||||
color={node.mean_ua_ch > 0.7 ? '#dc2626' : '#f97316'}
|
||||
label="UA-CH mismatch"
|
||||
/>
|
||||
<MiniBar
|
||||
value={Math.min(1, node.mean_fuzzing * 3)}
|
||||
color="#8b5cf6"
|
||||
label="Fuzzing"
|
||||
/>
|
||||
<MiniBar
|
||||
value={node.mean_ua_rotating}
|
||||
color="#ec4899"
|
||||
label="UA rotatif"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stack TCP */}
|
||||
<div className="flex gap-2 text-[10px] text-text-disabled">
|
||||
<span>TTL <b className="text-text-secondary">{Math.round(node.mean_ttl)}</b></span>
|
||||
<span>MSS <b className="text-text-secondary">{Math.round(node.mean_mss)}</b></span>
|
||||
{node.mean_scale > 0 && <span>Scale <b className="text-text-secondary">{node.mean_scale.toFixed(0)}</b></span>}
|
||||
</div>
|
||||
|
||||
{/* Pays + ASN */}
|
||||
{node.top_countries.length > 0 && (
|
||||
<p className="text-[10px] text-text-disabled truncate">
|
||||
🌍 {node.top_countries.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{node.top_orgs.slice(0, 2).map((org, i) => (
|
||||
<p key={i} className="text-[10px] text-text-disabled truncate">🏢 {org}</p>
|
||||
)).slice(0, 1)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Vue Cartes (défaut) ──────────────────────────────────────────────────────
|
||||
|
||||
function CardGridView({
|
||||
nodes, selectedId, onSelect,
|
||||
}: {
|
||||
nodes: ClusterNode[];
|
||||
selectedId: string | null;
|
||||
onSelect: (n: ClusterNode) => void;
|
||||
}) {
|
||||
const sorted = useMemo(
|
||||
() => [...nodes].sort((a, b) => b.risk_score - a.risk_score),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
// Groupes par niveau de risque
|
||||
const groups = useMemo(() => {
|
||||
const bots = sorted.filter(n => n.risk_score >= 0.45 || n.label.includes('🤖'));
|
||||
const warn = sorted.filter(n => n.risk_score >= 0.15 && n.risk_score < 0.45 && !n.label.includes('🤖'));
|
||||
const safe = sorted.filter(n => n.risk_score < 0.15 && !n.label.includes('🤖'));
|
||||
return { bots, warn, safe };
|
||||
}, [sorted]);
|
||||
|
||||
function Group({ title, color, nodes: gn }: { title: string; color: string; nodes: ClusterNode[] }) {
|
||||
if (gn.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-0.5 flex-1 rounded" style={{ backgroundColor: color }} />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest" style={{ color }}>
|
||||
{title} ({gn.length})
|
||||
</h3>
|
||||
<div className="h-0.5 flex-1 rounded" style={{ backgroundColor: color }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{gn.map(n => (
|
||||
<ClusterCard
|
||||
key={n.id}
|
||||
node={n}
|
||||
selected={selectedId === n.id}
|
||||
onClick={() => onSelect(n)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto flex-1 p-5 space-y-6">
|
||||
<Group title="Bots & Menaces Confirmées" color="#dc2626" nodes={groups.bots} />
|
||||
<Group title="Comportements Suspects" color="#f97316" nodes={groups.warn} />
|
||||
<Group title="Trafic Légitime" color="#22c55e" nodes={groups.safe} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Nœud ReactFlow (pour la vue Graphe) ─────────────────────────────────────
|
||||
|
||||
function GraphCardNode({ data }: NodeProps) {
|
||||
const rc = riskColor(data.risk_score);
|
||||
const rl = riskLabel(data.risk_score);
|
||||
const scoreN = Math.min(1, data.mean_score / 0.5);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
||||
<div
|
||||
className="rounded-xl border-2 overflow-hidden shadow-lg cursor-pointer select-none"
|
||||
style={{
|
||||
borderColor: rc,
|
||||
width: 220,
|
||||
backgroundColor: '#1e2533',
|
||||
boxShadow: data.risk_score > 0.40 ? `0 0 16px ${rc}55` : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="h-1" style={{ backgroundColor: rc }} />
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex justify-between items-start gap-1">
|
||||
<p className="text-xs font-bold text-white leading-tight flex-1">{data.label}</p>
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded text-white flex-shrink-0"
|
||||
style={{ backgroundColor: rc }}>
|
||||
{rl}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
<b className="text-white">{data.ip_count.toLocaleString()}</b> IPs ·{' '}
|
||||
{data.hit_count.toLocaleString()} req
|
||||
</p>
|
||||
{/* Barre risque */}
|
||||
<div className="w-full bg-gray-700 rounded-full h-1">
|
||||
<div className="h-1 rounded-full" style={{ width: `${data.risk_score * 100}%`, backgroundColor: rc }} />
|
||||
</div>
|
||||
{/* Mini métriques */}
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
['Anomalie', scoreN, scoreN > 0.5 ? '#dc2626' : '#f97316'],
|
||||
['UA-CH', data.mean_ua_ch, '#f97316'],
|
||||
['Fuzzing', Math.min(1, data.mean_fuzzing * 3), '#8b5cf6'],
|
||||
].map(([l, v, c]: any) => (
|
||||
<div key={l} className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 text-[9px] w-14">{l}</span>
|
||||
<div className="flex-1 bg-gray-700 rounded-full h-1">
|
||||
<div className="h-1 rounded-full" style={{ width: `${v * 100}%`, backgroundColor: c }} />
|
||||
</div>
|
||||
<span className="text-gray-400 text-[9px] w-7 text-right">{Math.round(v * 100)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.top_countries?.length > 0 && (
|
||||
<p className="text-[9px] text-gray-500 truncate">🌍 {data.top_countries.slice(0, 4).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { graphCard: GraphCardNode };
|
||||
|
||||
// ─── Vue Graphe ───────────────────────────────────────────────────────────────
|
||||
|
||||
function GraphView({
|
||||
data, selectedId, onSelect,
|
||||
}: {
|
||||
data: ClusteringData;
|
||||
selectedId: string | null;
|
||||
onSelect: (n: ClusterNode) => void;
|
||||
}) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
// Layout en colonnes par niveau de menace
|
||||
// Col 0 → bots (rouge), Col 1 → suspects (orange), Col 2 → légitimes (vert)
|
||||
const sorted = [...data.nodes].sort((a, b) => b.risk_score - a.risk_score);
|
||||
|
||||
const col: ClusterNode[][] = [[], [], []];
|
||||
for (const n of sorted) {
|
||||
if (n.risk_score >= 0.45 || n.label.includes('🤖')) col[0].push(n);
|
||||
else if (n.risk_score >= 0.15) col[1].push(n);
|
||||
else col[2].push(n);
|
||||
}
|
||||
|
||||
const NODE_W = 240;
|
||||
const NODE_H = 170;
|
||||
const PAD_X = 80;
|
||||
const PAD_Y = 40;
|
||||
const COL_GAP = 80;
|
||||
|
||||
const rfNodes: Node[] = [];
|
||||
col.forEach((group, ci) => {
|
||||
group.forEach((n, ri) => {
|
||||
rfNodes.push({
|
||||
id: n.id,
|
||||
type: 'graphCard',
|
||||
position: {
|
||||
x: ci * (NODE_W + COL_GAP) + PAD_X,
|
||||
y: ri * (NODE_H + PAD_Y) + PAD_Y,
|
||||
},
|
||||
data: n,
|
||||
draggable: true,
|
||||
selected: n.id === selectedId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Arêtes avec couleur par similarité
|
||||
const rfEdges: Edge[] = data.edges.map(e => {
|
||||
const sim = e.similarity;
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: {
|
||||
stroke: sim > 0.6 ? '#f97316' : sim > 0.4 ? '#6b7280' : '#374151',
|
||||
strokeWidth: Math.max(1, e.weight * 0.5),
|
||||
strokeDasharray: sim < 0.4 ? '4 4' : undefined,
|
||||
},
|
||||
label: sim > 0.55 ? `${Math.round(sim * 100)}%` : undefined,
|
||||
labelStyle: { fontSize: 9, fill: '#9ca3af' },
|
||||
labelBgStyle: { fill: '#0f1117aa', borderRadius: 3 },
|
||||
animated: sim > 0.6,
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(rfNodes);
|
||||
setEdges(rfEdges);
|
||||
setTimeout(() => fitView({ padding: 0.08 }), 120);
|
||||
}, [data, selectedId]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => onSelect(node.data as ClusterNode)}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.12}
|
||||
maxZoom={2.5}
|
||||
attributionPosition="bottom-right"
|
||||
>
|
||||
<Background color="#ffffff07" gap={30} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={n => riskColor((n.data as any)?.risk_score ?? 0)}
|
||||
style={{ background: '#0f1117', border: '1px solid #374151' }}
|
||||
/>
|
||||
{/* Légende colonnes */}
|
||||
<Panel position="top-center">
|
||||
<div className="flex gap-6 text-xs text-white/70 bg-background-card/90 rounded-lg px-5 py-2 shadow">
|
||||
{[
|
||||
{ color: '#dc2626', label: '🤖 Bots / Menaces', col: 0 },
|
||||
{ color: '#f97316', label: '⚠️ Suspects', col: 1 },
|
||||
{ color: '#22c55e', label: '✅ Légitimes', col: 2 },
|
||||
].map(({ color, label }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-white/40 ml-2">── similaire · - - différent · animé=fort</span>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel position="top-left">
|
||||
<div className="text-[10px] text-text-disabled bg-background-card/80 rounded p-2 space-y-0.5">
|
||||
<p className="font-semibold text-text-secondary">K-means++ · 21 features</p>
|
||||
<p>Colonnes : niveau de risque</p>
|
||||
<p>Arêtes : similarité des centroides</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar détail cluster ────────────────────────────────────────────────────
|
||||
|
||||
const RADAR_FEATURES_SET = new Set(RADAR_FEATURES);
|
||||
|
||||
function ClusterSidebar({ cluster, onClose }: { cluster: ClusterNode; onClose: () => void }) {
|
||||
const [ips, setIPs] = useState<ClusterIP[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/clustering/cluster/${cluster.id}/ips?limit=80`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setIPs(d.ips || []); setTotal(d.total || 0); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [cluster.id]);
|
||||
|
||||
const copyIPs = () => {
|
||||
navigator.clipboard.writeText(ips.map(i => i.ip).join('\n'));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const downloadCSV = () => {
|
||||
const header = 'IP,JA4,TTL,MSS,Hits,Score,Menace,Pays,ASN,Fuzzing,Vélocité\n';
|
||||
const rows = ips.map(i =>
|
||||
[i.ip, i.ja4, i.tcp_ttl, i.tcp_mss, i.hits,
|
||||
i.avg_score.toFixed(3), i.threat_level, i.country_code,
|
||||
`"${i.asn_org}"`, i.fuzzing.toFixed(2), i.velocity.toFixed(2)].join(',')
|
||||
).join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `cluster_${cluster.id}.csv`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const rc = riskColor(cluster.risk_score);
|
||||
const radarData = cluster.radar
|
||||
.filter(r => RADAR_FEATURES_SET.has(r.feature))
|
||||
.map(r => ({ subject: r.feature.replace('Vélocité (rps)', 'Vélocité'), val: Math.round(r.value * 100) }));
|
||||
|
||||
return (
|
||||
<div className="w-[420px] flex-shrink-0 bg-background-card border-l border-gray-700 shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700 flex-shrink-0" style={{ borderLeftWidth: 4, borderLeftColor: rc }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-base text-text-primary">{cluster.label}</p>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
<b className="text-text-primary">{cluster.ip_count.toLocaleString()}</b> IPs ·{' '}
|
||||
<b className="text-text-primary">{cluster.hit_count.toLocaleString()}</b> requêtes
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-secondary hover:text-text-primary text-lg leading-none ml-4 mt-1">✕</button>
|
||||
</div>
|
||||
{/* Risque */}
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-text-secondary mb-1">
|
||||
<span>Score de risque</span>
|
||||
<span className="font-bold" style={{ color: rc }}>{Math.round(cluster.risk_score * 100)}% — {riskLabel(cluster.risk_score)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div className="h-2 rounded-full" style={{ width: `${cluster.risk_score * 100}%`, backgroundColor: rc }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-5">
|
||||
{/* Radar */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">Profil Comportemental</p>
|
||||
<ResponsiveContainer width="100%" height={210}>
|
||||
<RadarChart data={radarData} margin={{ top: 10, right: 25, bottom: 10, left: 25 }}>
|
||||
<PolarGrid stroke="#ffffff18" />
|
||||
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 9, fill: '#9ca3af' }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Radar dataKey="val" stroke={rc} fill={rc} fillOpacity={0.30} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ background: '#1e2533', border: 'none', fontSize: 11, borderRadius: 8 }}
|
||||
formatter={(v: number) => [`${v}%`]}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Métriques */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">Toutes les métriques</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
['Score anomalie ML', Math.min(1, cluster.mean_score / 0.5), rc],
|
||||
['UA-CH mismatch', cluster.mean_ua_ch, '#f97316'],
|
||||
['UA rotatif', cluster.mean_ua_rotating, '#ec4899'],
|
||||
['Fuzzing', Math.min(1, cluster.mean_fuzzing * 3), '#8b5cf6'],
|
||||
['Headless', cluster.mean_headless, '#dc2626'],
|
||||
['Vélocité', cluster.mean_velocity, '#6366f1'],
|
||||
['ALPN mismatch', cluster.mean_alpn_mismatch, '#14b8a6'],
|
||||
['IP-ID zéro', cluster.mean_ip_id_zero, '#f59e0b'],
|
||||
['Entropie temporelle',cluster.mean_entropy, '#06b6d4'],
|
||||
['Browser score', Math.min(1, cluster.mean_browser_score / 50), '#22c55e'],
|
||||
].map(([lbl, val, col]: any) => (
|
||||
<MiniBar key={String(lbl)} label={String(lbl)} value={val as number} color={col as string} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TCP */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">Stack TCP</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['TTL Initial', Math.round(cluster.mean_ttl)],
|
||||
['MSS', Math.round(cluster.mean_mss)],
|
||||
['Scale', cluster.mean_scale.toFixed(1)],
|
||||
].map(([k, v]) => (
|
||||
<div key={String(k)} className="bg-background-secondary rounded-lg p-2 text-center">
|
||||
<p className="text-[10px] text-text-disabled">{k}</p>
|
||||
<p className="font-bold text-text-primary">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="space-y-2 text-xs">
|
||||
{cluster.top_threat && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-disabled">Menace dominante</span>
|
||||
<ThreatBadge level={cluster.top_threat} />
|
||||
</div>
|
||||
)}
|
||||
{cluster.top_countries.length > 0 && (
|
||||
<p><span className="text-text-disabled">Pays : </span>
|
||||
<span className="text-text-primary">{cluster.top_countries.join(', ')}</span></p>
|
||||
)}
|
||||
{cluster.top_orgs.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-text-disabled">ASN :</span>
|
||||
{cluster.top_orgs.slice(0, 3).map((org, i) => (
|
||||
<p key={i} className="text-text-secondary pl-2">• {org}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cluster.sample_ua && (
|
||||
<div>
|
||||
<span className="text-text-disabled">User-Agent type : </span>
|
||||
<p className="text-text-secondary break-all text-[10px] mt-1 pl-2 border-l border-gray-600">{cluster.sample_ua}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 sticky bottom-0 bg-background-card py-2">
|
||||
<button onClick={copyIPs}
|
||||
className="flex-1 py-2 text-xs rounded-lg bg-accent-primary text-white hover:opacity-80">
|
||||
{copied ? '✓ Copié !' : `📋 Copier IPs (${total.toLocaleString()})`}
|
||||
</button>
|
||||
<button onClick={downloadCSV}
|
||||
className="flex-1 py-2 text-xs rounded-lg bg-gray-700 text-white hover:bg-gray-600">
|
||||
⬇ CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Liste IPs */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">
|
||||
Adresses IP ({loading ? '…' : `${ips.length} / ${total.toLocaleString()}`})
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="text-text-disabled text-xs">Chargement…</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{ips.map((ip, i) => (
|
||||
<div key={i} className="bg-background-secondary rounded-lg p-2 text-xs">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-mono text-accent-primary">{ip.ip}</span>
|
||||
<div className="flex gap-1 items-center">
|
||||
<ThreatBadge level={ip.threat_level} />
|
||||
{ip.country_code && <span className="text-text-disabled">{ip.country_code}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-text-disabled text-[10px]">
|
||||
<span>TTL {ip.tcp_ttl}</span>
|
||||
<span>MSS {ip.tcp_mss}</span>
|
||||
<span>{ip.hits.toLocaleString()} req</span>
|
||||
{ip.avg_score > 0.1 && (
|
||||
<span className="text-orange-400">⚠ {(ip.avg_score * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
{ip.asn_org && <span className="truncate max-w-[100px]">{ip.asn_org}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Vue Graphe (wrapper avec ReactFlowProvider) ───────────────────────────────
|
||||
|
||||
function GraphViewWrapper({
|
||||
data, selectedId, onSelect,
|
||||
}: {
|
||||
data: ClusteringData;
|
||||
selectedId: string | null;
|
||||
onSelect: (n: ClusterNode) => void;
|
||||
}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<GraphView data={data} selectedId={selectedId} onSelect={onSelect} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Composant principal ─────────────────────────────────────────────────────
|
||||
|
||||
export default function ClusteringView() {
|
||||
const [data, setData] = useState<ClusteringData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [k, setK] = useState(14);
|
||||
const [pendingK, setPendingK] = useState(14);
|
||||
const [view, setView] = useState<'cards' | 'graph'>('cards');
|
||||
const [selected, setSelected] = useState<ClusterNode | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (kVal: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelected(null);
|
||||
try {
|
||||
const r = await fetch(`/api/clustering/clusters?k=${kVal}&n_samples=3000`);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
setData(await r.json());
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Erreur réseau');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchData(k); }, []);
|
||||
|
||||
const applyK = () => { setK(pendingK); fetchData(pendingK); };
|
||||
|
||||
const stats = data?.stats;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background overflow-hidden">
|
||||
{/* ── Barre de contrôle ── */}
|
||||
<div className="flex-none px-5 py-2.5 bg-background-card border-b border-gray-700 flex flex-wrap items-center gap-4 z-10">
|
||||
{/* Slider k */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary">k =</span>
|
||||
<input type="range" min={4} max={30} value={pendingK}
|
||||
onChange={e => setPendingK(Number(e.target.value))}
|
||||
className="w-24 accent-indigo-500" />
|
||||
<span className="text-sm font-bold text-text-primary w-6">{pendingK}</span>
|
||||
<button onClick={applyK} disabled={loading}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent-primary text-white hover:opacity-80 disabled:opacity-40">
|
||||
{loading ? '⏳ …' : '▶ Calculer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Onglets vue */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-600">
|
||||
{(['cards', 'graph'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`text-xs px-3 py-1.5 transition-colors ${
|
||||
view === v
|
||||
? 'bg-accent-primary text-white'
|
||||
: 'bg-background-secondary text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{v === 'cards' ? '⊞ Tableau de bord' : '⬡ Graphe de relations'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && !loading && (
|
||||
<div className="flex gap-4 ml-auto text-xs flex-wrap">
|
||||
<Stat label="clusters" value={stats.total_clusters} />
|
||||
<Stat label="IPs" value={stats.total_ips.toLocaleString()} />
|
||||
<Stat label="bots" value={stats.bot_ips.toLocaleString()} color="text-red-400" />
|
||||
<Stat label="suspects" value={stats.high_risk_ips.toLocaleString()} color="text-orange-400" />
|
||||
<span className="text-text-disabled">{stats.elapsed_s}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Erreur ── */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<div className="bg-red-900/90 text-white rounded-2xl p-8 text-center max-w-sm">
|
||||
<p className="text-4xl mb-3">⚠️</p>
|
||||
<p className="font-bold">Erreur de clustering</p>
|
||||
<p className="text-sm text-red-300 mt-2">{error}</p>
|
||||
<button onClick={() => fetchData(k)} className="mt-4 text-sm px-4 py-2 bg-red-600 rounded-lg hover:bg-red-500">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chargement ── */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 bg-background/75 backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl animate-spin mb-4">⚙️</div>
|
||||
<p className="text-text-primary font-semibold">Calcul K-means++ en cours…</p>
|
||||
<p className="text-text-disabled text-sm mt-1">Normalisation 21 features · PCA-2D · Nommage automatique</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Contenu principal ── */}
|
||||
{data && !loading && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{view === 'cards' ? (
|
||||
<CardGridView
|
||||
nodes={data.nodes}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={n => setSelected(prev => prev?.id === n.id ? null : n)}
|
||||
/>
|
||||
) : (
|
||||
<GraphViewWrapper
|
||||
data={data}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={n => setSelected(prev => prev?.id === n.id ? null : n)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
{selected && (
|
||||
<ClusterSidebar
|
||||
cluster={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Petit composant stat ─────────────────────────────────────────────────────
|
||||
|
||||
function Stat({ label, value, color = 'text-text-primary' }: { label: string; value: string | number; color?: string }) {
|
||||
return (
|
||||
<span className="text-text-secondary">
|
||||
<b className={color}>{value}</b> {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user