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

@ -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 */}