suite des maj
This commit is contained in:
@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user