import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useDetections } from '../hooks/useDetections'; import DataTable, { Column } from './ui/DataTable'; import { InfoTip } from './ui/Tooltip'; import { TIPS } from './ui/tooltips'; import { formatDate, formatDateOnly, formatTimeOnly } from '../utils/dateUtils'; type SortField = 'detected_at' | 'threat_level' | 'anomaly_score' | 'src_ip' | 'country_code' | 'asn_number' | 'host' | 'hits' | 'hit_velocity'; type SortOrder = 'asc' | 'desc'; interface ColumnConfig { key: string; label: string; visible: boolean; sortable: boolean; } interface DetectionRow { src_ip: string; ja4?: string; host?: string; client_headers?: string; model_name: string; anomaly_score: number; threat_level?: string; bot_name?: string; 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[]; anubis_bot_name?: string; anubis_bot_action?: string; anubis_bot_category?: string; } export function DetectionsList() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const page = parseInt(searchParams.get('page') || '1'); const modelName = searchParams.get('model_name') || undefined; const search = searchParams.get('search') || undefined; const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField; const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder; const scoreType = searchParams.get('score_type') || undefined; const [groupByIP, setGroupByIP] = useState(true); const { data, loading, error } = useDetections({ page, page_size: 25, model_name: modelName, search, sort_by: sortField, sort_order: sortOrder, group_by_ip: groupByIP, score_type: scoreType, }); const [searchInput, setSearchInput] = useState(search || ''); const [showColumnSelector, setShowColumnSelector] = useState(false); const [columns, setColumns] = useState([ { key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true }, { key: 'host', label: 'Host', visible: true, sortable: true }, { key: 'client_headers', label: 'Client Headers', visible: false, sortable: false }, { key: 'model_name', label: 'Modèle', visible: true, sortable: true }, { key: 'anomaly_score', label: 'Score', visible: true, sortable: true }, { key: 'anubis', label: '🤖 Anubis', visible: true, sortable: false }, { key: 'hits', label: 'Hits', visible: true, sortable: true }, { key: 'hit_velocity', label: 'Velocity', visible: true, sortable: true }, { key: 'asn', label: 'ASN', visible: true, sortable: true }, { key: 'country', label: 'Pays', visible: true, sortable: true }, { key: 'detected_at', label: 'Date', visible: true, sortable: true }, ]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const newParams = new URLSearchParams(searchParams); if (searchInput.trim()) { newParams.set('search', searchInput.trim()); } else { newParams.delete('search'); } newParams.set('page', '1'); setSearchParams(newParams); }; const handleFilterChange = (key: string, value: string) => { const newParams = new URLSearchParams(searchParams); if (value) { newParams.set(key, value); } else { newParams.delete(key); } newParams.set('page', '1'); setSearchParams(newParams); }; const toggleColumn = (key: string) => { setColumns(cols => cols.map(col => col.key === key ? { ...col, visible: !col.visible } : col )); }; const handlePageChange = (newPage: number) => { const newParams = new URLSearchParams(searchParams); newParams.set('page', newPage.toString()); setSearchParams(newParams); }; const handleSort = (key: string, dir: 'asc' | 'desc') => { const newParams = new URLSearchParams(searchParams); newParams.set('sort_by', key); newParams.set('sort_order', dir); newParams.set('page', '1'); setSearchParams(newParams); }; if (loading) { return (
Chargement...
); } if (error) { return (

Erreur: {error.message}

); } if (!data) return null; // Backend handles grouping — data is already grouped when groupByIP=true const processedData = data; // Build DataTable columns from visible column configs const tableColumns: Column[] = columns .filter((col) => col.visible) .map((col): Column => { switch (col.key) { case 'ip_ja4': return { key: 'src_ip', label: col.label, sortable: true, render: (_, row) => (
{row.src_ip}
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
{ja4}
))} {row.unique_ja4s.length > 3 && (
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
)}
) : (
{row.ja4 || '-'}
)}
), }; case 'host': return { key: 'host', label: col.label, sortable: true, render: (_, row) => groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
{row.unique_hosts.slice(0, 3).map((host, idx) => (
{host}
))} {row.unique_hosts.length > 3 && (
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
)}
) : (
{row.host || '-'}
), }; 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 ? (
{row.unique_client_headers.length} Header{row.unique_client_headers.length > 1 ? 's' : ''} unique{row.unique_client_headers.length > 1 ? 's' : ''}
{row.unique_client_headers.slice(0, 3).map((header, idx) => (
{header}
))} {row.unique_client_headers.length > 3 && (
+{row.unique_client_headers.length - 3} autre{row.unique_client_headers.length - 3 > 1 ? 's' : ''}
)}
) : (
{row.client_headers || '-'}
), }; case 'model_name': return { key: 'model_name', label: col.label, sortable: true, render: (_, row) => , }; case 'anubis': return { key: 'anubis_bot_name', label: ( 🤖 Anubis ), sortable: false, render: (_, row) => { const name = row.anubis_bot_name; const action = row.anubis_bot_action; const category = row.anubis_bot_category; if (!name) return ; const actionColor = action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' : action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' : 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'; return (
{name}
{action && {action}} {category && · {category}}
); }, }; case 'anomaly_score': return { key: 'anomaly_score', label: col.label, sortable: true, align: 'right' as const, render: (_, row) => ( ), }; case 'hits': return { key: 'hits', label: col.label, sortable: true, align: 'right' as const, render: (_, row) => (
{row.hits ?? 0}
), }; case 'hit_velocity': return { key: 'hit_velocity', label: col.label, sortable: true, align: 'right' as const, render: (_, row) => (
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'} req/s
), }; case 'asn': return { key: 'asn_org', label: col.label, sortable: true, render: (_, row) => (
{row.asn_org || row.asn_number || '-'}
{row.asn_number && (
AS{row.asn_number}
)}
), }; case 'country': return { key: 'country_code', label: col.label, sortable: true, align: 'center' as const, render: (_, row) => row.country_code ? ( {getFlag(row.country_code)} ) : ( - ), }; case 'detected_at': return { key: 'detected_at', label: col.label, sortable: true, render: (_, row) => groupByIP && row.first_seen ? (() => { const first = new Date(row.first_seen!); const last = new Date(row.last_seen!); const sameTime = first.getTime() === last.getTime(); const fmt = (d: Date) => formatDate(d.toISOString()); return sameTime ? (
{fmt(last)}
) : (
Premier: {fmt(first)}
Dernier: {fmt(last)}
); })() : ( <>
{formatDateOnly(row.detected_at)}
{formatTimeOnly(row.detected_at)}
), }; default: return { key: col.key, label: col.label, sortable: col.sortable }; } }); return (
{/* En-tête */}

Détections

{data.items.length} {data.total} détections
{/* Toggle Grouper par IP */} {/* Sélecteur de colonnes */}
{showColumnSelector && (

Afficher les colonnes

{columns.map(col => ( ))}
)}
{/* Recherche */}
setSearchInput(e.target.value)} placeholder="Rechercher IP, JA4, Host..." className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64" />
{/* Filtres */}
{(modelName || scoreType || search || sortField !== 'detected_at') && ( )}
{/* Tableau */}
data={processedData.items as DetectionRow[]} columns={tableColumns} rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`} defaultSortKey={sortField} defaultSortDir={sortOrder} onSort={handleSort} onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)} emptyMessage="Aucune détection trouvée" compact />
{/* Pagination */} {data.total_pages > 1 && (

Page {data.page} sur {data.total_pages} ({data.total} détections)

)}
); } // Composant ModelBadge function ModelBadge({ model }: { model: string }) { const styles: Record = { Complet: 'bg-accent-primary/20 text-accent-primary', Applicatif: 'bg-purple-500/20 text-purple-400', }; return ( {model} ); } // Composant ScoreBadge // Les scores non-IF (ANUBIS_DENY, KNOWN_BOT) sont stockés comme sentinels // (-1.0 et 0.0) et doivent être affichés comme des badges textuels, // pas comme des scores numériques calculés par l'IsolationForest. function ScoreBadge({ score, threatLevel, botName, anubisAction, }: { score: number; threatLevel?: string; botName?: string; anubisAction?: string; }) { // ANUBIS_DENY : menace identifiée par règle, pas par IF if (threatLevel === 'ANUBIS_DENY' || anubisAction === 'DENY') { return ( RÈGLE ); } // KNOWN_BOT : bot légitime identifié par dictionnaire ou Anubis ALLOW if (threatLevel === 'KNOWN_BOT' || (botName && botName !== '')) { return ( BOT ); } // Score IF réel let color = 'text-threat-low'; if (score < -0.3) color = 'text-threat-critical'; else if (score < -0.15) color = 'text-threat-high'; else if (score < -0.05) color = 'text-threat-medium'; return ( {score.toFixed(3)} ); } // Helper pour les drapeaux function getFlag(countryCode: string): string { const code = countryCode.toUpperCase(); return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)); } // Badge de réputation ASN function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) { if (score == null) return null; let bg: string; let text: string; let display: string; if (score < 0.3) { bg = 'bg-threat-critical/20'; text = 'text-threat-critical'; } else if (score < 0.6) { bg = 'bg-threat-medium/20'; text = 'text-threat-medium'; } else { bg = 'bg-threat-low/20'; text = 'text-threat-low'; } display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok'); return ( {display} ); }