Initial commit: Bot Detector Dashboard for SOC Incident Response
🛡️ Dashboard complet pour l'analyse et la classification des menaces Fonctionnalités principales: - Visualisation des détections en temps réel (24h) - Investigation multi-entités (IP, JA4, ASN, Host, User-Agent) - Analyse de corrélation pour classification SOC - Clustering automatique par subnet/JA4/UA - Export des classifications pour ML Composants: - Backend: FastAPI (Python) + ClickHouse - Frontend: React + TypeScript + TailwindCSS - 6 routes API: metrics, detections, variability, attributes, analysis, entities - 7 types d'entités investigables Documentation ajoutée: - NAVIGATION_GRAPH.md: Graph complet de navigation - SOC_OPTIMIZATION_PROPOSAL.md: Proposition d'optimisation pour SOC • Réduction de 7 à 2 clics pour classification • Nouvelle vue /incidents clusterisée • Panel latéral d'investigation • Quick Search (Cmd+K) • Timeline interactive • Graph de corrélations Sécurité: - .gitignore configuré (exclut .env, secrets, node_modules) - Credentials dans .env (à ne pas committer) ⚠️ Audit sécurité réalisé - Voir recommandations dans SOC_OPTIMIZATION_PROPOSAL.md Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
571
frontend/src/components/DetectionsList.tsx
Normal file
571
frontend/src/components/DetectionsList.tsx
Normal file
@ -0,0 +1,571 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDetections } from '../hooks/useDetections';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function DetectionsList() {
|
||||
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') || 'anomaly_score') as SortField;
|
||||
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'asc') as SortOrder;
|
||||
|
||||
const { data, loading, error } = useDetections({
|
||||
page,
|
||||
page_size: 25,
|
||||
model_name: modelName,
|
||||
search,
|
||||
sort_by: sortField,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
|
||||
const [searchInput, setSearchInput] = useState(search || '');
|
||||
const [showColumnSelector, setShowColumnSelector] = useState(false);
|
||||
const [groupByIP, setGroupByIP] = useState(true); // Grouper par IP par défaut
|
||||
|
||||
// Configuration des colonnes
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>([
|
||||
{ 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: '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 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
|
||||
));
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('page', newPage.toString());
|
||||
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">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4">
|
||||
<p className="text-threat-critical">Erreur: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Traiter les données pour le regroupement par IP
|
||||
const processedData = (() => {
|
||||
if (!groupByIP) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Grouper par IP
|
||||
const ipGroups = new Map<string, typeof data.items[0]>();
|
||||
const ipStats = new Map<string, {
|
||||
first: Date;
|
||||
last: Date;
|
||||
count: number;
|
||||
ja4s: Set<string>;
|
||||
hosts: Set<string>;
|
||||
clientHeaders: Set<string>;
|
||||
}>();
|
||||
|
||||
data.items.forEach(item => {
|
||||
if (!ipGroups.has(item.src_ip)) {
|
||||
ipGroups.set(item.src_ip, item);
|
||||
ipStats.set(item.src_ip, {
|
||||
first: new Date(item.detected_at),
|
||||
last: new Date(item.detected_at),
|
||||
count: 1,
|
||||
ja4s: new Set([item.ja4 || '']),
|
||||
hosts: new Set([item.host || '']),
|
||||
clientHeaders: new Set([item.client_headers || ''])
|
||||
});
|
||||
} else {
|
||||
const stats = ipStats.get(item.src_ip)!;
|
||||
const itemDate = new Date(item.detected_at);
|
||||
if (itemDate < stats.first) stats.first = itemDate;
|
||||
if (itemDate > stats.last) stats.last = itemDate;
|
||||
stats.count++;
|
||||
if (item.ja4) stats.ja4s.add(item.ja4);
|
||||
if (item.host) stats.hosts.add(item.host);
|
||||
if (item.client_headers) stats.clientHeaders.add(item.client_headers);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
items: Array.from(ipGroups.values()).map(item => ({
|
||||
...item,
|
||||
hits: ipStats.get(item.src_ip)!.count,
|
||||
first_seen: ipStats.get(item.src_ip)!.first.toISOString(),
|
||||
last_seen: ipStats.get(item.src_ip)!.last.toISOString(),
|
||||
unique_ja4s: Array.from(ipStats.get(item.src_ip)!.ja4s),
|
||||
unique_hosts: Array.from(ipStats.get(item.src_ip)!.hosts),
|
||||
unique_client_headers: Array.from(ipStats.get(item.src_ip)!.clientHeaders)
|
||||
}))
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>{groupByIP ? processedData.items.length : data.items.length}</span>
|
||||
<span>→</span>
|
||||
<span>{data.total} détections</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Toggle Grouper par IP */}
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
|
||||
groupByIP
|
||||
? 'bg-accent-primary text-white border-accent-primary'
|
||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'}
|
||||
</button>
|
||||
|
||||
{/* Sélecteur de colonnes */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
|
||||
>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
|
||||
{showColumnSelector && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-background-secondary border border-background-card rounded-lg shadow-lg z-10 p-2">
|
||||
<p className="text-xs text-text-secondary mb-2 px-2">Afficher les colonnes</p>
|
||||
{columns.map(col => (
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 hover:bg-background-card rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.visible}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-text-primary">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recherche */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
value={modelName || ''}
|
||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">Tous modèles</option>
|
||||
<option value="Complet">Complet</option>
|
||||
<option value="Applicatif">Applicatif</option>
|
||||
</select>
|
||||
|
||||
{(modelName || search || sortField) && (
|
||||
<button
|
||||
onClick={() => setSearchParams({})}
|
||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
Effacer filtres
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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={() => {
|
||||
window.location.href = `/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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Page {data.page} sur {data.total_pages} ({data.total} détections)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
disabled={data.page === 1}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
disabled={data.page === data.total_pages}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant ModelBadge
|
||||
function ModelBadge({ model }: { model: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
Complet: 'bg-accent-primary/20 text-accent-primary',
|
||||
Applicatif: 'bg-purple-500/20 text-purple-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`${styles[model] || 'bg-background-card'} px-2 py-1 rounded text-xs`}>
|
||||
{model}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant ScoreBadge
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
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 (
|
||||
<span className={`font-mono text-sm ${color}`}>
|
||||
{score.toFixed(3)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour les drapeaux
|
||||
function getFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
Reference in New Issue
Block a user