feat: système de tooltips universel sur tous les termes techniques

- Nouveau composant ui/Tooltip.tsx (createPortal → pas de clipping overflow)
  - Tooltip : bulle au survol avec ajustement viewport automatique
  - InfoTip : icône ⓘ avec bulle intégrée
- Nouveau ui/tooltips.ts : 50+ définitions en français
  (clustering, ML features, TCP spoofing, general)
- ui/DataTable.tsx : prop tooltip sur Column → InfoTip dans les en-têtes
- ClusteringView : ⓘ sur Sensibilité, k, arêtes, toutes les stats,
  légende CRITICAL/HIGH/MEDIUM/LOW, sidebar (score risque, radar,
  TTL/MSS/Score ML/Vélocité/Headless/UA-CH)
- MLFeaturesView : <title> SVG sur axes radar et scatter, tooltip
  sur colonnes Fuzzing/Type/Signaux
- TcpSpoofingView : tooltip sur colonnes TTL/MSS/Scale/OS/Confiance/Verdict
- App.tsx : tooltip sur Alertes 24h et niveaux CRITICAL/HIGH/MEDIUM

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-19 12:02:15 +01:00
parent 5805231c38
commit 485b95b62e
7 changed files with 529 additions and 39 deletions

View File

@ -21,6 +21,8 @@ import { HeaderFingerprintView } from './components/HeaderFingerprintView';
import { MLFeaturesView } from './components/MLFeaturesView'; import { MLFeaturesView } from './components/MLFeaturesView';
import ClusteringView from './components/ClusteringView'; import ClusteringView from './components/ClusteringView';
import { useTheme } from './ThemeContext'; import { useTheme } from './ThemeContext';
import { Tooltip } from './components/ui/Tooltip';
import { TIPS } from './components/ui/tooltips';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -144,19 +146,29 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
{/* Alert stats */} {/* Alert stats */}
{counts && ( {counts && (
<div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2"> <div className="mx-3 mt-5 bg-background-card rounded-lg p-3 space-y-2">
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider mb-2">Alertes 24h</div> <Tooltip content={TIPS.alertes_24h}>
<div className="text-xs font-semibold text-text-disabled uppercase tracking-wider mb-2 cursor-help">
Alertes 24h
</div>
</Tooltip>
{counts.critical > 0 && ( {counts.critical > 0 && (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-red-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block animate-pulse" /> CRITICAL</span> <Tooltip content={TIPS.risk_critical}>
<span className="text-xs text-red-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-red-500 inline-block animate-pulse" /> CRITICAL</span>
</Tooltip>
<span className="text-xs font-bold text-red-400 bg-red-500/20 px-1.5 py-0.5 rounded">{counts.critical}</span> <span className="text-xs font-bold text-red-400 bg-red-500/20 px-1.5 py-0.5 rounded">{counts.critical}</span>
</div> </div>
)} )}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-orange-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-orange-500 inline-block" /> HIGH</span> <Tooltip content={TIPS.risk_high}>
<span className="text-xs text-orange-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-orange-500 inline-block" /> HIGH</span>
</Tooltip>
<span className="text-xs font-bold text-orange-400 bg-orange-500/20 px-1.5 py-0.5 rounded">{counts.high}</span> <span className="text-xs font-bold text-orange-400 bg-orange-500/20 px-1.5 py-0.5 rounded">{counts.high}</span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-xs text-yellow-400 flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block" /> MEDIUM</span> <Tooltip content={TIPS.risk_medium}>
<span className="text-xs text-yellow-400 flex items-center gap-1 cursor-help"><span className="w-1.5 h-1.5 rounded-full bg-yellow-500 inline-block" /> MEDIUM</span>
</Tooltip>
<span className="text-xs font-bold text-yellow-400 bg-yellow-500/20 px-1.5 py-0.5 rounded">{counts.medium}</span> <span className="text-xs font-bold text-yellow-400 bg-yellow-500/20 px-1.5 py-0.5 rounded">{counts.medium}</span>
</div> </div>
<div className="border-t border-background-secondary pt-1.5 flex justify-between items-center mt-1"> <div className="border-t border-background-secondary pt-1.5 flex justify-between items-center mt-1">

View File

@ -14,6 +14,8 @@ import DeckGL from '@deck.gl/react';
import { OrthographicView } from '@deck.gl/core'; import { OrthographicView } from '@deck.gl/core';
import { ScatterplotLayer, PolygonLayer, TextLayer, LineLayer } from '@deck.gl/layers'; import { ScatterplotLayer, PolygonLayer, TextLayer, LineLayer } from '@deck.gl/layers';
import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, Tooltip } from 'recharts'; import { RadarChart, PolarGrid, PolarAngleAxis, Radar, ResponsiveContainer, Tooltip } from 'recharts';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import axios from 'axios'; import axios from 'axios';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -359,10 +361,13 @@ export default function ClusteringView() {
{/* Sensibilité */} {/* Sensibilité */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex justify-between text-xs text-text-secondary"> <div className="flex justify-between text-xs text-text-secondary">
<span>Sensibilité</span> <span className="flex items-center">
Sensibilité
<InfoTip content={TIPS.sensitivity} />
</span>
<span className="font-mono text-white"> <span className="font-mono text-white">
{sensitivity <= 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 2.0 ? 'Fine' : sensitivity <= 3.5 ? 'Très fine' : sensitivity <= 4.5 ? 'Maximale' : 'Extrême'} {sensitivity <= 0.5 ? 'Grossière' : sensitivity <= 1.0 ? 'Normale' : sensitivity <= 2.0 ? 'Fine' : sensitivity <= 3.5 ? 'Très fine' : sensitivity <= 4.5 ? 'Maximale' : 'Extrême'}
{' '}({Math.round(k * sensitivity)} clusters effectifs) {' '}(<span title={TIPS.k_actual}>{Math.round(k * sensitivity)} clusters effectifs</span>)
</span> </span>
</div> </div>
<input type="range" min={0.5} max={5.0} step={0.5} value={sensitivity} <input type="range" min={0.5} max={5.0} step={0.5} value={sensitivity}
@ -378,7 +383,10 @@ export default function ClusteringView() {
<summary className="cursor-pointer hover:text-white">Paramètres avancés</summary> <summary className="cursor-pointer hover:text-white">Paramètres avancés</summary>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<label className="block"> <label className="block">
<span className="flex items-center gap-1">
Clusters de base (k) Clusters de base (k)
<InfoTip content={TIPS.k_base} />
</span>
<input type="range" min={4} max={100} value={k} <input type="range" min={4} max={100} value={k}
onChange={e => setK(+e.target.value)} onChange={e => setK(+e.target.value)}
className="w-full mt-1 accent-accent-primary" /> className="w-full mt-1 accent-accent-primary" />
@ -401,7 +409,10 @@ export default function ClusteringView() {
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer"> <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)} <input type="checkbox" checked={showEdges} onChange={e => setShowEdges(e.target.checked)}
className="accent-accent-primary" /> className="accent-accent-primary" />
<span className="flex items-center">
Afficher les arêtes Afficher les arêtes
<InfoTip content={TIPS.show_edges} />
</span>
</label> </label>
<button onClick={() => fetchClusters(true)} <button onClick={() => fetchClusters(true)}
disabled={loading} disabled={loading}
@ -414,12 +425,12 @@ export default function ClusteringView() {
{data?.stats && ( {data?.stats && (
<div className="bg-background-card rounded-lg p-3 space-y-1 text-xs"> <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> <div className="font-semibold text-sm mb-2">Résultats</div>
<Stat label="Clusters" value={data.stats.total_clusters} /> <Stat label="Clusters" value={data.stats.total_clusters} tooltip={TIPS.k_actual} />
<Stat label="IPs totales" value={data.stats.total_ips.toLocaleString()} /> <Stat label="IPs totales" value={data.stats.total_ips.toLocaleString()} tooltip={TIPS.pca_2d} />
<Stat label="IPs bots 🤖" value={data.stats.bot_ips.toLocaleString()} color="text-red-400" /> <Stat label="IPs bots 🤖" value={data.stats.bot_ips.toLocaleString()} color="text-red-400" tooltip={TIPS.ips_bots} />
<Stat label="Risque élevé" value={data.stats.high_risk_ips.toLocaleString()} color="text-orange-400" /> <Stat label="Risque élevé" value={data.stats.high_risk_ips.toLocaleString()} color="text-orange-400" tooltip={TIPS.high_risk} />
<Stat label="Hits totaux" value={data.stats.total_hits.toLocaleString()} /> <Stat label="Hits totaux" value={data.stats.total_hits.toLocaleString()} tooltip={TIPS.total_hits} />
<Stat label="Calcul" value={`${data.stats.elapsed_s}s`} /> <Stat label="Calcul" value={`${data.stats.elapsed_s}s`} tooltip={TIPS.calc_time} />
</div> </div>
)} )}
@ -514,14 +525,22 @@ export default function ClusteringView() {
controller={true} controller={true}
> >
{/* Légende overlay */} {/* Légende overlay */}
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}> <div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'all' }}>
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1"> <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"> ['#dc2626', 'CRITICAL', TIPS.risk_critical],
<span className="w-3 h-3 rounded-full" style={{ background: c }} /> ['#f97316', 'HIGH', TIPS.risk_high],
<span className="text-white/80">{l}</span> ['#eab308', 'MEDIUM', TIPS.risk_medium],
['#22c55e', 'LOW', TIPS.risk_low],
] as const).map(([c, l, tip]) => (
<div key={l} className="flex items-center gap-2" title={tip}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: c }} />
<span className="text-white/80 cursor-help">{l}</span>
</div> </div>
))} ))}
<div className="mt-1 pt-1 border-t border-white/10 text-white/40 text-[10px] cursor-help" title={TIPS.features_31}>
31 features · PCA 2D
</div>
</div> </div>
</div> </div>
{/* Tooltip zoom hint */} {/* Tooltip zoom hint */}
@ -550,10 +569,13 @@ export default function ClusteringView() {
// ─── Stat helper ───────────────────────────────────────────────────────────── // ─── Stat helper ─────────────────────────────────────────────────────────────
function Stat({ label, value, color }: { label: string; value: string | number; color?: string }) { function Stat({ label, value, color, tooltip }: { label: string; value: string | number; color?: string; tooltip?: string }) {
return ( return (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-text-secondary">{label}</span> <span className="text-text-secondary flex items-center">
{label}
{tooltip && <InfoTip content={tooltip} />}
</span>
<span className={`font-mono font-semibold ${color ?? 'text-white'}`}>{value}</span> <span className={`font-mono font-semibold ${color ?? 'text-white'}`}>{value}</span>
</div> </div>
); );
@ -604,7 +626,10 @@ function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClo
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4"> <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Score risque */} {/* Score risque */}
<div className="bg-background-card rounded-lg p-3"> <div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2">Score de risque</div> <div className="text-xs text-text-secondary mb-2 flex items-center">
Score de risque
<InfoTip content={TIPS.risk_score} />
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 h-3 bg-gray-700 rounded-full overflow-hidden"> <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 className="h-full rounded-full" style={{ width: `${node.risk_score * 100}%`, background: node.color }} />
@ -618,7 +643,10 @@ function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClo
{/* Radar chart */} {/* Radar chart */}
{node.radar?.length > 0 && ( {node.radar?.length > 0 && (
<div className="bg-background-card rounded-lg p-3"> <div className="bg-background-card rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2">Profil 21 features</div> <div className="text-xs text-text-secondary mb-2 flex items-center">
Profil {node.radar?.length ?? 21} features
<InfoTip content={TIPS.radar_profile} />
</div>
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<RadarChart data={node.radar}> <RadarChart data={node.radar}>
<PolarGrid stroke="#374151" /> <PolarGrid stroke="#374151" />
@ -636,12 +664,12 @@ function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClo
{/* TCP stack */} {/* TCP stack */}
<div className="bg-background-card rounded-lg p-3 text-xs space-y-1"> <div className="bg-background-card rounded-lg p-3 text-xs space-y-1">
<div className="font-semibold mb-2">Stack TCP</div> <div className="font-semibold mb-2">Stack TCP</div>
<Stat label="TTL moyen" value={node.mean_ttl} /> <Stat label="TTL moyen" value={node.mean_ttl} tooltip={TIPS.mean_ttl} />
<Stat label="MSS moyen" value={node.mean_mss} /> <Stat label="MSS moyen" value={node.mean_mss} tooltip={TIPS.mean_mss} />
<Stat label="Score ML" value={`${(node.mean_score * 100).toFixed(1)}%`} /> <Stat label="Score ML" value={`${(node.mean_score * 100).toFixed(1)}%`} tooltip={TIPS.mean_score} />
<Stat label="Vélocité" value={node.mean_velocity?.toFixed ? `${node.mean_velocity.toFixed(2)} rps` : '-'} /> <Stat label="Vélocité" value={node.mean_velocity?.toFixed ? `${node.mean_velocity.toFixed(2)} rps` : '-'} tooltip={TIPS.mean_velocity} />
<Stat label="Headless" value={node.mean_headless ? `${(node.mean_headless * 100).toFixed(0)}%` : '-'} /> <Stat label="Headless" value={node.mean_headless ? `${(node.mean_headless * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_headless} />
<Stat label="UA-CH Mismatch" value={node.mean_ua_ch ? `${(node.mean_ua_ch * 100).toFixed(0)}%` : '-'} /> <Stat label="UA-CH Mismatch" value={node.mean_ua_ch ? `${(node.mean_ua_ch * 100).toFixed(0)}%` : '-'} tooltip={TIPS.mean_ua_ch} />
</div> </div>
{/* Contexte */} {/* Contexte */}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable'; import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -100,14 +101,14 @@ function ErrorMessage({ message }: { message: string }) {
// ─── Radar Chart (SVG octagonal) ───────────────────────────────────────────── // ─── Radar Chart (SVG octagonal) ─────────────────────────────────────────────
const RADAR_AXES = [ const RADAR_AXES = [
{ key: 'fuzzing_score', label: 'Fuzzing' }, { key: 'fuzzing_score', label: 'Fuzzing', tip: TIPS.fuzzing },
{ key: 'velocity_score', label: 'Vélocité' }, { key: 'velocity_score', label: 'Vélocité', tip: TIPS.velocity },
{ key: 'fake_nav_score', label: 'Fausse nav' }, { key: 'fake_nav_score', label: 'Fausse nav', tip: TIPS.fake_nav },
{ key: 'ua_mismatch_score', label: 'UA/CH mismatch' }, { key: 'ua_mismatch_score', label: 'UA/CH mismatch', tip: TIPS.ua_mismatch },
{ key: 'sni_mismatch_score', label: 'SNI mismatch' }, { key: 'sni_mismatch_score', label: 'SNI mismatch', tip: TIPS.sni_mismatch },
{ key: 'orphan_score', label: 'Orphan ratio' }, { key: 'orphan_score', label: 'Orphan ratio', tip: TIPS.orphan_ratio },
{ key: 'path_repetition_score', label: 'Répétition URL' }, { key: 'path_repetition_score', label: 'Répétition URL', tip: TIPS.path_repetition },
{ key: 'payload_anomaly_score', label: 'Payload anormal' }, { key: 'payload_anomaly_score', label: 'Payload anormal', tip: TIPS.payload_anomaly },
] as const; ] as const;
type RadarKey = typeof RADAR_AXES[number]['key']; type RadarKey = typeof RADAR_AXES[number]['key'];
@ -182,7 +183,7 @@ function RadarChart({ data }: { data: RadarData }) {
<circle key={i} cx={x} cy={y} r="3" fill="rgba(239,68,68,0.9)" /> <circle key={i} cx={x} cy={y} r="3" fill="rgba(239,68,68,0.9)" />
))} ))}
{/* Axis labels */} {/* Axis labels — survolez pour la définition */}
{RADAR_AXES.map((axis, i) => { {RADAR_AXES.map((axis, i) => {
const [x, y] = pointFor(i, maxR + 18); const [x, y] = pointFor(i, maxR + 18);
const anchor = x < cx - 5 ? 'end' : x > cx + 5 ? 'start' : 'middle'; const anchor = x < cx - 5 ? 'end' : x > cx + 5 ? 'start' : 'middle';
@ -195,7 +196,9 @@ function RadarChart({ data }: { data: RadarData }) {
fontSize="10" fontSize="10"
fill="rgba(148,163,184,0.9)" fill="rgba(148,163,184,0.9)"
dominantBaseline="middle" dominantBaseline="middle"
style={{ cursor: 'help' }}
> >
<title>{axis.tip}</title>
{axis.label} {axis.label}
</text> </text>
); );
@ -257,7 +260,10 @@ function ScatterPlot({ points }: { points: ScatterPoint[] }) {
{xTicks.map((v) => ( {xTicks.map((v) => (
<text key={v} x={toSvgX(v)} y={H - padB + 12} textAnchor="middle" fontSize="9" fill="rgba(148,163,184,0.7)">{v}</text> <text key={v} x={toSvgX(v)} y={H - padB + 12} textAnchor="middle" fontSize="9" fill="rgba(148,163,184,0.7)">{v}</text>
))} ))}
<text x={(W - padL - padR) / 2 + padL} y={H - 2} textAnchor="middle" fontSize="10" fill="rgba(148,163,184,0.8)">Fuzzing Index </text> <text x={(W - padL - padR) / 2 + padL} y={H - 2} textAnchor="middle" fontSize="10" fill="rgba(148,163,184,0.8)" style={{ cursor: 'help' }}>
<title>{TIPS.fuzzing_index}</title>
Fuzzing Index
</text>
{/* Y axis */} {/* Y axis */}
<line x1={padL} y1={padT} x2={padL} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" /> <line x1={padL} y1={padT} x2={padL} y2={H - padB} stroke="rgba(100,116,139,0.4)" strokeWidth="1" />
@ -361,6 +367,7 @@ function AnomaliesTable({
{ {
key: 'fuzzing_index', key: 'fuzzing_index',
label: 'Fuzzing', label: 'Fuzzing',
tooltip: TIPS.fuzzing_index,
align: 'right', align: 'right',
render: (v: number) => ( render: (v: number) => (
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}> <span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
@ -371,6 +378,7 @@ function AnomaliesTable({
{ {
key: 'attack_type', key: 'attack_type',
label: 'Type', label: 'Type',
tooltip: 'Type d\'attaque détecté : Brute Force 🔑, Flood 🌊, Scraper 🕷️, Spoofing 🎭, Scanner 🔍',
render: (v: string) => ( render: (v: string) => (
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span> <span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
), ),
@ -378,6 +386,7 @@ function AnomaliesTable({
{ {
key: '_signals', key: '_signals',
label: 'Signaux', label: 'Signaux',
tooltip: '⚠️ UA/CH mismatch · 🎭 Fausse navigation · 🔄 UA rotatif · 🌐 SNI mismatch',
sortable: false, sortable: false,
render: (_: unknown, row: MLAnomaly) => ( render: (_: unknown, row: MLAnomaly) => (
<span className="flex gap-0.5"> <span className="flex gap-0.5">

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable'; import DataTable, { Column } from './ui/DataTable';
import { TIPS } from './ui/tooltips';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -144,6 +145,7 @@ function TcpDetectionsTable({
{ {
key: 'tcp_ttl', key: 'tcp_ttl',
label: 'TTL obs. / init.', label: 'TTL obs. / init.',
tooltip: TIPS.ttl,
align: 'right', align: 'right',
render: (_: number, row: TcpSpoofingItem) => ( render: (_: number, row: TcpSpoofingItem) => (
<span className="font-mono text-xs"> <span className="font-mono text-xs">
@ -159,6 +161,7 @@ function TcpDetectionsTable({
{ {
key: 'tcp_mss', key: 'tcp_mss',
label: 'MSS', label: 'MSS',
tooltip: TIPS.mss,
align: 'right', align: 'right',
render: (v: number) => ( render: (v: number) => (
<span className={`font-mono text-xs ${mssColor(v)}`} title={mssLabel(v)}> <span className={`font-mono text-xs ${mssColor(v)}`} title={mssLabel(v)}>
@ -169,6 +172,7 @@ function TcpDetectionsTable({
{ {
key: 'tcp_win_scale', key: 'tcp_win_scale',
label: 'Scale', label: 'Scale',
tooltip: TIPS.win_scale,
align: 'right', align: 'right',
render: (v: number) => ( render: (v: number) => (
<span className="font-mono text-xs text-text-secondary">{v}</span> <span className="font-mono text-xs text-text-secondary">{v}</span>
@ -177,6 +181,7 @@ function TcpDetectionsTable({
{ {
key: 'suspected_os', key: 'suspected_os',
label: 'OS suspecté (TCP)', label: 'OS suspecté (TCP)',
tooltip: TIPS.os_tcp,
render: (v: string, row: TcpSpoofingItem) => ( render: (v: string, row: TcpSpoofingItem) => (
<span className={`text-xs flex items-center gap-1 ${row.is_bot_tool ? 'text-threat-critical font-semibold' : 'text-text-primary'}`}> <span className={`text-xs flex items-center gap-1 ${row.is_bot_tool ? 'text-threat-critical font-semibold' : 'text-text-primary'}`}>
<span>{osIcon(v)}</span> <span>{osIcon(v)}</span>
@ -187,6 +192,7 @@ function TcpDetectionsTable({
{ {
key: 'confidence', key: 'confidence',
label: 'Confiance', label: 'Confiance',
tooltip: TIPS.confidence,
render: (v: number) => confidenceBar(v), render: (v: number) => confidenceBar(v),
}, },
{ {
@ -197,11 +203,13 @@ function TcpDetectionsTable({
{ {
key: 'declared_os', key: 'declared_os',
label: 'OS déclaré (UA)', label: 'OS déclaré (UA)',
tooltip: TIPS.os_ua,
render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>, render: (v: string) => <span className="text-xs text-text-secondary">{v || '—'}</span>,
}, },
{ {
key: 'spoof_flag', key: 'spoof_flag',
label: 'Verdict', label: 'Verdict',
tooltip: TIPS.spoof_verdict,
sortable: false, sortable: false,
render: (v: boolean, row: TcpSpoofingItem) => { render: (v: boolean, row: TcpSpoofingItem) => {
if (row.is_bot_tool) { if (row.is_bot_tool) {

View File

@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { useSort, SortDir } from '../../hooks/useSort'; import { useSort, SortDir } from '../../hooks/useSort';
import { InfoTip } from './Tooltip';
export interface Column<T> { export interface Column<T> {
key: string; key: string;
label: string; label: string;
tooltip?: string;
sortable?: boolean; sortable?: boolean;
align?: 'left' | 'right' | 'center'; align?: 'left' | 'right' | 'center';
width?: string; width?: string;
@ -96,6 +98,7 @@ export default function DataTable<T extends Record<string, any>>({
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{col.label} {col.label}
{col.tooltip && <InfoTip content={col.tooltip} />}
{isSortable && {isSortable &&
(isActive ? ( (isActive ? (
<span className="text-accent-primary"> <span className="text-accent-primary">

View File

@ -0,0 +1,145 @@
/**
* Tooltip — composant universel de survol
*
* Rendu via createPortal dans document.body pour éviter tout clipping
* par les conteneurs overflow:hidden / overflow-y:auto.
*
* Usage :
* <Tooltip content="Explication…"><span>label</span></Tooltip>
* <InfoTip content="Explication…" /> ← ajoute un ⓘ cliquable
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface TooltipProps {
content: string | React.ReactNode;
children: React.ReactNode;
className?: string;
delay?: number;
maxWidth?: number;
}
interface TooltipPos {
x: number;
y: number;
}
export function Tooltip({ content, children, className = '', delay = 250, maxWidth = 300 }: TooltipProps) {
const [pos, setPos] = useState<TooltipPos | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const spanRef = useRef<HTMLSpanElement>(null);
const show = useCallback(
(e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
// Position au-dessus du centre de l'élément, ajustée si trop haut
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setPos({ x, y }), delay);
},
[delay],
);
const hide = useCallback(() => {
if (timer.current) clearTimeout(timer.current);
setPos(null);
}, []);
// Nettoyage si le composant est démonté pendant le délai
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
if (!content) return <>{children}</>;
return (
<>
<span
ref={spanRef}
className={`inline-flex items-center ${className}`}
onMouseEnter={show}
onMouseLeave={hide}
>
{children}
</span>
{pos &&
createPortal(
<TooltipBubble x={pos.x} y={pos.y} maxWidth={maxWidth}>
{content}
</TooltipBubble>,
document.body,
)}
</>
);
}
/** Bulle de tooltip positionnée en fixed par rapport au viewport */
function TooltipBubble({
x,
y,
maxWidth,
children,
}: {
x: number;
y: number;
maxWidth: number;
children: React.ReactNode;
}) {
const bubbleRef = useRef<HTMLDivElement>(null);
const [adjust, setAdjust] = useState({ dx: 0, dy: 0 });
// Ajustement viewport pour éviter les débordements
useEffect(() => {
if (!bubbleRef.current) return;
const el = bubbleRef.current;
const rect = el.getBoundingClientRect();
let dx = 0;
let dy = 0;
if (rect.left < 8) dx = 8 - rect.left;
if (rect.right > window.innerWidth - 8) dx = window.innerWidth - 8 - rect.right;
if (rect.top < 8) dy = 16; // bascule en dessous si trop haut
setAdjust({ dx, dy });
}, [x, y]);
return (
<div
ref={bubbleRef}
className="fixed z-[9999] pointer-events-none"
style={{
left: x + adjust.dx,
top: y + adjust.dy - 8,
transform: 'translate(-50%, -100%)',
}}
>
<div
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-100 shadow-2xl leading-relaxed whitespace-pre-line text-left"
style={{ maxWidth }}
>
{children}
</div>
{/* Flèche vers le bas */}
<div className="w-0 h-0 mx-auto border-[5px] border-transparent border-t-slate-600" />
</div>
);
}
/**
* InfoTip — icône ⓘ avec tooltip intégré.
* S'insère après un label pour donner une explication au survol.
*/
export function InfoTip({
content,
className = '',
}: {
content: string | React.ReactNode;
className?: string;
}) {
return (
<Tooltip content={content} className={className}>
<span className="ml-1 text-[10px] text-slate-500 cursor-help select-none hover:text-slate-300 transition-colors leading-none">
</span>
</Tooltip>
);
}

View File

@ -0,0 +1,285 @@
/**
* tooltips.ts — Textes d'aide pour tous les termes techniques du dashboard.
*
* Toutes les chaînes sont en français, multi-lignes via \n.
* Utilisé avec <InfoTip content={TIPS.xxx} /> ou <Tooltip content={TIPS.xxx}>.
*/
export const TIPS = {
// ── Clustering ──────────────────────────────────────────────────────────────
sensitivity:
'Contrôle la granularité du clustering.\n' +
'· Grossière → grands groupes comportementaux\n' +
'· Fine → distinction fine entre sous-comportements\n' +
'· Extrême → jusqu\'à k × 5 clusters, calcul long',
k_base:
'Nombre de clusters de base (k).\n' +
'Clusters effectifs = k × sensibilité (limité à 300).\n' +
'Augmenter k permet plus de nuance dans la classification.',
k_actual:
'Nombre réel de clusters calculés = k × sensibilité.\n' +
'Exemple : k=20 × sensibilité=3 = 60 clusters effectifs.\n' +
'Limité à 300 pour rester calculable.',
show_edges:
'Affiche les relations de similarité entre clusters proches.\n' +
'Seules les paires au-dessus d\'un seuil de similarité sont reliées.\n' +
'Utile pour identifier des groupes comportementaux liés.',
ips_bots:
'IPs avec score de risque ML > 70 %.\n' +
'Présentent les signaux les plus forts de comportement automatisé.\n' +
'Action immédiate recommandée.',
high_risk:
'IPs avec score de risque entre 45 % et 70 %.\n' +
'Activité suspecte nécessitant une analyse approfondie.',
total_hits:
'Nombre total de requêtes HTTP de toutes les IPs\n' +
'dans la fenêtre d\'analyse sélectionnée.',
calc_time:
'Durée du calcul K-means++ sur l\'ensemble des IPs.\n' +
'Augmente avec k × sensibilité et le nombre d\'IPs.',
pca_2d:
'Projection PCA (Analyse en Composantes Principales).\n' +
'31 features → 2 dimensions pour la visualisation.\n' +
'Les clusters proches ont des comportements similaires.',
features_31:
'31 métriques utilisées pour le clustering :\n' +
'· TCP : TTL, MSS, fenêtre de congestion\n' +
'· ML : score de détection bot\n' +
'· TLS/JA4 : fingerprint client\n' +
'· User-Agent, OS, headless, UA-CH\n' +
'· Pays, ASN (cloud/datacenter)\n' +
'· Headers HTTP : Accept-Language, Encoding, Sec-Fetch\n' +
'· Fingerprint headers : popularité, rotation, cookie, referer',
// ── Légende risque ──────────────────────────────────────────────────────────
risk_critical:
'CRITICAL — Score > 70 %\nBot très probable. Action immédiate recommandée.',
risk_high:
'HIGH — Score 4570 %\nActivité suspecte. Investigation recommandée.',
risk_medium:
'MEDIUM — Score 2545 %\nComportement anormal. Surveillance renforcée.',
risk_low:
'LOW — Score < 25 %\nTrafic probablement légitime.',
// ── Sidebar cluster ─────────────────────────────────────────────────────────
risk_score:
'Score composite [0100 %] calculé à partir de 14 sous-scores pondérés :\n' +
'· ML score (25 %) · Fuzzing (9 %)\n' +
'· UA-CH mismatch (7 %) · Headless (6 %)\n' +
'· Pays risqué (9 %) · ASN cloud (6 %)\n' +
'· Headers HTTP (12 %) · Fingerprint (12 %)\n' +
'· Vélocité (5 %) · IP ID zéro (5 %)',
radar_profile:
'Radar comportemental sur les features principales.\n' +
'Chaque axe = un sous-score normalisé entre 0 et 1.\n' +
'Survolez un point pour voir la valeur exacte.',
mean_ttl:
'Time-To-Live TCP moyen du cluster.\n' +
'· Linux ≈ 64 · Windows ≈ 128 · Cisco ≈ 255\n' +
'Différence TTL_initial TTL_observé = nombre de sauts réseau.',
mean_mss:
'Maximum Segment Size TCP moyen.\n' +
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
'· VPN ≈ 13801420 B · Bas débit < 1380 B\n' +
'MSS anormalement bas → probable tunnel ou VPN.',
mean_score:
'Score moyen du modèle ML de détection de bots.\n' +
'0 % = trafic légitime · 100 % = bot confirmé',
mean_velocity:
'Nombre moyen de requêtes par seconde (rps).\n' +
'Taux élevé → outil automatisé ou attaque volumétrique.',
mean_headless:
'Proportion d\'IPs utilisant un navigateur headless.\n' +
'(Puppeteer, Playwright, PhantomJS, Chromium sans UI…)\n' +
'Les bots utilisent fréquemment des navigateurs sans interface.',
mean_ua_ch:
'User-Agent / Client Hints mismatch.\n' +
'Le UA déclaré (ex: Chrome/Windows) contredit les hints\n' +
'(ex: Linux, version différente).\n' +
'Signal fort de spoofing d\'identité navigateur.',
// ── ML Features ─────────────────────────────────────────────────────────────
fuzzing:
'Score de fuzzing : variété anormale de paramètres ou payloads.\n' +
'Caractéristique des scanners de vulnérabilités\n' +
'(SQLi, XSS, path traversal, injection d\'en-têtes…).',
velocity:
'Score de vélocité : taux de requêtes / unité de temps normalisé.\n' +
'Au-dessus du seuil → outil automatisé confirmé.',
fake_nav:
'Fausse navigation : séquences de pages non conformes au comportement humain.\n' +
'Ex : accès direct à des API sans passer par les pages d\'entrée.',
ua_mismatch:
'User-Agent / Client Hints mismatch.\n' +
'Contradiction entre l\'OS/navigateur déclaré dans le UA\n' +
'et les Client Hints envoyés par le navigateur réel.',
sni_mismatch:
'SNI mismatch : le nom dans le SNI TLS ≠ le Host HTTP.\n' +
'Signe de proxying, de bot, ou de spoofing TLS.',
orphan_ratio:
'Orphan ratio : proportion de requêtes sans referer ni session.\n' +
'Les bots accèdent souvent directement aux URLs\n' +
'sans parcours préalable sur le site.',
path_repetition:
'Répétition URL : taux de requêtes sur les mêmes endpoints.\n' +
'Les bots ciblés répètent des patterns d\'URLs précis\n' +
'(ex : /login, /api/search, /admin…).',
payload_anomaly:
'Payload anormal : ratio de requêtes avec contenu inhabituel.\n' +
'(taille hors norme, encoding bizarre, corps non standard)\n' +
'Peut indiquer une injection ou une tentative de bypass.',
fuzzing_index:
'Indice brut de fuzzing mesuré sur les paramètres des requêtes.\n' +
'Valeur haute → tentative d\'injection ou fuzzing actif.',
hit_velocity_scatter:
'Taux de requêtes par seconde de cette IP.\n' +
'Valeur haute → outil automatisé ou attaque volumétrique.',
temporal_entropy:
'Entropie temporelle : irrégularité des intervalles entre requêtes.\n' +
'· Faible = bot régulier (machine, intervalles constants)\n' +
'· Élevée = humain ou bot à timing aléatoire',
anomalous_payload_ratio:
'Ratio de requêtes avec payload anormal / total de l\'IP.\n' +
'Ex : headers malformés, corps non HTTP standard.',
attack_brute_force:
'Brute Force : tentatives répétées d\'authentification\n' +
'ou d\'énumération de ressources (login, tokens, IDs…).',
attack_flood:
'Flood : envoi massif de requêtes pour saturer le service.\n' +
'(DoS/DDoS, rate limit bypass…)',
attack_scraper:
'Scraper : extraction systématique de contenu.\n' +
'(web scraping, crawling non autorisé, récolte de données)',
attack_spoofing:
'Spoofing : usurpation d\'identité UA/TLS\n' +
'pour contourner la détection de bots.',
attack_scanner:
'Scanner : exploration automatique des endpoints\n' +
'pour découvrir des vulnérabilités (CVE, misconfigs…).',
// ── TCP Spoofing ─────────────────────────────────────────────────────────────
ttl:
'TTL (Time-To-Live) : valeur observée vs initiale estimée.\n' +
'· Initiale typique : Linux=64, Windows=128, Cisco=255\n' +
'· Hops réseau = TTL_init TTL_observé',
mss:
'Maximum Segment Size TCP.\n' +
'· Ethernet = 1460 B · PPPoE ≈ 1452 B\n' +
'· VPN ≈ 13801420 B · Bas débit < 1380 B\n' +
'Révèle le type de réseau sous-jacent.',
win_scale:
'Window Scale TCP (RFC 1323).\n' +
'Facteur d\'échelle de la fenêtre de congestion.\n' +
'· Linux ≈ 7 · Windows ≈ 8 · Absent = vieux OS ou bot',
os_tcp:
'OS suspecté via fingerprinting TCP passif.\n' +
'Analyse combinée : TTL + MSS + Window Scale + options TCP.\n' +
'Indépendant du User-Agent déclaré.',
os_ua:
'OS déclaré dans le User-Agent HTTP.\n' +
'Comparé à l\'OS TCP pour détecter les usurpations d\'identité.',
confidence:
'Niveau de confiance du fingerprinting TCP [0100 %].\n' +
'Basé sur le nombre de signaux concordants\n' +
'(TTL, MSS, Window Scale, options TCP).',
spoof_verdict:
'Verdict de spoofing : OS TCP (réel) vs OS User-Agent (déclaré).\n' +
'Un écart indique une probable usurpation d\'identité.\n' +
'Ex : TCP→Linux mais UA→Windows/Chrome.',
// ── Général ──────────────────────────────────────────────────────────────────
ja4:
'JA4 : fingerprint TLS client.\n' +
'Basé sur : version TLS, suites chiffrées, extensions,\n' +
'algorithmes de signature et SNI.\n' +
'Identifie un client TLS de façon quasi-unique.',
asn:
'ASN (Autonomous System Number).\n' +
'Identifiant du réseau auquel appartient l\'IP.\n' +
'Permet d\'identifier l\'opérateur (AWS, GCP, Azure, hébergeur, FAI…).',
header_fingerprint:
'Fingerprint des headers HTTP.\n' +
'· Popularité : fréquence de ce profil dans le trafic global\n' +
' (rare = suspect, populaire = navigateur standard)\n' +
'· Rotation : le client change fréquemment de profil headers\n' +
' (signal fort de bot rotatif)',
header_count:
'Nombre de headers HTTP envoyés par le client.\n' +
'Navigateur standard ≈ 1015 headers.\n' +
'Bot HTTP basique ≈ 25 headers.',
accept_language:
'Header Accept-Language.\n' +
'Absent chez les bots HTTP basiques.\n' +
'Présent chez les navigateurs légitimes (fr-FR, en-US…).',
accept_encoding:
'Header Accept-Encoding.\n' +
'Absent → client HTTP basique ou bot simple.\n' +
'Présent (gzip, br…) → navigateur ou client HTTP moderne.',
sec_fetch:
'Headers Sec-Fetch-* (Site, Mode, Dest, User).\n' +
'Envoyés uniquement par les navigateurs Chromium/Firefox réels.\n' +
'Absent → bot, curl, ou client HTTP non-navigateur.',
alertes_24h:
'Alertes générées par le moteur de détection ML\n' +
'dans les dernières 24 heures, classifiées par niveau de menace.',
threat_level:
'Niveau de menace composite :\n' +
'· CRITICAL > 70 % · HIGH 4570 %\n' +
'· MEDIUM 2545 % · LOW < 25 %',
};