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