import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable';
// ─── Types ────────────────────────────────────────────────────────────────────
interface HeaderCluster {
hash: string;
unique_ips: number;
avg_browser_score: number;
ua_ch_mismatch_count: number;
ua_ch_mismatch_pct: number;
top_sec_fetch_modes: string[];
has_cookie_pct: number;
has_referer_pct: number;
classification: string;
}
interface ClusterIP {
ip: string;
browser_score: number;
ua_ch_mismatch: boolean;
sec_fetch_mode: string;
sec_fetch_dest: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatNumber(n: number): string {
return n.toLocaleString('fr-FR');
}
function mismatchColor(pct: number): string {
if (pct > 50) return 'text-threat-critical';
if (pct > 10) return 'text-threat-medium';
return 'text-threat-low';
}
function browserScoreColor(score: number): string {
if (score >= 70) return 'bg-threat-low';
if (score >= 40) return 'bg-threat-medium';
return 'bg-threat-critical';
}
function classificationBadge(cls: string): { bg: string; text: string; label: string } {
switch (cls) {
case 'bot':
return { bg: 'bg-threat-critical/20', text: 'text-threat-critical', label: '🤖 Bot' };
case 'suspicious':
return { bg: 'bg-threat-high/20', text: 'text-threat-high', label: '⚠️ Suspect' };
case 'legitimate':
return { bg: 'bg-threat-low/20', text: 'text-threat-low', label: '✅ Légitime' };
default:
return { bg: 'bg-background-card', text: 'text-text-secondary', label: cls };
}
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
{label}
{value}
);
}
function ErrorMessage({ message }: { message: string }) {
return (
⚠️ {message}
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function HeaderFingerprintView() {
const navigate = useNavigate();
const [clusters, setClusters] = useState([]);
const [totalClusters, setTotalClusters] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expandedHash, setExpandedHash] = useState(null);
const [clusterIPsMap, setClusterIPsMap] = useState>({});
const [loadingHashes, setLoadingHashes] = useState>(new Set());
const [ipErrors, setIpErrors] = useState>({});
useEffect(() => {
const fetchClusters = async () => {
setLoading(true);
try {
const res = await fetch('/api/headers/clusters?limit=50');
if (!res.ok) throw new Error('Erreur chargement des clusters');
const data: { clusters: HeaderCluster[]; total_clusters: number } = await res.json();
setClusters(data.clusters ?? []);
setTotalClusters(data.total_clusters ?? 0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
} finally {
setLoading(false);
}
};
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[] = [
{
key: 'hash',
label: 'Hash cluster',
sortable: true,
render: (_, row) => (
{expandedHash === row.hash ? '▾' : '▸'}
{row.hash.slice(0, 16)}…
),
},
{
key: 'unique_ips',
label: 'IPs',
sortable: true,
align: 'right',
render: (v) => {formatNumber(v)},
},
{
key: 'avg_browser_score',
label: 'Browser Score',
sortable: true,
render: (v) => (
),
},
{
key: 'ua_ch_mismatch_pct',
label: 'UA/CH Mismatch %',
sortable: true,
align: 'right',
render: (v) => (
{Math.round(v)}%
),
},
{
key: 'classification',
label: 'Classification',
sortable: true,
render: (v) => {
const badge = classificationBadge(v);
return (
{badge.label}
);
},
},
{
key: 'top_sec_fetch_modes',
label: 'Sec-Fetch modes',
sortable: false,
render: (v) => (
{(v ?? []).slice(0, 3).map((mode: string) => (
{mode}
))}
),
},
];
const ipColumns: Column[] = [
{
key: 'ip',
label: 'IP',
sortable: true,
render: (v) => {v},
},
{
key: 'browser_score',
label: 'Browser Score',
sortable: true,
align: 'right',
render: (v) => (
= 70 ? 'text-threat-low' : v >= 40 ? 'text-threat-medium' : 'text-threat-critical'}>
{Math.round(v)}
),
},
{
key: 'ua_ch_mismatch',
label: 'UA/CH Mismatch',
sortable: true,
render: (v) =>
v ? (
⚠️ Oui
) : (
✓ Non
),
},
{
key: 'sec_fetch_mode',
label: 'Sec-Fetch Mode',
sortable: true,
render: (v) => {v || '—'},
},
{
key: 'sec_fetch_dest',
label: 'Sec-Fetch Dest',
sortable: true,
render: (v) => {v || '—'},
},
{
key: 'actions',
label: '',
sortable: false,
render: (_, row) => (
),
},
];
return (
{/* Header */}
📡 Fingerprint HTTP Headers
Clustering par ordre et composition des headers HTTP pour identifier les bots et fingerprints suspects.
{/* Stat cards */}
{/* Clusters DataTable */}
{error ? (
) : (
data={clusters}
columns={clusterColumns}
rowKey="hash"
defaultSortKey="unique_ips"
onRowClick={(row) => handleToggleCluster(row.hash)}
loading={loading}
emptyMessage="Aucun cluster détecté"
compact
/>
)}
{/* Expanded IPs panel */}
{expandedHash && (
IPs du cluster{' '}
{expandedHash.slice(0, 16)}…
{ipErrors[expandedHash] ? (
) : (
data={clusterIPsMap[expandedHash] ?? []}
columns={ipColumns}
rowKey="ip"
defaultSortKey="browser_score"
loading={loadingHashes.has(expandedHash)}
emptyMessage="Aucune IP trouvée"
compact
/>
)}
)}
{!loading && !error && (
{formatNumber(totalClusters)} cluster(s) détecté(s)
)}
);
}