suite des maj

This commit is contained in:
SOC Analyst
2026-03-18 09:00:47 +01:00
parent 446d3623ec
commit 32a96966dd
17 changed files with 2398 additions and 755 deletions

View File

@ -18,9 +18,6 @@ import { CampaignsView } from './components/CampaignsView';
import { BruteForceView } from './components/BruteForceView';
import { TcpSpoofingView } from './components/TcpSpoofingView';
import { HeaderFingerprintView } from './components/HeaderFingerprintView';
import { HeatmapView } from './components/HeatmapView';
import { BotnetMapView } from './components/BotnetMapView';
import { RotationView } from './components/RotationView';
import { MLFeaturesView } from './components/MLFeaturesView';
import { useTheme } from './ThemeContext';
@ -83,9 +80,6 @@ function Sidebar({ counts }: { counts: AlertCounts | null }) {
{ path: '/bruteforce', label: 'Brute Force', icon: '🔥', aliases: [] },
{ path: '/tcp-spoofing', label: 'TCP Spoofing', icon: '🧬', aliases: [] },
{ path: '/headers', label: 'Header Fingerprint', icon: '📡', aliases: [] },
{ path: '/heatmap', label: 'Heatmap Temporelle', icon: '⏱️', aliases: [] },
{ path: '/botnets', label: 'Botnets Distribués', icon: '🌍', aliases: [] },
{ path: '/rotation', label: 'Rotation & Persistance', icon: '🔄', aliases: [] },
{ path: '/ml-features', label: 'Features ML', icon: '🤖', aliases: [] },
];
@ -245,9 +239,6 @@ function TopHeader({ counts }: { counts: AlertCounts | null }) {
if (p.startsWith('/bruteforce')) return 'Brute Force & Credential Stuffing';
if (p.startsWith('/tcp-spoofing')) return 'Spoofing TCP/OS';
if (p.startsWith('/headers')) return 'Header Fingerprint Clustering';
if (p.startsWith('/heatmap')) return 'Heatmap Temporelle';
if (p.startsWith('/botnets')) return 'Botnets Distribués';
if (p.startsWith('/rotation')) return 'Rotation JA4 & Persistance';
if (p.startsWith('/ml-features')) return 'Features ML / Radar';
return '';
};
@ -380,9 +371,9 @@ export default function App() {
<Route path="/bruteforce" element={<BruteForceView />} />
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
<Route path="/headers" element={<HeaderFingerprintView />} />
<Route path="/heatmap" element={<HeatmapView />} />
<Route path="/botnets" element={<BotnetMapView />} />
<Route path="/rotation" element={<RotationView />} />
<Route path="/heatmap" element={<Navigate to="/" replace />} />
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
<Route path="/ml-features" element={<MLFeaturesView />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -61,6 +62,70 @@ function ErrorMessage({ message }: { message: string }) {
);
}
// ─── Attackers DataTable ─────────────────────────────────────────────────────
function AttackersTable({
attackers,
navigate,
}: {
attackers: BruteForceAttacker[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<BruteForceAttacker>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{ key: 'distinct_hosts', label: 'Hosts ciblés', align: 'right' },
{
key: 'total_hits',
label: 'Hits',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'total_params',
label: 'Params',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'ja4',
label: 'JA4',
render: (v: string) => (
<span className="font-mono text-xs text-text-secondary">
{v ? `${v.slice(0, 16)}` : '—'}
</span>
),
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: BruteForceAttacker) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable
data={attackers}
columns={columns}
rowKey="ip"
defaultSortKey="total_hits"
emptyMessage="Aucun attaquant trouvé"
compact
/>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
interface HostAttacker { ip: string; total_hits: number; total_params: number; ja4: string; attack_type: string; }
@ -338,37 +403,7 @@ export function BruteForceView() {
) : attackersError ? (
<div className="p-4"><ErrorMessage message={attackersError} /></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">IP</th>
<th className="px-4 py-3">Hosts ciblés</th>
<th className="px-4 py-3">Hits</th>
<th className="px-4 py-3">Params</th>
<th className="px-4 py-3">JA4</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{attackers.map((a) => (
<tr key={a.ip} className="border-b border-border hover:bg-background-card transition-colors">
<td className="px-4 py-3 font-mono text-text-primary text-xs">{a.ip}</td>
<td className="px-4 py-3 text-text-primary">{a.distinct_hosts}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_hits)}</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(a.total_params)}</td>
<td className="px-4 py-3 font-mono text-xs text-text-secondary">{a.ja4 ? a.ja4.slice(0, 16) : ''}…</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/investigation/${a.ip}`)}
className="text-xs bg-threat-high/10 text-threat-high px-3 py-1 rounded hover:bg-threat-high/20 transition-colors"
>
Investiguer
</button>
</td>
</tr>
))}
</tbody>
</table>
<AttackersTable attackers={attackers} navigate={navigate} />
)}
</div>
)}

View File

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import ThreatBadge from './ui/ThreatBadge';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -44,7 +46,7 @@ interface JA4AttributesResponse {
items: JA4AttributeItem[];
}
type ActiveTab = 'clusters' | 'ja4' | 'behavioral';
type ActiveTab = 'clusters' | 'ja4' | 'behavioral' | 'botnets';
// ─── Helpers ──────────────────────────────────────────────────────────────────
@ -256,6 +258,7 @@ export function CampaignsView() {
{ id: 'clusters', label: 'Clusters réseau' },
{ id: 'ja4', label: 'Fingerprints JA4' },
{ id: 'behavioral', label: 'Analyse comportementale' },
{ id: 'botnets', label: '🌍 Botnets Distribués' },
] as const
).map(tab => (
<button
@ -324,6 +327,8 @@ export function CampaignsView() {
{activeTab === 'behavioral' && (
<BehavioralTab clusters={clusters} />
)}
{activeTab === 'botnets' && <BotnetTab />}
</div>
);
}
@ -721,72 +726,345 @@ function BehavioralTab({ clusters }: BehavioralTabProps) {
)}
{/* Behavioral matrix */}
<div className="bg-background-secondary rounded-lg p-4">
<h3 className="text-text-primary font-semibold mb-4">Matrice de signaux comportementaux</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Subnet</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Score</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Tendance</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Niveau menace</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs">Pays</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">IPs</th>
<th className="px-4 py-2 text-text-secondary font-medium text-xs text-right">Détections</th>
</tr>
</thead>
<tbody>
{clusters.map((cluster, idx) => {
const { bg, text } = getThreatColors(cluster.severity.toLowerCase());
return (
<tr
key={cluster.subnet}
className={`hover:bg-background-card/50 transition-colors ${
idx < clusters.length - 1 ? 'border-b border-border/50' : ''
}`}
>
<td className="px-4 py-2 font-mono text-text-primary text-xs">{cluster.subnet}</td>
<td className="px-4 py-2">
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(cluster.score / 100)}`}>
{cluster.score}
</span>
</td>
<td className="px-4 py-2">
{cluster.trend === 'up' ? (
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
+{cluster.trend_percentage}%
</span>
) : cluster.trend === 'new' ? (
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
Nouveau
</span>
) : (
<span className="text-text-disabled text-xs">{cluster.trend}</span>
)}
</td>
<td className="px-4 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${bg} ${text}`}>
{getThreatLabel(cluster.severity.toLowerCase())}
</span>
</td>
<td className="px-4 py-2">
{cluster.countries?.slice(0, 2).map(c => (
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
))}
</td>
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
{cluster.unique_ips}
</td>
<td className="px-4 py-2 text-text-primary text-xs font-mono text-right">
{cluster.total_detections.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="bg-background-secondary rounded-lg overflow-hidden">
<div className="px-4 py-2.5 border-b border-background-card">
<h3 className="text-text-primary font-semibold text-sm">Matrice de signaux comportementaux</h3>
</div>
<BehavioralMatrix clusters={clusters} />
</div>
</div>
);
}
// ─── Behavioral Matrix DataTable ─────────────────────────────────────────────
interface BehavioralRow {
subnet: string;
score: number;
severity: string;
trend: string;
trend_percentage: number;
unique_ips: number;
total_detections: number;
countries: { code: string; percentage: number }[];
}
function BehavioralMatrix({ clusters }: { clusters: ClusterData[] }) {
const columns = useMemo((): Column<BehavioralRow>[] => [
{
key: 'subnet',
label: 'Subnet',
render: (v: string) => <span className="font-mono text-text-primary text-xs">{v}</span>,
},
{
key: 'score',
label: 'Score',
align: 'right',
render: (v: number) => (
<span className={`text-xs font-mono font-semibold ${getConfidenceTextColor(v / 100)}`}>
{v}
</span>
),
},
{
key: 'severity',
label: 'Niveau',
render: (v: string) => <ThreatBadge level={v} />,
},
{
key: 'trend',
label: 'Tendance',
sortable: false,
render: (_: string, row: BehavioralRow) =>
row.trend === 'up' ? (
<span className="bg-threat-high/20 text-threat-high px-2 py-0.5 rounded text-xs">
+{row.trend_percentage}%
</span>
) : row.trend === 'new' ? (
<span className="bg-accent-primary/20 text-accent-primary px-2 py-0.5 rounded text-xs">
Nouveau
</span>
) : (
<span className="text-text-disabled text-xs">{row.trend}</span>
),
},
{
key: 'countries',
label: 'Pays',
sortable: false,
render: (_: unknown, row: BehavioralRow) => (
<span>
{row.countries?.slice(0, 2).map((c) => (
<span key={c.code} className="text-text-secondary text-xs mr-1">{c.code}</span>
))}
</span>
),
},
{ key: 'unique_ips', label: 'IPs', align: 'right' },
{
key: 'total_detections',
label: 'Détections',
align: 'right',
render: (v: number) => formatNumber(v),
},
], []);
const rows: BehavioralRow[] = clusters;
return (
<DataTable
data={rows}
columns={columns}
rowKey="subnet"
defaultSortKey="score"
emptyMessage="Aucun cluster disponible"
compact
/>
);
}
// ─── Tab: Botnets Distribués ──────────────────────────────────────────────────
interface BotnetItem {
ja4: string;
unique_ips: number;
unique_countries: number;
targeted_hosts: number;
distribution_score: number;
botnet_class: string;
}
interface BotnetSummary {
total_global_botnets: number;
total_ips_in_botnets: number;
most_spread_ja4: string;
most_ips_ja4: string;
}
interface BotnetCountryEntry {
country_code: string;
unique_ips: number;
hits: number;
}
function formatNumber(n: number): string {
return n.toLocaleString('fr-FR');
}
function getCountryFlag(code: string): string {
if (!code || code.length !== 2) return '🌐';
return code.toUpperCase().replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
}
function botnetClassBadge(cls: string): { bg: string; text: string; label: string } {
switch (cls) {
case 'global_botnet': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🌐 Global' };
case 'regional_botnet': return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '🗺️ Régional' };
case 'concentrated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium', label: '📍 Concentré' };
default: return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
}
}
function BotnetRow({ item, onInvestigate }: { item: BotnetItem; onInvestigate: (ja4: string) => void }) {
const [expanded, setExpanded] = useState(false);
const [countries, setCountries] = useState<BotnetCountryEntry[]>([]);
const [countriesLoading, setCountriesLoading] = useState(false);
const [countriesError, setCountriesError] = useState<string | null>(null);
const [countriesLoaded, setCountriesLoaded] = useState(false);
const toggle = async () => {
setExpanded((prev) => !prev);
if (!countriesLoaded && !expanded) {
setCountriesLoading(true);
try {
const res = await fetch(`/api/botnets/ja4/${encodeURIComponent(item.ja4)}/countries`);
if (!res.ok) throw new Error('Erreur chargement des pays');
const data: { items: BotnetCountryEntry[] } = await res.json();
setCountries(data.items ?? []);
setCountriesLoaded(true);
} catch (err) {
setCountriesError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setCountriesLoading(false);
}
}
};
const badge = botnetClassBadge(item.botnet_class);
return (
<>
<tr
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
onClick={toggle}
>
<td className="px-4 py-3">
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
<span className="font-mono text-xs text-text-primary" title={item.ja4}>
{item.ja4 ? item.ja4.slice(0, 20) + '…' : '—'}
</span>
</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(item.unique_ips)}</td>
<td className="px-4 py-3 text-text-primary">🌍 {formatNumber(item.unique_countries)}</td>
<td className="px-4 py-3 text-text-secondary">{formatNumber(item.targeted_hosts)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-16 bg-background-card rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-accent-primary" style={{ width: `${Math.min(item.distribution_score, 100)}%` }} />
</div>
<span className="text-xs text-text-secondary">{Math.round(item.distribution_score)}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
</td>
<td className="px-4 py-3">
<button
onClick={(e) => { e.stopPropagation(); onInvestigate(item.ja4); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer JA4
</button>
</td>
</tr>
{expanded && (
<tr className="border-b border-border bg-background-card">
<td colSpan={7} className="px-6 py-4">
{countriesLoading ? (
<div className="flex items-center gap-2 text-text-secondary text-sm">
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
Chargement des pays
</div>
) : countriesError ? (
<span className="text-threat-critical text-sm"> {countriesError}</span>
) : (
<div className="flex flex-wrap gap-2">
{countries.map((c) => (
<span
key={c.country_code}
title={`${c.country_code}: ${c.unique_ips} IPs, ${c.hits} hits`}
className="inline-flex items-center gap-1 bg-background-secondary border border-border rounded-full px-2 py-1 text-xs text-text-primary"
>
{getCountryFlag(c.country_code)} {c.country_code}
<span className="text-text-disabled">·</span>
<span className="text-accent-primary">{formatNumber(c.unique_ips)} IPs</span>
</span>
))}
</div>
)}
</td>
</tr>
)}
</>
);
}
function BotnetTab() {
const navigate = useNavigate();
const [items, setItems] = useState<BotnetItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<BotnetSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true);
const [summaryError, setSummaryError] = useState<string | null>(null);
const [sortField, setSortField] = useState<'unique_ips' | 'unique_countries' | 'targeted_hosts'>('unique_ips');
useEffect(() => {
fetch('/api/botnets/ja4-spread')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des botnets'))
.then((data: { items: BotnetItem[] }) => setItems(data.items ?? []))
.catch(err => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
fetch('/api/botnets/summary')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement du résumé'))
.then((data: BotnetSummary) => setSummary(data))
.catch(err => setSummaryError(err instanceof Error ? err.message : String(err)))
.finally(() => setSummaryLoading(false));
}, []);
const sortedItems = [...items].sort((a, b) => b[sortField] - a[sortField]);
return (
<div className="space-y-4 py-4">
{summaryLoading ? (
<div className="flex items-center justify-center py-6">
<div className="w-6 h-6 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
</div>
) : summaryError ? (
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical text-sm"> {summaryError}</div>
) : summary ? (
<div className="grid grid-cols-4 gap-4">
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">Total Global Botnets</span>
<span className="text-2xl font-bold text-threat-critical">{formatNumber(summary.total_global_botnets)}</span>
</div>
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">IPs impliquées</span>
<span className="text-2xl font-bold text-threat-high">{formatNumber(summary.total_ips_in_botnets)}</span>
</div>
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">JA4 le + répandu</span>
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_spread_ja4}>{summary.most_spread_ja4 || '—'}</span>
</div>
<div className="bg-background-secondary rounded-lg p-4 flex flex-col gap-1 border border-border">
<span className="text-text-secondary text-sm">IPs max par JA4</span>
<span className="font-mono text-xs font-bold text-accent-primary truncate" title={summary.most_ips_ja4}>{summary.most_ips_ja4 || '—'}</span>
</div>
</div>
) : null}
<div className="flex items-center gap-2">
<span className="text-text-secondary text-sm">Trier par :</span>
{(['unique_ips', 'unique_countries', 'targeted_hosts'] as const).map((field) => (
<button
key={field}
onClick={() => setSortField(field)}
className={`text-xs px-3 py-1.5 rounded transition-colors ${
sortField === field ? 'bg-accent-primary/20 text-accent-primary' : 'text-text-secondary hover:text-text-primary'
}`}
>
{field === 'unique_ips' ? '🖥️ IPs' : field === 'unique_countries' ? '🌍 Pays' : '🎯 Hosts'}
</button>
))}
</div>
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
</div>
) : error ? (
<div className="p-4 bg-threat-critical/10 border border-threat-critical/30 text-threat-critical text-sm"> {error}</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">JA4</th>
<th className="px-4 py-3">IPs distinctes</th>
<th className="px-4 py-3">Pays</th>
<th className="px-4 py-3">Hosts ciblés</th>
<th className="px-4 py-3">Score distribution</th>
<th className="px-4 py-3">Classe</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{sortedItems.map((item) => (
<BotnetRow
key={item.ja4}
item={item}
onInvestigate={(ja4) => navigate(`/investigation/ja4/${encodeURIComponent(ja4)}`)}
/>
))}
{sortedItems.length === 0 && (
<tr><td colSpan={7} className="px-4 py-8 text-center text-text-secondary">Aucun botnet détecté</td></tr>
)}
</tbody>
</table>
)}
</div>
</div>
);

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable';
type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity';
type SortOrder = 'asc' | 'desc';
@ -12,6 +13,28 @@ interface ColumnConfig {
sortable: boolean;
}
interface DetectionRow {
src_ip: string;
ja4?: string;
host?: string;
client_headers?: string;
model_name: string;
anomaly_score: number;
hits?: number;
hit_velocity?: number;
asn_org?: string;
asn_number?: string | number;
asn_score?: number | null;
asn_rep_label?: string;
country_code?: string;
detected_at: string;
first_seen?: string;
last_seen?: string;
unique_ja4s?: string[];
unique_hosts?: string[];
unique_client_headers?: string[];
}
export function DetectionsList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -72,26 +95,6 @@ export function DetectionsList() {
setSearchParams(newParams);
};
const handleSort = (field: SortField) => {
const newParams = new URLSearchParams(searchParams);
const currentSortField = newParams.get('sort_by') || 'detected_at';
const currentOrder = newParams.get('sort_order') || 'desc';
if (currentSortField === field) {
// Inverser l'ordre ou supprimer le tri
if (currentOrder === 'desc') {
newParams.set('sort_order', 'asc');
} else {
newParams.delete('sort_by');
newParams.delete('sort_order');
}
} else {
newParams.set('sort_by', field);
newParams.set('sort_order', 'desc');
}
setSearchParams(newParams);
};
const toggleColumn = (key: string) => {
setColumns(cols => cols.map(col =>
col.key === key ? { ...col, visible: !col.visible } : col
@ -104,20 +107,6 @@ export function DetectionsList() {
setSearchParams(newParams);
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return '⇅';
return sortOrder === 'asc' ? '↑' : '↓';
};
// Par défaut, trier par score croissant (scores négatifs en premier)
const getDefaultSortIcon = (field: SortField) => {
if (!searchParams.has('sort_by') && !searchParams.has('sort')) {
if (field === 'anomaly_score') return '↑';
return '⇅';
}
return getSortIcon(field);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@ -190,6 +179,208 @@ export function DetectionsList() {
};
})();
// Build DataTable columns from visible column configs
const tableColumns: Column<DetectionRow>[] = columns
.filter((col) => col.visible)
.map((col): Column<DetectionRow> => {
switch (col.key) {
case 'ip_ja4':
return {
key: 'src_ip',
label: col.label,
sortable: true,
render: (_, row) => (
<div>
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
<div className="mt-1 space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
</div>
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{ja4}
</div>
))}
{row.unique_ja4s.length > 3 && (
<div className="font-mono text-xs text-text-disabled">
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{row.ja4 || '-'}
</div>
)}
</div>
),
};
case 'host':
return {
key: 'host',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
</div>
{row.unique_hosts.slice(0, 3).map((host, idx) => (
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{host}
</div>
))}
{row.unique_hosts.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{row.host || '-'}
</div>
),
};
case 'client_headers':
return {
key: 'client_headers',
label: col.label,
sortable: false,
render: (_, row) =>
groupByIP && row.unique_client_headers && row.unique_client_headers.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
</div>
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
{header}
</div>
))}
{row.unique_client_headers.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
{row.client_headers || '-'}
</div>
),
};
case 'model_name':
return {
key: 'model_name',
label: col.label,
sortable: true,
render: (_, row) => <ModelBadge model={row.model_name} />,
};
case 'anomaly_score':
return {
key: 'anomaly_score',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => <ScoreBadge score={row.anomaly_score} />,
};
case 'hits':
return {
key: 'hits',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div className="text-sm text-text-primary font-medium">{row.hits ?? 0}</div>
),
};
case 'hit_velocity':
return {
key: 'hit_velocity',
label: col.label,
sortable: true,
align: 'right' as const,
render: (_, row) => (
<div
className={`text-sm font-medium ${
row.hit_velocity && row.hit_velocity > 10
? 'text-threat-high'
: row.hit_velocity && row.hit_velocity > 1
? 'text-threat-medium'
: 'text-text-primary'
}`}
>
{row.hit_velocity ? row.hit_velocity.toFixed(2) : '0.00'}
<span className="text-xs text-text-secondary ml-1">req/s</span>
</div>
),
};
case 'asn':
return {
key: 'asn_org',
label: col.label,
sortable: true,
render: (_, row) => (
<div>
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
{row.asn_number && (
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
)}
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
</div>
),
};
case 'country':
return {
key: 'country_code',
label: col.label,
sortable: true,
align: 'center' as const,
render: (_, row) =>
row.country_code ? (
<span className="text-lg">{getFlag(row.country_code)}</span>
) : (
<span>-</span>
),
};
case 'detected_at':
return {
key: 'detected_at',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.first_seen ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span>{' '}
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="text-xs text-text-secondary">
<span className="font-medium">Dernier:</span>{' '}
{new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '}
{new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
) : (
<>
<div className="text-sm text-text-primary">
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
</div>
<div className="text-xs text-text-secondary">
{new Date(row.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</>
),
};
default:
return { key: col.key, label: col.label, sortable: col.sortable };
}
});
return (
<div className="space-y-4 animate-fade-in">
{/* En-tête */}
@ -291,223 +482,15 @@ export function DetectionsList() {
{/* Tableau */}
<div className="bg-background-secondary rounded-lg overflow-x-auto">
<table className="w-full">
<thead className="bg-background-card">
<tr>
{columns.filter(col => col.visible).map(col => (
<th
key={col.key}
className={`px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase ${col.sortable ? 'cursor-pointer hover:text-text-primary' : ''}`}
onClick={() => col.sortable && handleSort(col.key as SortField)}
>
<div className="flex items-center gap-1">
{col.label}
{col.sortable && (
<span className="text-text-disabled">{getDefaultSortIcon(col.key as SortField)}</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{processedData.items.map((detection) => (
<tr
key={`${detection.src_ip}-${detection.detected_at}-${groupByIP ? 'grouped' : 'individual'}`}
className="hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => {
navigate(`/detections/ip/${encodeURIComponent(detection.src_ip)}`);
}}
>
{columns.filter(col => col.visible).map(col => {
if (col.key === 'ip_ja4') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
<div className="font-mono text-sm text-text-primary">{detection.src_ip}</div>
{groupByIP && detectionAny.unique_ja4s?.length > 0 ? (
<div className="mt-1 space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_ja4s.length} JA4{detectionAny.unique_ja4s.length > 1 ? 's' : ''} unique{detectionAny.unique_ja4s.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_ja4s.slice(0, 3).map((ja4: string, idx: number) => (
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{ja4}
</div>
))}
{detectionAny.unique_ja4s.length > 3 && (
<div className="font-mono text-xs text-text-disabled">
+{detectionAny.unique_ja4s.length - 3} autre{detectionAny.unique_ja4s.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{detection.ja4 || '-'}
</div>
)}
</td>
);
}
if (col.key === 'host') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.unique_hosts?.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_hosts.length} Host{detectionAny.unique_hosts.length > 1 ? 's' : ''} unique{detectionAny.unique_hosts.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_hosts.slice(0, 3).map((host: string, idx: number) => (
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{host}
</div>
))}
{detectionAny.unique_hosts.length > 3 && (
<div className="text-xs text-text-disabled">
+{detectionAny.unique_hosts.length - 3} autre{detectionAny.unique_hosts.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{detection.host || '-'}
</div>
)}
</td>
);
}
if (col.key === 'client_headers') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.unique_client_headers?.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{detectionAny.unique_client_headers.length} Header{detectionAny.unique_client_headers.length > 1 ? 's' : ''} unique{detectionAny.unique_client_headers.length > 1 ? 's' : ''}
</div>
{detectionAny.unique_client_headers.slice(0, 3).map((header: string, idx: number) => (
<div key={idx} className="text-xs text-text-primary break-all whitespace-normal font-mono">
{header}
</div>
))}
{detectionAny.unique_client_headers.length > 3 && (
<div className="text-xs text-text-disabled">
+{detectionAny.unique_client_headers.length - 3} autre{detectionAny.unique_client_headers.length - 3 > 1 ? 's' : ''}
</div>
)}
</div>
) : (
<div className="text-xs text-text-primary break-all whitespace-normal font-mono">
{detection.client_headers || '-'}
</div>
)}
</td>
);
}
if (col.key === 'model_name') {
return (
<td key={col.key} className="px-4 py-3">
<ModelBadge model={detection.model_name} />
</td>
);
}
if (col.key === 'anomaly_score') {
return (
<td key={col.key} className="px-4 py-3">
<ScoreBadge score={detection.anomaly_score} />
</td>
);
}
if (col.key === 'hits') {
return (
<td key={col.key} className="px-4 py-3">
<div className="text-sm text-text-primary font-medium">
{detection.hits || 0}
</div>
</td>
);
}
if (col.key === 'hit_velocity') {
return (
<td key={col.key} className="px-4 py-3">
<div className={`text-sm font-medium ${
detection.hit_velocity && detection.hit_velocity > 10
? 'text-threat-high'
: detection.hit_velocity && detection.hit_velocity > 1
? 'text-threat-medium'
: 'text-text-primary'
}`}>
{detection.hit_velocity ? detection.hit_velocity.toFixed(2) : '0.00'}
<span className="text-xs text-text-secondary ml-1">req/s</span>
</div>
</td>
);
}
if (col.key === 'asn') {
return (
<td key={col.key} className="px-4 py-3">
<div className="text-sm text-text-primary">{detection.asn_org || detection.asn_number || '-'}</div>
{detection.asn_number && (
<div className="text-xs text-text-secondary">AS{detection.asn_number}</div>
)}
<AsnRepBadge score={detection.asn_score} label={detection.asn_rep_label} />
</td>
);
}
if (col.key === 'country') {
return (
<td key={col.key} className="px-4 py-3">
{detection.country_code ? (
<span className="text-lg">{getFlag(detection.country_code)}</span>
) : (
'-'
)}
</td>
);
}
if (col.key === 'detected_at') {
const detectionAny = detection as any;
return (
<td key={col.key} className="px-4 py-3">
{groupByIP && detectionAny.first_seen ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span>{' '}
{new Date(detectionAny.first_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(detectionAny.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="text-xs text-text-secondary">
<span className="font-medium">Dernier:</span>{' '}
{new Date(detectionAny.last_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(detectionAny.last_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
) : (
<>
<div className="text-sm text-text-primary">
{new Date(detection.detected_at).toLocaleDateString('fr-FR')}
</div>
<div className="text-xs text-text-secondary">
{new Date(detection.detected_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div>
</>
)}
</td>
);
}
return null;
})}
</tr>
))}
</tbody>
</table>
{data.items.length === 0 && (
<div className="text-center py-12 text-text-secondary">
Aucune détection trouvée
</div>
)}
<DataTable<DetectionRow>
data={processedData.items as DetectionRow[]}
columns={tableColumns}
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
defaultSortKey="anomaly_score"
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
emptyMessage="Aucune détection trouvée"
compact
/>
</div>
{/* Pagination */}

View File

@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useState, useEffect, useCallback, Fragment, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
import ThreatBadge from './ui/ThreatBadge';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -32,7 +34,8 @@ interface IPsData {
}
type SortField = 'ip_count' | 'detections' | 'botnet_score';
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis';
type SortDir = 'asc' | 'desc';
type ActiveTab = 'ja4' | 'spoofing' | 'ua_analysis' | 'rotation';
// ─── Spoofing types ───────────────────────────────────────────────────────────
@ -904,6 +907,11 @@ export function FingerprintsView() {
const [expandedJa4, setExpandedJa4] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('ip_count');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const handleColSort = (field: SortField) => {
if (field === sortField) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortField(field); setSortDir('desc'); }
};
const [minIps, setMinIps] = useState(0);
const [copiedJa4, setCopiedJa4] = useState<string | null>(null);
@ -1053,24 +1061,25 @@ export function FingerprintsView() {
.sort((a, b) => {
const va = variabilityCache.get(a.value);
const vb = variabilityCache.get(b.value);
let diff = 0;
if (sortField === 'ip_count') {
const ia = va ? va.unique_ips : a.count;
const ib = vb ? vb.unique_ips : b.count;
return ib - ia;
}
if (sortField === 'detections') {
diff = ib - ia;
} else if (sortField === 'detections') {
const da = va ? va.total_detections : a.count;
const db = vb ? vb.total_detections : b.count;
return db - da;
diff = db - da;
} else {
const sa = va
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
: 0;
const sb = vb
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
: 0;
diff = sb - sa;
}
// botnet_score
const sa = va
? botnetScore(va.unique_ips, botUaPercentage(va.attributes.user_agents))
: 0;
const sb = vb
? botnetScore(vb.unique_ips, botUaPercentage(vb.attributes.user_agents))
: 0;
return sb - sa;
return sortDir === 'desc' ? diff : -diff;
});
// ── Loading state ──
@ -1098,11 +1107,12 @@ export function FingerprintsView() {
{ id: 'ja4', label: '🔏 JA4 Actifs', desc: 'Fingerprints TLS & IPs associées' },
{ id: 'spoofing', label: '🎭 Spoofing JA4', desc: 'Détection spoofing navigateur' },
{ id: 'ua_analysis', label: '🧬 Analyse UA', desc: 'User-Agents & rotation' },
{ id: 'rotation', label: '🔄 Rotation JA4', desc: 'IPs changeant de fingerprint TLS' },
] as { id: ActiveTab; label: string; desc: string }[]).map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors border-b-2 -mb-px ${
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'border-accent-primary text-accent-primary bg-accent-primary/5'
: 'border-transparent text-text-secondary hover:text-text-primary hover:bg-background-card'
@ -1119,6 +1129,9 @@ export function FingerprintsView() {
{/* ── UA Analysis tab ── */}
{activeTab === 'ua_analysis' && <UAAnalysisPanel />}
{/* ── Rotation tab ── */}
{activeTab === 'rotation' && <RotationTab />}
{/* ── JA4 tab (original content) ── */}
{activeTab === 'ja4' && (<>
@ -1146,15 +1159,6 @@ export function FingerprintsView() {
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm placeholder:text-text-disabled focus:outline-none focus:border-accent-primary"
/>
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
className="px-3 py-2 rounded-lg bg-background-secondary border border-background-card text-text-primary text-sm focus:outline-none focus:border-accent-primary"
>
<option value="ip_count">Trier par IPs</option>
<option value="detections">Trier par détections</option>
<option value="botnet_score">Trier par score botnet</option>
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-text-secondary whitespace-nowrap">IPs min :</label>
<input
@ -1170,33 +1174,57 @@ export function FingerprintsView() {
{/* ── Table ── */}
<div className="bg-background-secondary rounded-lg overflow-x-auto">
<table className="w-full">
<thead className="bg-background-card">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
<tr className="bg-background-secondary">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
JA4
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
IPs actives
<th
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
onClick={() => handleColSort('ip_count')}
>
<span className="inline-flex items-center gap-1">
IPs actives
{sortField === 'ip_count'
? <span className="text-accent-primary">{sortDir === 'desc' ? '' : ''}</span>
: <span className="text-text-disabled opacity-50">⇅</span>}
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
Détections
<th
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
onClick={() => handleColSort('detections')}
>
<span className="inline-flex items-center gap-1">
Détections
{sortField === 'detections'
? <span className="text-accent-primary">{sortDir === 'desc' ? '' : ''}</span>
: <span className="text-text-disabled opacity-50">⇅</span>}
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
Score botnet
<th
className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card cursor-pointer hover:text-text-primary select-none"
onClick={() => handleColSort('botnet_score')}
>
<span className="inline-flex items-center gap-1">
Score botnet
{sortField === 'botnet_score'
? <span className="text-accent-primary">{sortDir === 'desc' ? '' : ''}</span>
: <span className="text-text-disabled opacity-50">⇅</span>}
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
Top pays
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
Top ASN
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
% Bot UA
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
Insights
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-text-disabled uppercase tracking-wider border-b border-background-card">
Actions
</th>
</tr>
@ -1383,3 +1411,541 @@ export function FingerprintsView() {
</div>
);
}
// ─── Tab: Rotation JA4 ────────────────────────────────────────────────────────
interface JA4Rotator {
ip: string;
distinct_ja4_count: number;
total_hits: number;
evasion_score: number;
}
interface PersistentThreat {
ip: string;
recurrence: number;
worst_score: number;
worst_threat_level: string;
first_seen: string;
last_seen: string;
persistence_score: number;
}
interface JA4HistoryEntry {
ja4: string;
hits: number;
window_start: string;
}
interface SophisticationItem {
ip: string;
ja4_rotation_count: number;
recurrence: number;
bruteforce_hits: number;
sophistication_score: number;
tier: string;
}
interface ProactiveHuntItem {
ip: string;
recurrence: number;
worst_score: number;
worst_threat_level: string;
first_seen: string;
last_seen: string;
days_active: number;
risk_assessment: string;
}
type RotationSubTab = 'rotators' | 'persistent' | 'sophistication' | 'hunt';
function formatDate(iso: string): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch {
return iso;
}
}
function tierBadge(tier: string): { bg: string; text: string } {
switch (tier) {
case 'APT-like': return { bg: 'bg-threat-critical/20', text: 'text-threat-critical' };
case 'Advanced': return { bg: 'bg-threat-high/20', text: 'text-threat-high' };
case 'Automated': return { bg: 'bg-threat-medium/20', text: 'text-threat-medium' };
default: return { bg: 'bg-background-card', text: 'text-text-secondary' };
}
}
function RotatorRow({ item }: { item: JA4Rotator }) {
const [expanded, setExpanded] = useState(false);
const [history, setHistory] = useState<JA4HistoryEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [historyLoaded, setHistoryLoaded] = useState(false);
const toggle = async () => {
setExpanded((prev) => !prev);
if (!historyLoaded && !expanded) {
setHistoryLoading(true);
try {
const res = await fetch(`/api/rotation/ip/${encodeURIComponent(item.ip)}/ja4-history`);
if (!res.ok) throw new Error('Erreur chargement historique JA4');
const data: { ja4_history: JA4HistoryEntry[] } = await res.json();
setHistory(data.ja4_history ?? []);
setHistoryLoaded(true);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setHistoryLoading(false);
}
}
};
return (
<>
<tr className="border-b border-border hover:bg-background-card transition-colors cursor-pointer" onClick={toggle}>
<td className="px-4 py-3">
<span className="text-accent-primary text-xs mr-2">{expanded ? '' : ''}</span>
<span className="font-mono text-xs text-text-primary">{item.ip}</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${item.distinct_ja4_count > 5 ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'}`}>
{item.distinct_ja4_count} JA4
</span>
</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(item.total_hits)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-24 bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-threat-critical" style={{ width: `${Math.min(item.evasion_score, 100)}%` }} />
</div>
<span className="text-xs text-threat-critical font-semibold">{Math.round(item.evasion_score)}</span>
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-border bg-background-card">
<td colSpan={4} className="px-6 py-4">
{historyLoading ? (
<div className="flex items-center gap-2 text-text-secondary text-sm">
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
Chargement de l'historique
</div>
) : historyError ? (
<span className="text-threat-critical text-sm"> {historyError}</span>
) : history.length === 0 ? (
<span className="text-text-secondary text-sm">Aucun historique disponible.</span>
) : (
<div className="space-y-1.5">
<p className="text-text-secondary text-xs mb-2">Historique des JA4 utilisés :</p>
{history.map((entry, idx) => (
<div key={idx} className="flex items-center gap-3 text-xs">
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
<span className="text-text-disabled">{formatDate(entry.window_start)}</span>
</div>
))}
</div>
)}
</td>
</tr>
)}
</>
);
}
// ─── Rotation DataTable sub-components ───────────────────────────────────────
function PersistentTable({
data,
navigate,
}: {
data: PersistentThreat[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<PersistentThreat>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{
key: 'recurrence',
label: 'Récurrence',
align: 'right',
render: (v: number) => (
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}j</span>
),
},
{
key: 'worst_score',
label: 'Score menace',
align: 'right',
render: (v: number) => <span className="text-text-primary font-semibold">{Math.round(v)}</span>,
},
{
key: 'worst_threat_level',
label: 'Niveau',
render: (v: string) => <ThreatBadge level={v} />,
},
{
key: 'first_seen',
label: 'Première vue',
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
},
{
key: 'last_seen',
label: 'Dernière vue',
render: (v: string) => <span className="text-text-secondary text-xs">{formatDate(v)}</span>,
},
{
key: 'persistence_score',
label: 'Score persistance',
align: 'right',
render: (v: number) => (
<div className="flex items-center gap-2 justify-end">
<div className="w-20 bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-threat-high" style={{ width: `${Math.min(v, 100)}%` }} />
</div>
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
</div>
),
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: PersistentThreat) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="persistence_score" compact />
);
}
function SophisticationTable({
data,
navigate,
}: {
data: SophisticationItem[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<SophisticationItem>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{
key: 'ja4_rotation_count',
label: 'Rotation JA4',
align: 'right',
render: (v: number) => (
<span className="bg-threat-medium/10 text-threat-medium text-xs px-2 py-1 rounded-full">{v} JA4</span>
),
},
{ key: 'recurrence', label: 'Récurrence', align: 'right' },
{
key: 'bruteforce_hits',
label: 'Hits bruteforce',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'sophistication_score',
label: 'Score sophistication',
align: 'right',
render: (v: number) => (
<div className="flex items-center gap-2 justify-end">
<div className="w-24 bg-background-card rounded-full h-2">
<div className="h-2 rounded-full bg-threat-critical" style={{ width: `${Math.min(v, 100)}%` }} />
</div>
<span className="text-xs font-semibold text-threat-critical">{v}</span>
</div>
),
},
{
key: 'tier',
label: 'Tier',
render: (v: string) => {
const tb = tierBadge(v);
return <span className={`text-xs px-2 py-1 rounded-full ${tb.bg} ${tb.text} font-semibold`}>{v}</span>;
},
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: SophisticationItem) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="sophistication_score" compact />
);
}
function ProactiveTable({
data,
navigate,
}: {
data: ProactiveHuntItem[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<ProactiveHuntItem>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{
key: 'recurrence',
label: 'Récurrence',
align: 'right',
render: (v: number) => (
<span className="bg-background-card border border-border text-text-primary text-xs px-2 py-1 rounded-full">{v}×</span>
),
},
{
key: 'worst_score',
label: 'Score max',
align: 'right',
render: (v: number) => <span className="text-threat-medium font-semibold">{v.toFixed(3)}</span>,
},
{
key: 'days_active',
label: 'Jours actifs',
align: 'right',
render: (v: number) => <span className="text-text-primary font-medium">{v}j</span>,
},
{
key: 'risk_assessment',
label: 'Évaluation',
render: (v: string) => (
<span className={`text-xs px-2 py-1 rounded-full font-semibold ${v === 'Évadeur potentiel' ? 'bg-threat-critical/20 text-threat-critical' : 'bg-threat-medium/20 text-threat-medium'}`}>
{v}
</span>
),
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: ProactiveHuntItem) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Lancer investigation
</button>
),
},
], [navigate]);
return (
<DataTable data={data} columns={columns} rowKey="ip" defaultSortKey="days_active" compact />
);
}
function RotationTab() {
const navigate = useNavigate();
const [activeSubTab, setActiveSubTab] = useState<RotationSubTab>('rotators');
const [rotators, setRotators] = useState<JA4Rotator[]>([]);
const [rotatorsLoading, setRotatorsLoading] = useState(true);
const [rotatorsError, setRotatorsError] = useState<string | null>(null);
const [persistent, setPersistent] = useState<PersistentThreat[]>([]);
const [persistentLoading, setPersistentLoading] = useState(false);
const [persistentError, setPersistentError] = useState<string | null>(null);
const [persistentLoaded, setPersistentLoaded] = useState(false);
const [sophistication, setSophistication] = useState<SophisticationItem[]>([]);
const [sophisticationLoading, setSophisticationLoading] = useState(false);
const [sophisticationError, setSophisticationError] = useState<string | null>(null);
const [sophisticationLoaded, setSophisticationLoaded] = useState(false);
const [proactive, setProactive] = useState<ProactiveHuntItem[]>([]);
const [proactiveLoading, setProactiveLoading] = useState(false);
const [proactiveError, setProactiveError] = useState<string | null>(null);
const [proactiveLoaded, setProactiveLoaded] = useState(false);
useEffect(() => {
fetch('/api/rotation/ja4-rotators?limit=50')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement des rotateurs'))
.then((data: { items: JA4Rotator[] }) => setRotators(data.items ?? []))
.catch(err => setRotatorsError(err instanceof Error ? err.message : String(err)))
.finally(() => setRotatorsLoading(false));
}, []);
const loadPersistent = () => {
if (persistentLoaded) return;
setPersistentLoading(true);
fetch('/api/rotation/persistent-threats?limit=100')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement menaces persistantes'))
.then((data: { items: PersistentThreat[] }) => {
setPersistent([...(data.items ?? [])].sort((a, b) => b.persistence_score - a.persistence_score));
setPersistentLoaded(true);
})
.catch(err => setPersistentError(err instanceof Error ? err.message : String(err)))
.finally(() => setPersistentLoading(false));
};
const loadSophistication = () => {
if (sophisticationLoaded) return;
setSophisticationLoading(true);
fetch('/api/rotation/sophistication?limit=50')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement sophistication'))
.then((data: { items: SophisticationItem[] }) => {
setSophistication(data.items ?? []);
setSophisticationLoaded(true);
})
.catch(err => setSophisticationError(err instanceof Error ? err.message : String(err)))
.finally(() => setSophisticationLoading(false));
};
const loadProactive = () => {
if (proactiveLoaded) return;
setProactiveLoading(true);
fetch('/api/rotation/proactive-hunt?min_recurrence=1&min_days=0&limit=50')
.then(r => r.ok ? r.json() : Promise.reject('Erreur chargement chasse proactive'))
.then((data: { items: ProactiveHuntItem[] }) => {
setProactive(data.items ?? []);
setProactiveLoaded(true);
})
.catch(err => setProactiveError(err instanceof Error ? err.message : String(err)))
.finally(() => setProactiveLoading(false));
};
const handleSubTabChange = (tab: RotationSubTab) => {
setActiveSubTab(tab);
if (tab === 'persistent') loadPersistent();
if (tab === 'sophistication') loadSophistication();
if (tab === 'hunt') loadProactive();
};
const maxEvasion = rotators.length > 0 ? Math.max(...rotators.map(r => r.evasion_score)) : 0;
const maxPersistence = persistent.length > 0 ? Math.max(...persistent.map(p => p.persistence_score)) : 0;
const subTabs: { id: RotationSubTab; label: string }[] = [
{ id: 'rotators', label: '🎭 Rotateurs JA4' },
{ id: 'persistent', label: '🕰️ Menaces Persistantes' },
{ id: 'sophistication', label: '🏆 Sophistication' },
{ id: 'hunt', label: '🕵️ Chasse proactive' },
];
const Spinner = () => (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
</div>
);
return (
<div className="space-y-4 py-4">
{/* Stat cards */}
<div className="grid grid-cols-4 gap-4">
<StatCard label="IPs en rotation" value={formatNumber(rotators.length)} accent="text-threat-high" />
<StatCard label="Score évasion max" value={Math.round(maxEvasion)} accent="text-threat-critical" />
<StatCard label="IPs persistantes" value={persistentLoaded ? formatNumber(persistent.length) : '—'} accent="text-threat-medium" />
<StatCard label="Score persistance max" value={persistentLoaded ? Math.round(maxPersistence) : '—'} accent="text-threat-critical" />
</div>
{/* Sub-tabs */}
<div className="flex gap-2 border-b border-border">
{subTabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleSubTabChange(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeSubTab === tab.id
? 'text-accent-primary border-b-2 border-accent-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Rotateurs */}
{activeSubTab === 'rotators' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{rotatorsLoading ? <Spinner /> : rotatorsError ? (
<div className="p-4 text-threat-critical text-sm"> {rotatorsError}</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">IP</th>
<th className="px-4 py-3">JA4 distincts</th>
<th className="px-4 py-3">Total hits</th>
<th className="px-4 py-3">Score d'évasion</th>
</tr>
</thead>
<tbody>
{rotators.map((item) => <RotatorRow key={item.ip} item={item} />)}
</tbody>
</table>
)}
</div>
)}
{/* Persistantes */}
{activeSubTab === 'persistent' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{persistentLoading ? <Spinner /> : persistentError ? (
<div className="p-4 text-threat-critical text-sm">⚠️ {persistentError}</div>
) : (
<PersistentTable data={persistent} navigate={navigate} />
)}
</div>
)}
{/* Sophistication */}
{activeSubTab === 'sophistication' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{sophisticationLoading ? <Spinner /> : sophisticationError ? (
<div className="p-4 text-threat-critical text-sm">⚠️ {sophisticationError}</div>
) : (
<>
<div className="p-4 border-b border-border text-text-secondary text-sm">Score de sophistication = rotation JA4 × 10 + récurrence × 20 + log(bruteforce+1) × 5</div>
<SophisticationTable data={sophistication} navigate={navigate} />
{sophistication.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune donnée de sophistication disponible.</div>}
</>
)}
</div>
)}
{/* Chasse proactive */}
{activeSubTab === 'hunt' && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{proactiveLoading ? <Spinner /> : proactiveError ? (
<div className="p-4 text-threat-critical text-sm"> {proactiveError}</div>
) : (
<>
<div className="p-4 border-b border-border text-text-secondary text-sm">IPs récurrentes volant sous le radar (score &lt; 0.5) persistantes mais non détectées comme critiques.</div>
<ProactiveTable data={proactive} navigate={navigate} />
{proactive.length === 0 && <div className="text-center py-8 text-text-secondary text-sm">Aucune IP sous le radar détectée avec ces critères.</div>}
</>
)}
</div>
)}
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -65,14 +66,6 @@ function StatCard({ label, value, accent }: { label: string; value: string | num
);
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
function ErrorMessage({ message }: { message: string }) {
return (
<div className="bg-threat-critical/10 border border-threat-critical/30 rounded-lg p-4 text-threat-critical">
@ -81,141 +74,6 @@ function ErrorMessage({ message }: { message: string }) {
);
}
// ─── Cluster row with expandable IPs ─────────────────────────────────────────
function ClusterRow({
cluster,
onInvestigateIP,
}: {
cluster: HeaderCluster;
onInvestigateIP: (ip: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [clusterIPs, setClusterIPs] = useState<ClusterIP[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsError, setIpsError] = useState<string | null>(null);
const [ipsLoaded, setIpsLoaded] = useState(false);
const toggle = async () => {
setExpanded((prev) => !prev);
if (!ipsLoaded && !expanded) {
setIpsLoading(true);
try {
const res = await fetch(`/api/headers/cluster/${cluster.hash}/ips?limit=50`);
if (!res.ok) throw new Error('Erreur chargement IPs');
const data: { items: ClusterIP[] } = await res.json();
setClusterIPs(data.items ?? []);
setIpsLoaded(true);
} catch (err) {
setIpsError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setIpsLoading(false);
}
}
};
const badge = classificationBadge(cluster.classification);
return (
<>
<tr
className="border-b border-border hover:bg-background-card transition-colors cursor-pointer"
onClick={toggle}
>
<td className="px-4 py-3">
<span className="text-accent-primary text-xs mr-2">{expanded ? '▾' : '▸'}</span>
<span className="font-mono text-xs text-text-primary">{cluster.hash.slice(0, 16)}</span>
</td>
<td className="px-4 py-3 text-text-primary">{formatNumber(cluster.unique_ips)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-20 bg-background-card rounded-full h-2">
<div
className={`h-2 rounded-full ${browserScoreColor(cluster.avg_browser_score)}`}
style={{ width: `${cluster.avg_browser_score}%` }}
/>
</div>
<span className="text-xs text-text-secondary">{Math.round(cluster.avg_browser_score)}</span>
</div>
</td>
<td className={`px-4 py-3 font-semibold text-sm ${mismatchColor(cluster.ua_ch_mismatch_pct)}`}>
{Math.round(cluster.ua_ch_mismatch_pct)}%
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(cluster.top_sec_fetch_modes ?? []).slice(0, 3).map((mode) => (
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
{mode}
</span>
))}
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-border bg-background-card">
<td colSpan={6} className="px-6 py-4">
{ipsLoading ? (
<div className="flex items-center gap-2 text-text-secondary text-sm">
<div className="w-4 h-4 border-2 border-accent-primary border-t-transparent rounded-full animate-spin" />
Chargement des IPs
</div>
) : ipsError ? (
<span className="text-threat-critical text-sm"> {ipsError}</span>
) : (
<div className="overflow-auto max-h-64">
<table className="w-full text-xs">
<thead>
<tr className="text-text-secondary">
<th className="text-left py-1 pr-4">IP</th>
<th className="text-left py-1 pr-4">Browser Score</th>
<th className="text-left py-1 pr-4">UA/CH Mismatch</th>
<th className="text-left py-1 pr-4">Sec-Fetch Mode</th>
<th className="text-left py-1 pr-4">Sec-Fetch Dest</th>
<th />
</tr>
</thead>
<tbody>
{clusterIPs.map((ip) => (
<tr key={ip.ip} className="border-t border-border/50">
<td className="py-1.5 pr-4 font-mono text-text-primary">{ip.ip}</td>
<td className="py-1.5 pr-4">
<span className={ip.browser_score >= 70 ? 'text-threat-low' : ip.browser_score >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
{Math.round(ip.browser_score)}
</span>
</td>
<td className="py-1.5 pr-4">
{ip.ua_ch_mismatch ? (
<span className="text-threat-critical"> Oui</span>
) : (
<span className="text-threat-low"> Non</span>
)}
</td>
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_mode || '—'}</td>
<td className="py-1.5 pr-4 text-text-secondary">{ip.sec_fetch_dest || '—'}</td>
<td className="py-1.5">
<button
onClick={(e) => { e.stopPropagation(); onInvestigateIP(ip.ip); }}
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</td>
</tr>
)}
</>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HeaderFingerprintView() {
@ -226,6 +84,11 @@ export function HeaderFingerprintView() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedHash, setExpandedHash] = useState<string | null>(null);
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
useEffect(() => {
const fetchClusters = async () => {
setLoading(true);
@ -244,9 +107,157 @@ export function HeaderFingerprintView() {
fetchClusters();
}, []);
const handleToggleCluster = async (hash: string) => {
if (expandedHash === hash) {
setExpandedHash(null);
return;
}
setExpandedHash(hash);
if (clusterIPsMap[hash] !== undefined) return;
setLoadingHashes((prev) => new Set(prev).add(hash));
try {
const res = await fetch(`/api/headers/cluster/${hash}/ips?limit=50`);
if (!res.ok) throw new Error('Erreur chargement IPs');
const data: { items: ClusterIP[] } = await res.json();
setClusterIPsMap((prev) => ({ ...prev, [hash]: data.items ?? [] }));
} catch (err) {
setIpErrors((prev) => ({ ...prev, [hash]: err instanceof Error ? err.message : 'Erreur inconnue' }));
} finally {
setLoadingHashes((prev) => {
const next = new Set(prev);
next.delete(hash);
return next;
});
}
};
const suspiciousClusters = clusters.filter((c) => c.ua_ch_mismatch_pct > 50).length;
const legitimateClusters = clusters.filter((c) => c.classification === 'legitimate').length;
const clusterColumns: Column<HeaderCluster>[] = [
{
key: 'hash',
label: 'Hash cluster',
sortable: true,
render: (_, row) => (
<span>
<span className="text-accent-primary text-xs mr-2">{expandedHash === row.hash ? '▾' : '▸'}</span>
<span className="font-mono text-xs text-text-primary">{row.hash.slice(0, 16)}</span>
</span>
),
},
{
key: 'unique_ips',
label: 'IPs',
sortable: true,
align: 'right',
render: (v) => <span className="text-text-primary">{formatNumber(v)}</span>,
},
{
key: 'avg_browser_score',
label: 'Browser Score',
sortable: true,
render: (v) => (
<div className="flex items-center gap-2">
<div className="w-20 bg-background-card rounded-full h-2">
<div className={`h-2 rounded-full ${browserScoreColor(v)}`} style={{ width: `${v}%` }} />
</div>
<span className="text-xs text-text-secondary">{Math.round(v)}</span>
</div>
),
},
{
key: 'ua_ch_mismatch_pct',
label: 'UA/CH Mismatch %',
sortable: true,
align: 'right',
render: (v) => (
<span className={`font-semibold text-sm ${mismatchColor(v)}`}>{Math.round(v)}%</span>
),
},
{
key: 'classification',
label: 'Classification',
sortable: true,
render: (v) => {
const badge = classificationBadge(v);
return (
<span className={`text-xs px-2 py-1 rounded-full ${badge.bg} ${badge.text}`}>{badge.label}</span>
);
},
},
{
key: 'top_sec_fetch_modes',
label: 'Sec-Fetch modes',
sortable: false,
render: (v) => (
<div className="flex flex-wrap gap-1">
{(v ?? []).slice(0, 3).map((mode: string) => (
<span key={mode} className="text-xs bg-background-card border border-border px-1.5 py-0.5 rounded text-text-secondary">
{mode}
</span>
))}
</div>
),
},
];
const ipColumns: Column<ClusterIP>[] = [
{
key: 'ip',
label: 'IP',
sortable: true,
render: (v) => <span className="font-mono text-text-primary">{v}</span>,
},
{
key: 'browser_score',
label: 'Browser Score',
sortable: true,
align: 'right',
render: (v) => (
<span className={v >= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
{Math.round(v)}
</span>
),
},
{
key: 'ua_ch_mismatch',
label: 'UA/CH Mismatch',
sortable: true,
render: (v) =>
v ? (
<span className="text-threat-critical"> Oui</span>
) : (
<span className="text-threat-low"> Non</span>
),
},
{
key: 'sec_fetch_mode',
label: 'Sec-Fetch Mode',
sortable: true,
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
},
{
key: 'sec_fetch_dest',
label: 'Sec-Fetch Dest',
sortable: true,
render: (v) => <span className="text-text-secondary">{v || '—'}</span>,
},
{
key: 'actions',
label: '',
sortable: false,
render: (_, row) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="bg-accent-primary/10 text-accent-primary px-2 py-0.5 rounded hover:bg-accent-primary/20 transition-colors text-xs"
>
Investiguer
</button>
),
},
];
return (
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
@ -264,36 +275,55 @@ export function HeaderFingerprintView() {
<StatCard label="Clusters légitimes" value={formatNumber(legitimateClusters)} accent="text-threat-low" />
</div>
{/* Table */}
{/* Clusters DataTable */}
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
{loading ? (
<LoadingSpinner />
) : error ? (
{error ? (
<div className="p-4"><ErrorMessage message={error} /></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">Hash cluster</th>
<th className="px-4 py-3">IPs</th>
<th className="px-4 py-3">Browser Score</th>
<th className="px-4 py-3">UA/CH Mismatch %</th>
<th className="px-4 py-3">Classification</th>
<th className="px-4 py-3">Sec-Fetch modes</th>
</tr>
</thead>
<tbody>
{clusters.map((cluster) => (
<ClusterRow
key={cluster.hash}
cluster={cluster}
onInvestigateIP={(ip) => navigate(`/investigation/${ip}`)}
/>
))}
</tbody>
</table>
<DataTable<HeaderCluster>
data={clusters}
columns={clusterColumns}
rowKey="hash"
defaultSortKey="unique_ips"
onRowClick={(row) => handleToggleCluster(row.hash)}
loading={loading}
emptyMessage="Aucun cluster détecté"
compact
/>
)}
</div>
{/* Expanded IPs panel */}
{expandedHash && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary">
IPs du cluster{' '}
<span className="font-mono text-xs text-accent-primary">{expandedHash.slice(0, 16)}</span>
</span>
<button
onClick={() => setExpandedHash(null)}
className="text-text-secondary hover:text-text-primary text-xs"
>
Fermer
</button>
</div>
{ipErrors[expandedHash] ? (
<div className="p-4"><ErrorMessage message={ipErrors[expandedHash]} /></div>
) : (
<DataTable<ClusterIP>
data={clusterIPsMap[expandedHash] ?? []}
columns={ipColumns}
rowKey="ip"
defaultSortKey="browser_score"
loading={loadingHashes.has(expandedHash)}
emptyMessage="Aucune IP trouvée"
compact
/>
)}
</div>
)}
{!loading && !error && (
<p className="text-text-secondary text-xs">{formatNumber(totalClusters)} cluster(s) détecté(s)</p>
)}

View File

@ -469,6 +469,9 @@ export function IncidentsView() {
</div>
</div>
</div>{/* end grid */}
<div className="mt-6">
<MiniHeatmap />
</div>
</div>
);
}
@ -500,3 +503,57 @@ function MetricCard({
</div>
);
}
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
interface HeatmapHour {
hour: number;
hits: number;
unique_ips: number;
}
function MiniHeatmap() {
const [data, setData] = useState<HeatmapHour[]>([]);
useEffect(() => {
fetch('/api/heatmap/hourly')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
.catch(() => {});
}, []);
if (data.length === 0) return null;
const maxHits = Math.max(...data.map(d => d.hits), 1);
const barColor = (hits: number) => {
const pct = (hits / maxHits) * 100;
if (pct >= 75) return 'bg-red-500/70';
if (pct >= 50) return 'bg-purple-500/60';
if (pct >= 25) return 'bg-blue-500/50';
if (pct >= 5) return 'bg-blue-400/30';
return 'bg-slate-700/30';
};
return (
<div className="bg-background-secondary border border-border rounded-lg p-4">
<div className="text-sm font-semibold text-text-primary mb-3"> Activité par heure (72h)</div>
<div className="flex items-end gap-px h-16">
{data.map((d, i) => (
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
<div
className={`w-full rounded-sm ${barColor(d.hits)}`}
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
{d.hits.toLocaleString()} hits {d.unique_ips} IPs
</div>
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -321,6 +322,89 @@ function ScatterPlot({ points }: { points: ScatterPoint[] }) {
);
}
// ─── Anomalies DataTable ─────────────────────────────────────────────────────
function AnomaliesTable({
anomalies,
selectedIP,
onRowClick,
}: {
anomalies: MLAnomaly[];
selectedIP: string | null;
onRowClick: (row: MLAnomaly) => void;
}) {
const columns = useMemo((): Column<MLAnomaly>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string, row: MLAnomaly) => (
<span className={`font-mono text-xs ${selectedIP === row.ip ? 'text-accent-primary' : 'text-text-primary'}`}>
{v}
</span>
),
},
{
key: 'host',
label: 'Host',
render: (v: string) => (
<span className="text-text-secondary max-w-[120px] truncate block" title={v}>
{v || '—'}
</span>
),
},
{
key: 'hits',
label: 'Hits',
align: 'right',
render: (v: number) => formatNumber(v),
},
{
key: 'fuzzing_index',
label: 'Fuzzing',
align: 'right',
render: (v: number) => (
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(v)}`}>
{Math.round(v)}
</span>
),
},
{
key: 'attack_type',
label: 'Type',
render: (v: string) => (
<span title={v} className="text-sm">{attackTypeEmoji(v)}</span>
),
},
{
key: '_signals',
label: 'Signaux',
sortable: false,
render: (_: unknown, row: MLAnomaly) => (
<span className="flex gap-0.5">
{row.ua_ch_mismatch && <span title="UA/CH mismatch"></span>}
{row.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
{row.is_ua_rotating && <span title="UA rotatif">🔄</span>}
{row.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
</span>
),
},
], [selectedIP]);
return (
<div className="overflow-auto max-h-[500px]">
<DataTable
data={anomalies}
columns={columns}
rowKey="ip"
defaultSortKey="fuzzing_index"
onRowClick={onRowClick}
emptyMessage="Aucune anomalie détectée"
compact
/>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function MLFeaturesView() {
@ -412,57 +496,11 @@ export function MLFeaturesView() {
) : anomaliesError ? (
<div className="p-4"><ErrorMessage message={anomaliesError} /></div>
) : (
<div className="overflow-auto max-h-[500px]">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-background-secondary z-10">
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Host</th>
<th className="px-3 py-2">Hits</th>
<th className="px-3 py-2">Fuzzing</th>
<th className="px-3 py-2">Type</th>
<th className="px-3 py-2">Signaux</th>
</tr>
</thead>
<tbody>
{anomalies.map((item) => (
<tr
key={item.ip}
onClick={() => loadRadar(item.ip)}
className={`border-b border-border cursor-pointer transition-colors ${
selectedIP === item.ip
? 'bg-accent-primary/10'
: 'hover:bg-background-card'
}`}
>
<td className="px-3 py-2 font-mono text-text-primary">{item.ip}</td>
<td className="px-3 py-2 text-text-secondary max-w-[120px] truncate" title={item.host}>
{item.host || ''}
</td>
<td className="px-3 py-2 text-text-primary">{formatNumber(item.hits)}</td>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-semibold ${fuzzingBadgeClass(item.fuzzing_index)}`}>
{Math.round(item.fuzzing_index)}
</span>
</td>
<td className="px-3 py-2">
<span title={item.attack_type} className="text-sm">
{attackTypeEmoji(item.attack_type)}
</span>
</td>
<td className="px-3 py-2">
<span className="flex gap-0.5">
{item.ua_ch_mismatch && <span title="UA/CH mismatch">⚠️</span>}
{item.is_fake_navigation && <span title="Fausse navigation">🎭</span>}
{item.is_ua_rotating && <span title="UA rotatif">🔄</span>}
{item.sni_host_mismatch && <span title="SNI mismatch">🌐</span>}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<AnomaliesTable
anomalies={anomalies}
selectedIP={selectedIP}
onRowClick={(row) => loadRadar(row.ip)}
/>
)}
</div>
</div>

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -75,6 +76,94 @@ function ErrorMessage({ message }: { message: string }) {
);
}
// ─── Detections DataTable ─────────────────────────────────────────────────────
function TcpDetectionsTable({
items,
navigate,
}: {
items: TcpSpoofingItem[];
navigate: (path: string) => void;
}) {
const columns = useMemo((): Column<TcpSpoofingItem>[] => [
{
key: 'ip',
label: 'IP',
render: (v: string) => <span className="font-mono text-xs text-text-primary">{v}</span>,
},
{
key: 'ja4',
label: 'JA4',
render: (v: string) => (
<span className="font-mono text-xs text-text-secondary">
{v ? `${v.slice(0, 14)}` : '—'}
</span>
),
},
{
key: 'tcp_ttl',
label: 'TTL observé',
align: 'right',
render: (v: number) => (
<span className={`font-mono font-semibold ${ttlColor(v)}`}>{v}</span>
),
},
{
key: 'tcp_window_size',
label: 'Fenêtre TCP',
align: 'right',
render: (v: number) => (
<span className="text-text-secondary text-xs">{formatNumber(v)}</span>
),
},
{
key: 'suspected_os',
label: 'OS suspecté',
render: (v: string) => <span className="text-text-primary text-xs">{v || '—'}</span>,
},
{
key: 'declared_os',
label: 'OS déclaré',
render: (v: string) => <span className="text-text-secondary text-xs">{v || '—'}</span>,
},
{
key: 'spoof_flag',
label: 'Spoof',
sortable: false,
render: (v: boolean) =>
v ? (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
🚨 Spoof
</span>
) : null,
},
{
key: '_actions',
label: '',
sortable: false,
render: (_: unknown, row: TcpSpoofingItem) => (
<button
onClick={(e) => { e.stopPropagation(); navigate(`/investigation/${row.ip}`); }}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
),
},
], [navigate]);
return (
<DataTable
data={items}
columns={columns}
rowKey="ip"
defaultSortKey="tcp_ttl"
emptyMessage="Aucune détection"
compact
/>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function TcpSpoofingView() {
@ -261,53 +350,7 @@ export function TcpSpoofingView() {
) : itemsError ? (
<div className="p-4"><ErrorMessage message={itemsError} /></div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-text-secondary text-left">
<th className="px-4 py-3">IP</th>
<th className="px-4 py-3">JA4</th>
<th className="px-4 py-3">TTL observé</th>
<th className="px-4 py-3">Fenêtre TCP</th>
<th className="px-4 py-3">OS suspecté</th>
<th className="px-4 py-3">OS déclaré</th>
<th className="px-4 py-3">Spoof</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => (
<tr key={item.ip} className="border-b border-border hover:bg-background-card transition-colors">
<td className="px-4 py-3 font-mono text-xs text-text-primary">{item.ip}</td>
<td className="px-4 py-3 font-mono text-xs text-text-secondary">
{item.ja4 ? `${item.ja4.slice(0, 14)}` : '—'}
</td>
<td className={`px-4 py-3 font-mono font-semibold ${ttlColor(item.tcp_ttl)}`}>
{item.tcp_ttl}
</td>
<td className="px-4 py-3 text-text-secondary text-xs">
{formatNumber(item.tcp_window_size)}
</td>
<td className="px-4 py-3 text-text-primary text-xs">{item.suspected_os || '—'}</td>
<td className="px-4 py-3 text-text-secondary text-xs">{item.declared_os || '—'}</td>
<td className="px-4 py-3">
{item.spoof_flag && (
<span className="bg-threat-critical/20 text-threat-critical text-xs px-2 py-0.5 rounded-full">
🚨 Spoof
</span>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/investigation/${item.ip}`)}
className="text-xs bg-accent-primary/10 text-accent-primary px-3 py-1 rounded hover:bg-accent-primary/20 transition-colors"
>
Investiguer
</button>
</td>
</tr>
))}
</tbody>
</table>
<TcpDetectionsTable items={filteredItems} navigate={navigate} />
)}
</div>
</>

View File

@ -0,0 +1,26 @@
import React from 'react';
interface CardProps {
title?: string;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export default function Card({ title, actions, children, className = '' }: CardProps) {
return (
<div
className={`bg-background-secondary border border-background-card rounded-lg overflow-hidden ${className}`}
>
{(title || actions) && (
<div className="flex items-center justify-between px-4 py-2.5 border-b border-background-card">
{title && (
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
)}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
<div className="p-0">{children}</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
import React from 'react';
import { useSort, SortDir } from '../../hooks/useSort';
export interface Column<T> {
key: string;
label: string;
sortable?: boolean;
align?: 'left' | 'right' | 'center';
width?: string;
render?: (value: any, row: T) => React.ReactNode;
className?: string;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
defaultSortKey?: string;
defaultSortDir?: SortDir;
onRowClick?: (row: T) => void;
rowKey: keyof T | ((row: T) => string);
emptyMessage?: string;
loading?: boolean;
className?: string;
compact?: boolean;
maxHeight?: string;
}
export default function DataTable<T extends Record<string, any>>({
data,
columns,
defaultSortKey,
defaultSortDir = 'desc',
onRowClick,
rowKey,
emptyMessage = 'Aucune donnée disponible',
loading = false,
className = '',
compact = false,
maxHeight,
}: DataTableProps<T>) {
const firstSortableKey =
defaultSortKey ||
columns.find((c) => c.sortable !== false)?.key ||
columns[0]?.key ||
'id';
const { sorted, sortKey, sortDir, handleSort } = useSort<T>(
data,
firstSortableKey as keyof T,
defaultSortDir
);
const cell = compact ? 'px-3 py-1.5' : 'px-4 py-2.5';
const getRowKey = (row: T): string => {
if (typeof rowKey === 'function') return rowKey(row);
return String(row[rowKey as keyof T] ?? '');
};
const alignClass = (align?: 'left' | 'right' | 'center') => {
if (align === 'right') return 'text-right';
if (align === 'center') return 'text-center';
return 'text-left';
};
return (
<div className={`${maxHeight ? `${maxHeight} overflow-y-auto` : ''} ${className}`}>
<table className="w-full">
<thead style={{ position: 'sticky', top: 0, zIndex: 10 }}>
<tr>
{columns.map((col) => {
const isSortable = col.sortable !== false;
const isActive = String(sortKey) === col.key;
return (
<th
key={col.key}
className={[
cell,
'text-xs font-semibold text-text-disabled uppercase tracking-wider',
'bg-background-secondary border-b border-background-card',
col.width ?? '',
alignClass(col.align),
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
].join(' ')}
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined}
>
<span className="inline-flex items-center gap-1">
{col.label}
{isSortable &&
(isActive ? (
<span className="text-accent-primary">
{sortDir === 'desc' ? '↓' : '↑'}
</span>
) : (
<span className="text-text-disabled opacity-50"></span>
))}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.key} className={`${cell} border-b border-background-card`}>
<div className="bg-background-card/50 rounded animate-pulse h-4" />
</td>
))}
</tr>
))
) : sorted.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="text-center py-8 text-text-disabled text-sm"
>
{emptyMessage}
</td>
</tr>
) : (
sorted.map((row) => (
<tr
key={getRowKey(row)}
className={[
'border-b border-background-card transition-colors',
'hover:bg-background-card/50',
onRowClick ? 'cursor-pointer' : '',
].join(' ')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => {
const value = row[col.key as keyof T];
return (
<td
key={col.key}
className={[cell, alignClass(col.align), col.className ?? ''].join(' ')}
>
{col.render ? col.render(value, row) : (value as React.ReactNode)}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1,35 @@
import React from 'react';
type Color = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'slate';
const COLOR_MAP: Record<Color, string> = {
red: 'text-red-400',
orange: 'text-orange-400',
yellow: 'text-yellow-400',
green: 'text-green-400',
blue: 'text-blue-400',
purple: 'text-purple-400',
slate: 'text-slate-400',
};
interface StatCardProps {
label: string;
value: string | number;
sub?: string;
color?: Color;
icon?: React.ReactNode;
}
export default function StatCard({ label, value, sub, color, icon }: StatCardProps) {
const valueClass = color ? COLOR_MAP[color] : 'text-text-primary';
return (
<div className="flex flex-col gap-0.5">
<div className="text-xs text-text-disabled uppercase tracking-wider flex items-center gap-1">
{icon && <span>{icon}</span>}
{label}
</div>
<div className={`text-xl font-bold ${valueClass}`}>{value}</div>
{sub && <div className="text-xs text-text-secondary mt-0.5">{sub}</div>}
</div>
);
}

View File

@ -0,0 +1,24 @@
type ThreatLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NORMAL' | 'KNOWN_BOT';
const BADGE_STYLES: Record<ThreatLevel, string> = {
CRITICAL: 'bg-red-900/50 text-red-400 border border-red-800/50',
HIGH: 'bg-orange-900/50 text-orange-400 border border-orange-800/50',
MEDIUM: 'bg-yellow-900/50 text-yellow-400 border border-yellow-800/50',
LOW: 'bg-green-900/50 text-green-400 border border-green-800/50',
NORMAL: 'bg-slate-700/50 text-slate-400 border border-slate-600/50',
KNOWN_BOT: 'bg-purple-900/50 text-purple-400 border border-purple-800/50',
};
interface ThreatBadgeProps {
level: string;
}
export default function ThreatBadge({ level }: ThreatBadgeProps) {
const key = (level?.toUpperCase() ?? 'NORMAL') as ThreatLevel;
const cls = BADGE_STYLES[key] ?? BADGE_STYLES.NORMAL;
return (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium uppercase ${cls}`}>
{level || 'NORMAL'}
</span>
);
}

View File

@ -0,0 +1,42 @@
import { useState, useMemo } from 'react';
export type SortDir = 'asc' | 'desc';
export function useSort<T extends Record<string, any>>(
data: T[],
defaultKey: keyof T,
defaultDir: SortDir = 'desc'
): {
sorted: T[];
sortKey: keyof T;
sortDir: SortDir;
handleSort: (key: keyof T) => void;
} {
const [sortKey, setSortKey] = useState<keyof T>(defaultKey);
const [sortDir, setSortDir] = useState<SortDir>(defaultDir);
const handleSort = (key: keyof T) => {
if (key === sortKey) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('desc');
}
};
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
let cmp = 0;
if (av == null && bv == null) cmp = 0;
else if (av == null) cmp = 1;
else if (bv == null) cmp = -1;
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
else cmp = String(av).localeCompare(String(bv));
return sortDir === 'desc' ? -cmp : cmp;
});
}, [data, sortKey, sortDir]);
return { sorted, sortKey, sortDir, handleSort };
}