maj cumulative
This commit is contained in:
@ -137,6 +137,7 @@ export const detectionsApi = {
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
group_by_ip?: boolean;
|
||||
}) => api.get<DetectionsListResponse>('/detections', { params }),
|
||||
|
||||
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),
|
||||
|
||||
@ -5,26 +5,26 @@ import { VariabilityPanel } from './VariabilityPanel';
|
||||
export function DetailsView() {
|
||||
const { type, value } = useParams<{ type: string; value: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const { data, loading, error } = useVariability(type || '', value || '');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-text-secondary">Chargement...</div>
|
||||
<div className="flex items-center justify-center h-64 text-text-secondary">
|
||||
Chargement…
|
||||
</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 className="bg-threat-critical_bg border border-threat-critical rounded-xl p-6">
|
||||
<p className="text-threat-critical font-semibold mb-4">Erreur : {error.message}</p>
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="mt-4 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
← Retour aux détections
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -32,141 +32,124 @@ export function DetailsView() {
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const typeLabels: Record<string, { label: string }> = {
|
||||
ip: { label: 'IP' },
|
||||
ja4: { label: 'JA4' },
|
||||
country: { label: 'Pays' },
|
||||
asn: { label: 'ASN' },
|
||||
host: { label: 'Host' },
|
||||
user_agent: { label: 'User-Agent' },
|
||||
const typeLabels: Record<string, string> = {
|
||||
ip: 'IP',
|
||||
ja4: 'JA4',
|
||||
country: 'Pays',
|
||||
asn: 'ASN',
|
||||
host: 'Host',
|
||||
user_agent: 'User-Agent',
|
||||
};
|
||||
const typeLabel = typeLabels[type || ''] || type;
|
||||
const isIP = type === 'ip';
|
||||
const isJA4 = type === 'ja4';
|
||||
|
||||
const typeInfo = typeLabels[type || ''] || { label: type };
|
||||
const first = data.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||
const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
|
||||
const sameDate = first && last && first.getTime() === last.getTime();
|
||||
|
||||
const fmtDate = (d: Date) =>
|
||||
d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) +
|
||||
' ' +
|
||||
d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<Link to="/" className="hover:text-text-primary transition-colors">Dashboard</Link>
|
||||
<nav className="flex items-center gap-2 text-xs text-text-secondary">
|
||||
<Link to="/" className="hover:text-text-primary">Dashboard</Link>
|
||||
<span>/</span>
|
||||
<Link to="/detections" className="hover:text-text-primary transition-colors">Détections</Link>
|
||||
<Link to="/detections" className="hover:text-text-primary">Détections</Link>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{typeInfo.label}: {value}</span>
|
||||
<span className="text-text-primary">{typeLabel}: {value}</span>
|
||||
</nav>
|
||||
|
||||
{/* En-tête */}
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Header card */}
|
||||
<div className="bg-background-secondary rounded-xl p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
{/* Identité */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
{typeInfo.label}
|
||||
</h1>
|
||||
<p className="font-mono text-text-secondary break-all">{value}</p>
|
||||
<p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-1">{typeLabel}</p>
|
||||
<p className="text-lg font-mono font-bold text-text-primary break-all">{value}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-text-primary">{data.total_detections}</div>
|
||||
<div className="text-text-secondary text-sm">détections (24h)</div>
|
||||
{type === 'ip' && value && (
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isIP && (
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/${encodeURIComponent(value)}`)}
|
||||
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
onClick={() => navigate(`/investigation/${encodeURIComponent(value!)}`)}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
🔍 Investigation complète
|
||||
</button>
|
||||
)}
|
||||
{type === 'ja4' && value && (
|
||||
{isJA4 && (
|
||||
<button
|
||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value)}`)}
|
||||
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors"
|
||||
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value!)}`)}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
🔍 Investigation JA4
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="bg-background-card hover:bg-background-card/70 text-text-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats rapides */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<StatBox
|
||||
label="IPs Uniques"
|
||||
value={data.unique_ips.toLocaleString()}
|
||||
/>
|
||||
<StatBox
|
||||
label="Première détection"
|
||||
value={formatDate(data.date_range.first_seen)}
|
||||
/>
|
||||
<StatBox
|
||||
label="Dernière détection"
|
||||
value={formatDate(data.date_range.last_seen)}
|
||||
/>
|
||||
<StatBox
|
||||
label="User-Agents"
|
||||
value={data.attributes.user_agents.length.toString()}
|
||||
/>
|
||||
{/* Métriques clés */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-5">
|
||||
<Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
|
||||
{!isIP && (
|
||||
<Metric label="IPs uniques" value={data.unique_ips.toLocaleString()} />
|
||||
)}
|
||||
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
|
||||
{first && last && (
|
||||
sameDate ? (
|
||||
<Metric label="Détecté le" value={fmtDate(last)} />
|
||||
) : (
|
||||
<div className="bg-background-card rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
|
||||
<p className="text-xs text-text-primary font-medium">{fmtDate(first)}</p>
|
||||
<p className="text-[10px] text-text-secondary">→ {fmtDate(last)}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights + Variabilité côte à côte */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
{data.insights.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
|
||||
{data.insights.map((insight, i) => (
|
||||
<InsightCard key={i} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={data.insights.length > 0 ? 'col-span-2' : 'col-span-3'}>
|
||||
<VariabilityPanel attributes={data.attributes} />
|
||||
{/* Insights */}
|
||||
{data.insights.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.insights.map((ins, i) => {
|
||||
const s: Record<string, string> = {
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
|
||||
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
|
||||
success: 'bg-green-500/10 border-green-500/40 text-green-400',
|
||||
};
|
||||
return (
|
||||
<div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
|
||||
{ins.message}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bouton retour */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={() => navigate('/detections')}
|
||||
className="bg-background-card hover:bg-background-card/80 text-text-primary px-6 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
← Retour aux détections
|
||||
</button>
|
||||
</div>
|
||||
{/* Attributs */}
|
||||
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs={isIP} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant StatBox
|
||||
function StatBox({ label, value }: { label: string; value: string }) {
|
||||
function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div className="bg-background-card rounded-lg p-4">
|
||||
<div className="text-xl font-bold text-text-primary">{value}</div>
|
||||
<div className="text-text-secondary text-xs">{label}</div>
|
||||
<div className="bg-background-card rounded-xl p-3">
|
||||
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant InsightCard
|
||||
function InsightCard({ insight }: { insight: { type: string; message: string } }) {
|
||||
const styles: Record<string, string> = {
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/50 text-yellow-500',
|
||||
info: 'bg-blue-500/10 border-blue-500/50 text-blue-400',
|
||||
success: 'bg-green-500/10 border-green-500/50 text-green-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles[insight.type] || styles.info} border rounded-lg p-4`}>
|
||||
<span>{insight.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour formater la date
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
@ -42,8 +42,10 @@ export function DetectionsList() {
|
||||
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 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 [groupByIP, setGroupByIP] = useState(true);
|
||||
|
||||
const { data, loading, error } = useDetections({
|
||||
page,
|
||||
@ -52,13 +54,11 @@ export function DetectionsList() {
|
||||
search,
|
||||
sort_by: sortField,
|
||||
sort_order: sortOrder,
|
||||
group_by_ip: groupByIP,
|
||||
});
|
||||
|
||||
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 },
|
||||
@ -107,6 +107,14 @@ export function DetectionsList() {
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@ -125,59 +133,8 @@ export function DetectionsList() {
|
||||
|
||||
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)
|
||||
}))
|
||||
};
|
||||
})();
|
||||
// Backend handles grouping — data is already grouped when groupByIP=true
|
||||
const processedData = data;
|
||||
|
||||
// Build DataTable columns from visible column configs
|
||||
const tableColumns: Column<DetectionRow>[] = columns
|
||||
@ -352,20 +309,25 @@ export function DetectionsList() {
|
||||
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' })}
|
||||
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) =>
|
||||
`${d.toLocaleDateString('fr-FR')} ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
return sameTime ? (
|
||||
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Premier:</span> {fmt(first)}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Dernier:</span> {fmt(last)}
|
||||
</div>
|
||||
</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')}
|
||||
@ -388,7 +350,7 @@ export function DetectionsList() {
|
||||
<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>{data.items.length}</span>
|
||||
<span>→</span>
|
||||
<span>{data.total} détections</span>
|
||||
</div>
|
||||
@ -403,8 +365,9 @@ export function DetectionsList() {
|
||||
? 'bg-accent-primary text-white border-accent-primary'
|
||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
||||
>
|
||||
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'}
|
||||
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
||||
</button>
|
||||
|
||||
{/* Sélecteur de colonnes */}
|
||||
@ -486,7 +449,9 @@ export function DetectionsList() {
|
||||
data={processedData.items as DetectionRow[]}
|
||||
columns={tableColumns}
|
||||
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
|
||||
defaultSortKey="anomaly_score"
|
||||
defaultSortKey={sortField}
|
||||
defaultSortDir={sortOrder}
|
||||
onSort={handleSort}
|
||||
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
|
||||
emptyMessage="Aucune détection trouvée"
|
||||
compact
|
||||
|
||||
@ -975,30 +975,28 @@ export function FingerprintsView() {
|
||||
const data = await res.json();
|
||||
const items: JA4AttributeItem[] = data.items || [];
|
||||
setJa4List(items);
|
||||
setLoadingList(false); // afficher la liste immédiatement
|
||||
|
||||
// Auto-enrich top 30 by count
|
||||
// Enrichissement variability en arrière-plan (sans bloquer le rendu)
|
||||
const top30 = items
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 30);
|
||||
|
||||
await Promise.all(
|
||||
top30.map(async (item) => {
|
||||
try {
|
||||
const vRes = await fetch(
|
||||
`/api/variability/ja4/${encodeURIComponent(item.value)}`
|
||||
);
|
||||
if (!vRes.ok) return;
|
||||
const vData: VariabilityData = await vRes.json();
|
||||
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
|
||||
} catch {
|
||||
// ignore individual errors
|
||||
}
|
||||
})
|
||||
);
|
||||
top30.forEach(async (item) => {
|
||||
try {
|
||||
const vRes = await fetch(
|
||||
`/api/variability/ja4/${encodeURIComponent(item.value)}`
|
||||
);
|
||||
if (!vRes.ok) return;
|
||||
const vData: VariabilityData = await vRes.json();
|
||||
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
|
||||
} catch {
|
||||
// ignore individual errors
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('FingerprintsView:', err);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
@ -1434,7 +1432,8 @@ interface PersistentThreat {
|
||||
interface JA4HistoryEntry {
|
||||
ja4: string;
|
||||
hits: number;
|
||||
window_start: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface SophisticationItem {
|
||||
@ -1544,7 +1543,7 @@ function RotatorRow({ item }: { item: JA4Rotator }) {
|
||||
<div key={idx} className="flex items-center gap-3 text-xs">
|
||||
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
|
||||
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
|
||||
<span className="text-text-disabled">{formatDate(entry.window_start)}</span>
|
||||
<span className="text-text-disabled">{formatDate(entry.first_seen)} → {formatDate(entry.last_seen)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
|
||||
@ -88,6 +88,7 @@ export function HeaderFingerprintView() {
|
||||
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
|
||||
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
|
||||
const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
|
||||
const expandedPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchClusters = async () => {
|
||||
@ -113,6 +114,7 @@ export function HeaderFingerprintView() {
|
||||
return;
|
||||
}
|
||||
setExpandedHash(hash);
|
||||
setTimeout(() => expandedPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
||||
if (clusterIPsMap[hash] !== undefined) return;
|
||||
setLoadingHashes((prev) => new Set(prev).add(hash));
|
||||
try {
|
||||
@ -289,13 +291,14 @@ export function HeaderFingerprintView() {
|
||||
loading={loading}
|
||||
emptyMessage="Aucun cluster détecté"
|
||||
compact
|
||||
maxHeight="max-h-[480px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded IPs panel */}
|
||||
{expandedHash && (
|
||||
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden">
|
||||
<div ref={expandedPanelRef} 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{' '}
|
||||
|
||||
@ -56,10 +56,10 @@ export function JA4InvestigationView() {
|
||||
unique_ips: ipsData.total || 0,
|
||||
first_seen: baseData.date_range?.first_seen || '',
|
||||
last_seen: baseData.date_range?.last_seen || '',
|
||||
top_ips: ipsData.ips?.slice(0, 10).map((ip: string) => ({
|
||||
ip,
|
||||
count: 0,
|
||||
percentage: 0
|
||||
top_ips: ipsData.ips?.slice(0, 10).map((item: any) => ({
|
||||
ip: typeof item === 'string' ? item : item.ip,
|
||||
count: typeof item === 'string' ? 0 : (item.count || 0),
|
||||
percentage: typeof item === 'string' ? 0 : (item.percentage || 0)
|
||||
})) || [],
|
||||
top_countries: countriesData.items?.map((item: any) => ({
|
||||
code: item.value,
|
||||
|
||||
@ -2,66 +2,57 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'ip' | 'ja4' | 'asn' | 'host' | 'user_agent';
|
||||
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||
value: string;
|
||||
count?: number;
|
||||
threat_level?: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
url: string;
|
||||
investigation_url?: string;
|
||||
}
|
||||
|
||||
interface QuickSearchProps {
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<string, string> = { ip: '🌐', ja4: '🔏', host: '🖥️', asn: '🏢' };
|
||||
const TYPE_COLOR: Record<string, string> = {
|
||||
ip: 'bg-blue-500/20 text-blue-400',
|
||||
ja4: 'bg-purple-500/20 text-purple-400',
|
||||
host: 'bg-green-500/20 text-green-400',
|
||||
asn: 'bg-orange-500/20 text-orange-400',
|
||||
};
|
||||
|
||||
export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Détection du type de recherche
|
||||
const detectType = (value: string): 'ip' | 'ja4' | 'asn' | 'host' | 'other' => {
|
||||
// IPv4 pattern
|
||||
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)) return 'ip';
|
||||
// IPv6 pattern (simplified)
|
||||
if (/^[0-9a-fA-F:]+$/.test(value) && value.includes(':')) return 'ip';
|
||||
// JA4 pattern
|
||||
if (/^t[0-9a-f]{2}[0-9a-f]{4}/.test(value)) return 'ja4';
|
||||
// ASN pattern
|
||||
if (/^AS?\d+$/i.test(value)) return 'asn';
|
||||
// Host pattern
|
||||
if (/\.[a-z]{2,}$/.test(value)) return 'host';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
// Recherche automatique
|
||||
// Recherche via le nouvel endpoint unifié
|
||||
useEffect(() => {
|
||||
if (query.length < 3) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
if (query.length < 2) { setResults([]); return; }
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const type = detectType(query);
|
||||
const endpoint = type === 'other' ? 'ip' : type;
|
||||
const response = await fetch(`/api/attributes/${endpoint}?limit=5`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const items = data.items || data || [];
|
||||
setResults(Array.isArray(items) ? items : []);
|
||||
} else {
|
||||
setResults([]);
|
||||
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, 250);
|
||||
}, [query]);
|
||||
|
||||
// Raccourci clavier Cmd+K
|
||||
@ -72,52 +63,39 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||
inputRef.current?.focus();
|
||||
setIsOpen(true);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Navigation au clavier
|
||||
// Navigation clavier dans les résultats
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (!isOpen || results.length === 0) return;
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
|
||||
if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(results[selectedIndex]);
|
||||
handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, results, selectedIndex]);
|
||||
|
||||
// Click outside
|
||||
// Click en dehors
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (result: SearchResult) => {
|
||||
const type = result.type || detectType(result.value);
|
||||
navigate(`/investigate/${type}/${encodeURIComponent(result.value)}`);
|
||||
const handleSelect = (result: SearchResult, useInvestigation = false) => {
|
||||
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||
navigate(url);
|
||||
setIsOpen(false);
|
||||
setQuery('');
|
||||
onNavigate?.();
|
||||
@ -125,53 +103,24 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.length >= 3) {
|
||||
const type = detectType(query);
|
||||
navigate(`/investigate/${type}/${encodeURIComponent(query)}`);
|
||||
setIsOpen(false);
|
||||
onNavigate?.();
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ip': return '🌐';
|
||||
case 'ja4': return '🔐';
|
||||
case 'asn': return '🏢';
|
||||
case 'host': return '🖥️';
|
||||
case 'user_agent': return '🤖';
|
||||
default: return '🔍';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ip': return 'bg-blue-500/20 text-blue-400';
|
||||
case 'ja4': return 'bg-purple-500/20 text-purple-400';
|
||||
case 'asn': return 'bg-orange-500/20 text-orange-400';
|
||||
case 'host': return 'bg-green-500/20 text-green-400';
|
||||
default: return 'bg-gray-500/20 text-gray-400';
|
||||
}
|
||||
if (results[0]) handleSelect(results[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full max-w-2xl">
|
||||
{/* Search Bar */}
|
||||
{/* Barre de recherche */}
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus:border-accent-primary transition-colors">
|
||||
<span className="pl-4 text-text-secondary">🔍</span>
|
||||
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus-within:border-accent-primary transition-colors">
|
||||
<span className="pl-4 text-text-secondary">{loading ? <span className="animate-pulse">⌛</span> : '🔍'}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
|
||||
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
|
||||
<span>⌘</span>K
|
||||
@ -179,79 +128,52 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results Dropdown */}
|
||||
{isOpen && (query.length >= 3) && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
|
||||
{/* Dropdown résultats */}
|
||||
{isOpen && query.length >= 2 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-xl shadow-2xl z-50 max-h-96 overflow-y-auto">
|
||||
{results.length > 0 ? (
|
||||
<div className="py-2">
|
||||
<div className="px-4 py-2 text-xs text-text-secondary border-b border-background-card">
|
||||
Résultats suggérés
|
||||
</div>
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
key={`${result.type}-${result.value}`}
|
||||
onClick={() => handleSelect(result)}
|
||||
className={`w-full px-4 py-3 flex items-center gap-3 hover:bg-background-card transition-colors ${
|
||||
index === selectedIndex ? 'bg-background-card' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{getTypeIcon(result.type)}</span>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-mono text-sm text-text-primary">{result.value}</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
{result.type} {result.count && `• ${result.count} détections`}
|
||||
<ul className="py-1">
|
||||
{results.map((result, i) => (
|
||||
<li key={`${result.type}-${result.value}-${i}`}>
|
||||
<button
|
||||
onClick={() => handleSelect(result)}
|
||||
className={[
|
||||
'w-full flex items-center gap-3 px-4 py-2.5 transition-colors text-left',
|
||||
i === selectedIndex ? 'bg-accent-primary/10 border-l-2 border-accent-primary' : 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||
].join(' ')}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="text-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
|
||||
<div className="text-xs text-text-secondary">{result.meta}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs ${getTypeColor(result.type)}`}>
|
||||
{result.type.toUpperCase()}
|
||||
</span>
|
||||
</button>
|
||||
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
|
||||
{result.type.toUpperCase()}
|
||||
</span>
|
||||
{result.investigation_url && (
|
||||
<button
|
||||
className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
|
||||
onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
|
||||
title="Investigation complète"
|
||||
>→</button>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : !loading ? (
|
||||
<div className="px-4 py-6 text-center text-text-disabled text-sm">
|
||||
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-text-secondary">
|
||||
<div className="text-2xl mb-2">🔍</div>
|
||||
<div className="text-sm">
|
||||
Tapez pour rechercher une IP, JA4, ASN, Host...
|
||||
</div>
|
||||
<div className="text-xs mt-2">
|
||||
Appuyez sur Entrée pour rechercher "{query}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="border-t border-background-card px-4 py-3">
|
||||
<div className="text-xs text-text-secondary mb-2">Actions rapides</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/incidents?threat_level=CRITICAL');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-threat-critical/20 text-threat-critical rounded text-xs hover:bg-threat-critical/30 transition-colors"
|
||||
>
|
||||
🔴 Menaces Critiques
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/detections');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-accent-primary/20 text-accent-primary rounded text-xs hover:bg-accent-primary/30 transition-colors"
|
||||
>
|
||||
🔍 Investigation avancée
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/threat-intel');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-purple-500/20 text-purple-400 rounded text-xs hover:bg-purple-500/30 transition-colors"
|
||||
>
|
||||
📚 Threat Intel
|
||||
</button>
|
||||
</div>
|
||||
{/* Hints */}
|
||||
<div className="border-t border-background-card px-4 py-2 flex items-center gap-3 text-xs text-text-disabled">
|
||||
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||
<span className="ml-auto opacity-60">24h</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
182
frontend/src/components/SearchModal.tsx
Normal file
182
frontend/src/components/SearchModal.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface SearchResult {
|
||||
type: 'ip' | 'ja4' | 'host' | 'asn';
|
||||
value: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
url: string;
|
||||
investigation_url?: string;
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<string, string> = {
|
||||
ip: '🌐',
|
||||
ja4: '🔏',
|
||||
host: '🖥️',
|
||||
asn: '🏢',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
ip: 'IP',
|
||||
ja4: 'JA4',
|
||||
host: 'Host',
|
||||
asn: 'ASN',
|
||||
};
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSelected(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const search = useCallback(async (q: string) => {
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
setSelected(0);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setQuery(val);
|
||||
if (debounce.current) clearTimeout(debounce.current);
|
||||
debounce.current = setTimeout(() => search(val), 200);
|
||||
};
|
||||
|
||||
const go = (result: SearchResult, useInvestigation = false) => {
|
||||
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
|
||||
navigate(url);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
|
||||
if (e.key === 'Enter' && results[selected]) {
|
||||
go(results[selected], e.metaKey || e.ctrlKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl bg-background-secondary border border-background-card rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-background-card">
|
||||
<span className="text-text-disabled text-lg">🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
placeholder="Rechercher IP, JA4, host, ASN..."
|
||||
className="flex-1 bg-transparent text-text-primary placeholder-text-disabled outline-none text-base"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="text-text-disabled text-xs animate-pulse">…</span>
|
||||
)}
|
||||
<kbd className="hidden sm:inline text-xs text-text-disabled bg-background-card px-1.5 py-0.5 rounded border border-border">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results.length > 0 && (
|
||||
<ul className="max-h-96 overflow-y-auto py-2">
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.type}-${r.value}-${i}`}>
|
||||
<button
|
||||
className={[
|
||||
'w-full flex items-start gap-3 px-4 py-2.5 text-left transition-colors',
|
||||
i === selected
|
||||
? 'bg-accent-primary/10 border-l-2 border-accent-primary'
|
||||
: 'hover:bg-background-card/50 border-l-2 border-transparent',
|
||||
].join(' ')}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onClick={() => go(r)}
|
||||
>
|
||||
<span className="mt-0.5 text-base">{TYPE_ICON[r.type]}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase text-text-disabled tracking-wider">
|
||||
{TYPE_LABEL[r.type]}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-text-primary truncate">{r.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary mt-0.5">{r.meta}</div>
|
||||
</div>
|
||||
{r.investigation_url && (
|
||||
<button
|
||||
className="shrink-0 text-xs text-accent-primary hover:underline"
|
||||
onClick={e => { e.stopPropagation(); go(r, true); }}
|
||||
title="Ouvrir l'investigation complète"
|
||||
>
|
||||
Investigation →
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{query.length >= 2 && !loading && results.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-text-disabled text-sm">
|
||||
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer hints */}
|
||||
<div className="px-4 py-2 border-t border-background-card flex items-center gap-4 text-xs text-text-disabled">
|
||||
<span><kbd className="bg-background-card px-1 rounded">↑↓</kbd> naviguer</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">↵</kbd> ouvrir</span>
|
||||
<span><kbd className="bg-background-card px-1 rounded">⌘↵</kbd> investigation</span>
|
||||
<span className="ml-auto">Recherche sur les 24 dernières heures</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -41,7 +41,7 @@ export function ThreatIntelView() {
|
||||
const statsResponse = await fetch('/api/analysis/classifications/stats');
|
||||
if (statsResponse.ok) {
|
||||
const data = await statsResponse.json();
|
||||
setStats(data.items || []);
|
||||
setStats(data.stats || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching threat intel:', error);
|
||||
@ -248,7 +248,7 @@ export function ThreatIntelView() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||
{getLabelIcon(classification.label)} {classification.label.toUpperCase()}
|
||||
{getLabelIcon(classification.label)} {(classification.label ?? '').toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@ -4,163 +4,146 @@ import { VariabilityAttributes, AttributeValue } from '../api/client';
|
||||
|
||||
interface VariabilityPanelProps {
|
||||
attributes: VariabilityAttributes;
|
||||
/** When true, hides the "Voir IPs associées" button (e.g. when already on an IP page) */
|
||||
hideAssociatedIPs?: boolean;
|
||||
}
|
||||
|
||||
export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
|
||||
const [showModal, setShowModal] = useState<{
|
||||
type: string;
|
||||
export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
|
||||
const [modal, setModal] = useState<{
|
||||
title: string;
|
||||
items: string[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fonction pour charger la liste des IPs associées
|
||||
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
||||
const data = await response.json();
|
||||
setShowModal({
|
||||
type: 'ips',
|
||||
title: `${data.total || total} IPs associées à ${value}`,
|
||||
const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
|
||||
const data = await res.json();
|
||||
setModal({
|
||||
title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
|
||||
items: data.ips || [],
|
||||
total: data.total || total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement IPs:', error);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const sections: Array<{
|
||||
title: string;
|
||||
icon: string;
|
||||
items: AttributeValue[] | undefined;
|
||||
getLink: (v: AttributeValue) => string;
|
||||
attrType?: string;
|
||||
mono?: boolean;
|
||||
}> = [
|
||||
{
|
||||
title: 'JA4 Fingerprints',
|
||||
icon: '🔏',
|
||||
items: attributes.ja4,
|
||||
getLink: (v) => `/investigation/ja4/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'ja4',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
title: 'Hosts ciblés',
|
||||
icon: '🌐',
|
||||
items: attributes.hosts,
|
||||
getLink: (v) => `/detections/host/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'host',
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
title: 'ASN',
|
||||
icon: '🏢',
|
||||
items: attributes.asns,
|
||||
getLink: (v) => {
|
||||
const n = v.value.match(/AS(\d+)/)?.[1] || v.value;
|
||||
return `/detections/asn/${encodeURIComponent(n)}`;
|
||||
},
|
||||
attrType: 'asn',
|
||||
},
|
||||
{
|
||||
title: 'Pays',
|
||||
icon: '🌍',
|
||||
items: attributes.countries,
|
||||
getLink: (v) => `/detections/country/${encodeURIComponent(v.value)}`,
|
||||
attrType: 'country',
|
||||
},
|
||||
{
|
||||
title: 'Niveaux de menace',
|
||||
icon: '⚠️',
|
||||
items: attributes.threat_levels,
|
||||
getLink: (v) => `/detections?threat_level=${encodeURIComponent(v.value)}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
|
||||
|
||||
{/* JA4 Fingerprints */}
|
||||
{attributes.ja4 && attributes.ja4.length > 0 && (
|
||||
<AttributeSection
|
||||
title="JA4 Fingerprints"
|
||||
items={attributes.ja4}
|
||||
getValue={(item) => item.value}
|
||||
getLink={(item) => `/investigation/ja4/${encodeURIComponent(item.value)}`}
|
||||
onViewAll={(value, count) => loadAssociatedIPs('ja4', value, count)}
|
||||
showViewAll
|
||||
viewAllLabel="Voir les IPs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User-Agents */}
|
||||
{/* User-Agents — plein format avec texte long */}
|
||||
{attributes.user_agents && attributes.user_agents.length > 0 && (
|
||||
<UASection items={attributes.user_agents} />
|
||||
)}
|
||||
|
||||
{/* Pays */}
|
||||
{attributes.countries && attributes.countries.length > 0 && (
|
||||
<AttributeSection
|
||||
title="Pays"
|
||||
items={attributes.countries}
|
||||
getValue={(item) => item.value}
|
||||
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`}
|
||||
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)}
|
||||
showViewAll
|
||||
viewAllLabel="Voir les IPs"
|
||||
/>
|
||||
)}
|
||||
{/* Grille 2 colonnes pour les autres attributs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sections.map((s) =>
|
||||
s.items && s.items.length > 0 ? (
|
||||
<AttributeSection
|
||||
key={s.title}
|
||||
title={s.title}
|
||||
icon={s.icon}
|
||||
items={s.items}
|
||||
getLink={s.getLink}
|
||||
attrType={s.attrType}
|
||||
mono={s.mono}
|
||||
hideAssociatedIPs={hideAssociatedIPs}
|
||||
onLoadIPs={loadAssociatedIPs}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASN */}
|
||||
{attributes.asns && attributes.asns.length > 0 && (
|
||||
<AttributeSection
|
||||
title="ASN"
|
||||
items={attributes.asns}
|
||||
getValue={(item) => item.value}
|
||||
getLink={(item) => {
|
||||
const asnNumber = item.value.match(/AS(\d+)/)?.[1] || item.value;
|
||||
return `/detections/asn/${encodeURIComponent(asnNumber)}`;
|
||||
}}
|
||||
onViewAll={(value, count) => loadAssociatedIPs('asn', value, count)}
|
||||
showViewAll
|
||||
viewAllLabel="Voir les IPs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hosts */}
|
||||
{attributes.hosts && attributes.hosts.length > 0 && (
|
||||
<AttributeSection
|
||||
title="Hosts"
|
||||
items={attributes.hosts}
|
||||
getValue={(item) => item.value}
|
||||
getLink={(item) => `/detections/host/${encodeURIComponent(item.value)}`}
|
||||
onViewAll={(value, count) => loadAssociatedIPs('host', value, count)}
|
||||
showViewAll
|
||||
viewAllLabel="Voir les IPs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Threat Levels */}
|
||||
{attributes.threat_levels && attributes.threat_levels.length > 0 && (
|
||||
<AttributeSection
|
||||
title="Niveaux de Menace"
|
||||
items={attributes.threat_levels}
|
||||
getValue={(item) => item.value}
|
||||
getLink={(item) => `/detections?threat_level=${encodeURIComponent(item.value)}`}
|
||||
onViewAll={(value, count) => loadAssociatedIPs('threat_level', value, count)}
|
||||
showViewAll
|
||||
viewAllLabel="Voir les IPs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal pour afficher la liste complète */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-background-secondary rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-background-card">
|
||||
<h3 className="text-xl font-semibold text-text-primary">{showModal.title}</h3>
|
||||
<button
|
||||
onClick={() => setShowModal(null)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/* Modal IPs associées */}
|
||||
{(modal || loading) && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-background-secondary rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col shadow-2xl">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-background-card">
|
||||
<h3 className="font-semibold text-text-primary">{modal?.title ?? 'Chargement…'}</h3>
|
||||
<button onClick={() => setModal(null)} className="text-text-secondary hover:text-text-primary text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-text-secondary py-8">Chargement...</div>
|
||||
) : showModal.items.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{showModal.items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all"
|
||||
<p className="text-center text-text-secondary py-8">Chargement…</p>
|
||||
) : modal && modal.items.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{modal.items.map((ip, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/detections/ip/${ip}`}
|
||||
onClick={() => setModal(null)}
|
||||
className="bg-background-card hover:bg-background-card/70 rounded px-3 py-2 font-mono text-sm text-accent-primary transition-colors"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
{ip}
|
||||
</Link>
|
||||
))}
|
||||
{showModal.total > showModal.items.length && (
|
||||
<p className="text-center text-text-secondary text-sm mt-4">
|
||||
Affichage de {showModal.items.length} sur {showModal.total} éléments
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-text-secondary py-8">
|
||||
Aucune donnée disponible
|
||||
</div>
|
||||
<p className="text-center text-text-secondary py-8">Aucune donnée</p>
|
||||
)}
|
||||
{modal && modal.total > modal.items.length && (
|
||||
<p className="text-center text-text-secondary text-xs mt-4">
|
||||
{modal.items.length} / {modal.total} affichées
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-background-card text-right">
|
||||
<button
|
||||
onClick={() => setShowModal(null)}
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
<div className="px-6 py-4 border-t border-background-card text-right">
|
||||
<button onClick={() => setModal(null)} className="bg-accent-primary hover:bg-accent-primary/80 text-white px-5 py-2 rounded-lg text-sm">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -169,159 +152,107 @@ export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Composant UASection — jamais de troncature, expand/collapse
|
||||
function UASection({ items }: { items: AttributeValue[] }) {
|
||||
/* ─── AttributeSection ─────────────────────────────────────────────────────── */
|
||||
function AttributeSection({
|
||||
title,
|
||||
icon,
|
||||
items,
|
||||
getLink,
|
||||
attrType,
|
||||
mono,
|
||||
hideAssociatedIPs,
|
||||
onLoadIPs,
|
||||
}: {
|
||||
title: string;
|
||||
icon: string;
|
||||
items: AttributeValue[];
|
||||
getLink: (v: AttributeValue) => string;
|
||||
attrType?: string;
|
||||
mono?: boolean;
|
||||
hideAssociatedIPs?: boolean;
|
||||
onLoadIPs: (type: string, value: string, count: number) => void;
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const INITIAL = 5;
|
||||
const displayed = showAll ? items : items.slice(0, INITIAL);
|
||||
const LIMIT = 8;
|
||||
const displayed = showAll ? items : items.slice(0, LIMIT);
|
||||
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">
|
||||
User-Agents ({items.length})
|
||||
<div className="bg-background-secondary rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span>{icon}</span> {title} <span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{displayed.map((item, index) => (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="text-text-primary font-medium text-xs font-mono break-all leading-relaxed flex-1">
|
||||
{item.value}
|
||||
<div className="space-y-2">
|
||||
{displayed.map((item, i) => {
|
||||
const pct = item.percentage || 0;
|
||||
const barColor =
|
||||
pct >= 50 ? 'bg-threat-critical' :
|
||||
pct >= 25 ? 'bg-threat-high' :
|
||||
pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Link
|
||||
to={getLink(item)}
|
||||
className={`flex-1 text-xs hover:text-accent-primary transition-colors text-text-primary truncate ${mono ? 'font-mono' : ''}`}
|
||||
title={item.value}
|
||||
>
|
||||
{item.value}
|
||||
</Link>
|
||||
<span className="text-xs text-text-secondary shrink-0">{item.count} ({pct.toFixed(0)}%)</span>
|
||||
{!hideAssociatedIPs && attrType && (
|
||||
<button
|
||||
onClick={() => onLoadIPs(attrType, item.value, item.count)}
|
||||
className="shrink-0 text-xs text-text-secondary hover:text-accent-primary transition-colors"
|
||||
title="Voir les IPs associées"
|
||||
>
|
||||
👥
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-text-primary font-medium">{item.count}</div>
|
||||
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
|
||||
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-threat-medium transition-all"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length > INITIAL && (
|
||||
{items.length > LIMIT && (
|
||||
<button
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
|
||||
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
|
||||
>
|
||||
{showAll ? '↑ Réduire' : `↓ Voir les ${items.length - INITIAL} autres`}
|
||||
{showAll ? '↑ Réduire' : `↓ ${items.length - LIMIT} de plus`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant AttributeSection
|
||||
function AttributeSection({
|
||||
title,
|
||||
items,
|
||||
getValue,
|
||||
getLink,
|
||||
onViewAll,
|
||||
showViewAll = false,
|
||||
viewAllLabel = 'Voir les IPs',
|
||||
}: {
|
||||
title: string;
|
||||
items: AttributeValue[];
|
||||
getValue: (item: AttributeValue) => string;
|
||||
getLink: (item: AttributeValue) => string;
|
||||
onViewAll?: (value: string, count: number) => void;
|
||||
showViewAll?: boolean;
|
||||
viewAllLabel?: string;
|
||||
}) {
|
||||
const displayItems = items.slice(0, 10);
|
||||
|
||||
/* ─── UASection ─────────────────────────────────────────────────────────────── */
|
||||
function UASection({ items }: { items: AttributeValue[] }) {
|
||||
return (
|
||||
<div className="bg-background-secondary rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-text-primary">
|
||||
{title} ({items.length})
|
||||
</h3>
|
||||
{showViewAll && items.length > 0 && (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value && onViewAll) {
|
||||
const item = items.find(i => i.value === e.target.value);
|
||||
if (item) {
|
||||
onViewAll(item.value, item.count);
|
||||
}
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
className="bg-background-card border border-background-card rounded-lg px-3 py-1 text-sm text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
>
|
||||
<option value="">{viewAllLabel}...</option>
|
||||
{displayItems.map((item, idx) => (
|
||||
<option key={idx} value={item.value}>
|
||||
{getValue(item).substring(0, 40)}{getValue(item).length > 40 ? '...' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-background-secondary rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span>🖥️</span> User-Agents
|
||||
<span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{displayItems.map((item, index) => (
|
||||
<AttributeRow
|
||||
key={index}
|
||||
value={item}
|
||||
getValue={getValue}
|
||||
getLink={getLink}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length > 10 && (
|
||||
<p className="text-text-secondary text-sm mt-4 text-center">
|
||||
... et {items.length - 10} autres (top 10 affiché)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant AttributeRow
|
||||
function AttributeRow({
|
||||
value,
|
||||
getValue,
|
||||
getLink,
|
||||
}: {
|
||||
value: AttributeValue;
|
||||
getValue: (item: AttributeValue) => string;
|
||||
getLink: (item: AttributeValue) => string;
|
||||
}) {
|
||||
const percentage = value.percentage || 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
to={getLink(value)}
|
||||
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1"
|
||||
>
|
||||
{getValue(value)}
|
||||
</Link>
|
||||
<div className="text-right">
|
||||
<div className="text-text-primary font-medium">{value.count}</div>
|
||||
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-background-card rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
{items.map((item, i) => {
|
||||
const pct = item.percentage || 0;
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<span className="flex-1 text-xs font-mono text-text-primary break-all leading-relaxed">{item.value}</span>
|
||||
<span className="shrink-0 text-xs text-text-secondary">{item.count} ({pct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-background-card rounded-full h-1.5">
|
||||
<div className="h-1.5 rounded-full bg-threat-medium" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper pour la couleur de la barre
|
||||
function getPercentageColor(percentage: number): string {
|
||||
if (percentage >= 50) return 'bg-threat-critical';
|
||||
if (percentage >= 25) return 'bg-threat-high';
|
||||
if (percentage >= 10) return 'bg-threat-medium';
|
||||
return 'bg-threat-low';
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ interface DataTableProps<T> {
|
||||
defaultSortKey?: string;
|
||||
defaultSortDir?: SortDir;
|
||||
onRowClick?: (row: T) => void;
|
||||
onSort?: (key: string, dir: SortDir) => void;
|
||||
rowKey: keyof T | ((row: T) => string);
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
@ -31,6 +32,7 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
defaultSortKey,
|
||||
defaultSortDir = 'desc',
|
||||
onRowClick,
|
||||
onSort,
|
||||
rowKey,
|
||||
emptyMessage = 'Aucune donnée disponible',
|
||||
loading = false,
|
||||
@ -82,7 +84,15 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
alignClass(col.align),
|
||||
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined}
|
||||
onClick={isSortable ? () => {
|
||||
handleSort(col.key as keyof T);
|
||||
if (onSort) {
|
||||
const newDir = String(sortKey) === col.key
|
||||
? (sortDir === 'asc' ? 'desc' : 'asc')
|
||||
: 'desc';
|
||||
onSort(col.key, newDir);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
|
||||
@ -11,6 +11,7 @@ interface UseDetectionsParams {
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
group_by_ip?: boolean;
|
||||
}
|
||||
|
||||
export function useDetections(params: UseDetectionsParams = {}) {
|
||||
@ -42,6 +43,7 @@ export function useDetections(params: UseDetectionsParams = {}) {
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.sort_order,
|
||||
params.group_by_ip,
|
||||
]);
|
||||
|
||||
return { data, loading, error };
|
||||
|
||||
Reference in New Issue
Block a user