refactor: UI improvements and code cleanup

Frontend:
- DetectionsList: Simplify columns, improve truncation and display for IPs, hosts, bot info
- IncidentsView: Replace metric cards with compact stat cards (unique IPs, known bots, ML anomalies, threat levels)
- InvestigationView: Add section navigation anchors, reorganize layout with proper IDs
- ThreatIntelView: Add navigation links to investigation pages, add comment column, improve table layout

Backend:
- Various route and model adjustments
- Configuration updates

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
SOC Analyst
2026-03-20 09:56:49 +01:00
parent dbb9bb3f94
commit bd33fbad01
17 changed files with 444 additions and 510 deletions

View File

@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
}
return (
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
<main className="flex-1 px-4 py-3 mt-14 overflow-auto">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/incidents" element={<IncidentsView />} />

View File

@ -114,7 +114,7 @@ export function CampaignsView() {
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
const [minIPs, setMinIPs] = useState(3);
const [minIPs, setMinIPs] = useState(1);
const [severityFilter, setSeverityFilter] = useState<string>('all');
// Fetch clusters on mount

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable';
@ -55,6 +55,14 @@ export function DetectionsList() {
const scoreType = searchParams.get('score_type') || undefined;
const [groupByIP, setGroupByIP] = useState(true);
const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]);
useEffect(() => {
fetch('/api/metrics/threats')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.items) setThreatDist(d.items); })
.catch(() => null);
}, []);
const { data, loading, error } = useDetections({
page,
@ -157,60 +165,36 @@ export function DetectionsList() {
key: 'src_ip',
label: col.label,
sortable: true,
render: (_, row) => (
<div>
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
<div className="mt-1 space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
</div>
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{ja4}
</div>
))}
{row.unique_ja4s.length > 3 && (
<div className="font-mono text-xs text-text-disabled">
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
</div>
)}
width: 'w-[220px] min-w-[180px]',
render: (_, row) => {
const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
return (
<div>
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
{ja4Label}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{row.ja4 || '-'}
</div>
)}
</div>
),
</div>
);
},
};
case 'host':
return {
key: 'host',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary font-medium">
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
</div>
{row.unique_hosts.slice(0, 3).map((host, idx) => (
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{host}
</div>
))}
{row.unique_hosts.length > 3 && (
<div className="text-xs text-text-disabled">
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
</div>
)}
width: 'w-[180px] min-w-[140px]',
render: (_, row) => {
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
const primary = hosts[0] ?? '—';
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
return (
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
{primary}<span className="text-text-disabled text-xs">{extra}</span>
</div>
) : (
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
{row.host || '-'}
</div>
),
);
},
};
case 'client_headers':
return {
@ -257,24 +241,18 @@ export function DetectionsList() {
</span>
),
sortable: false,
width: 'w-[140px]',
render: (_, row) => {
const name = row.anubis_bot_name;
const action = row.anubis_bot_action;
const category = row.anubis_bot_category;
if (!name) return <span className="text-text-disabled text-xs"></span>;
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';
action === 'ALLOW' ? 'text-green-400' :
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
return (
<div className="space-y-0.5">
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
<span className="font-medium">{name}</span>
</div>
<div className="flex gap-1 flex-wrap">
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
</div>
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
</div>
);
},
@ -330,13 +308,11 @@ export function DetectionsList() {
key: 'asn_org',
label: col.label,
sortable: true,
width: 'w-[150px]',
render: (_, row) => (
<div>
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
{row.asn_number && (
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
)}
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || ''}</span>
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
</div>
),
};
@ -358,34 +334,18 @@ export function DetectionsList() {
key: 'detected_at',
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.first_seen ? (() => {
const first = new Date(row.first_seen!);
width: 'w-[110px]',
render: (_, row) => {
if (groupByIP && 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 ? (
<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-sm text-text-primary">
{formatDateOnly(row.detected_at)}
</div>
<div className="text-xs text-text-secondary">
{formatTimeOnly(row.detected_at)}
</div>
</>
),
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
}
return (
<div className="text-xs text-text-secondary whitespace-nowrap">
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
</div>
);
},
};
default:
return { key: col.key, label: col.label, sortable: col.sortable };
@ -393,118 +353,143 @@ export function DetectionsList() {
});
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>{data.items.length}</span>
<span></span>
<span>{data.total} détections</span>
</div>
<div className="space-y-2 animate-fade-in">
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
{/* Titre + compteur */}
<div className="flex items-center gap-2 shrink-0">
<span className="font-semibold text-text-primary">Détections</span>
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
{data.total.toLocaleString()}
</span>
</div>
<div className="flex gap-2">
{/* Toggle Grouper par IP */}
<div className="w-px h-5 bg-background-card shrink-0" />
{/* Pills distribution */}
{threatDist.map(({ threat_level, count, percentage }) => {
const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' :
threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' :
threat_level === 'HIGH' ? '⚠️ HIGH' :
threat_level === 'MEDIUM' ? '📊 MED' :
threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level;
const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' :
threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' :
threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' :
threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' :
threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' :
'bg-background-card text-text-secondary border-background-card';
const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null;
const active = filterVal && scoreType === filterVal;
return (
<button
key={threat_level}
onClick={() => {
if (filterVal) handleFilterChange('score_type', scoreType === filterVal ? '' : filterVal);
else handleFilterChange('threat_level', threat_level);
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium transition-colors ${style} ${active ? 'ring-1 ring-offset-1 ring-current' : ''}`}
>
{label} <span className="font-bold">{count.toLocaleString()}</span>
<span className="opacity-50">{percentage.toFixed(0)}%</span>
</button>
);
})}
<div className="w-px h-5 bg-background-card shrink-0" />
{/* Filtres select */}
<select
value={modelName || ''}
onChange={(e) => handleFilterChange('model_name', e.target.value)}
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
>
<option value="">Tous modèles</option>
<option value="Complet">Complet</option>
<option value="Applicatif">Applicatif</option>
</select>
<select
value={scoreType || ''}
onChange={(e) => handleFilterChange('score_type', e.target.value)}
className="bg-background-card border border-background-card rounded px-2 py-1 text-text-primary text-xs focus:outline-none focus:border-accent-primary"
>
<option value="">Tous scores</option>
<option value="BOT">🟢 BOT</option>
<option value="REGLE">🔴 RÈGLE</option>
<option value="BOT_REGLE">BOT+RÈGLE</option>
<option value="SCORE">Score num.</option>
</select>
{(modelName || scoreType || search || sortField !== 'detected_at') && (
<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'
}`}
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
onClick={() => setSearchParams({})}
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
>
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
Effacer
</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>
{/* Spacer */}
<div className="flex-1" />
{/* 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>
{/* Toggle grouper */}
<button
onClick={() => setGroupByIP(!groupByIP)}
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
}`}
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
>
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
</button>
{/* Filtres */}
<div className="bg-background-secondary rounded-lg p-4">
<div className="flex flex-wrap gap-3 items-center">
<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"
{/* Sélecteur colonnes */}
<div className="relative shrink-0">
<button
onClick={() => setShowColumnSelector(!showColumnSelector)}
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
>
<option value="">Tous modèles</option>
<option value="Complet">Complet</option>
<option value="Applicatif">Applicatif</option>
</select>
<select
value={scoreType || ''}
onChange={(e) => handleFilterChange('score_type', 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 types de score</option>
<option value="BOT">🟢 BOT seulement</option>
<option value="REGLE">🔴 RÈGLE seulement</option>
<option value="BOT_REGLE">BOT + RÈGLE</option>
<option value="SCORE">Score numérique seulement</option>
</select>
{(modelName || scoreType || search || sortField !== 'detected_at') && (
<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>
Colonnes
</button>
{showColumnSelector && (
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
{columns.map(col => (
<label key={col.key} className="flex items-center gap-2 px-2 py-1 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-xs text-text-primary">{col.label}</span>
</label>
))}
</div>
)}
</div>
{/* Recherche */}
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="IP, JA4, Host..."
className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40"
/>
<button
type="submit"
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
>
🔍
</button>
</form>
</div>
{/* Tableau */}
{/* ── Tableau ── */}
<div className="bg-background-secondary rounded-lg overflow-x-auto">
<DataTable<DetectionRow>
data={processedData.items as DetectionRow[]}
@ -519,24 +504,24 @@ export function DetectionsList() {
/>
</div>
{/* Pagination */}
{/* ── 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)
<div className="flex items-center justify-between text-sm">
<p className="text-text-secondary text-xs">
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
</p>
<div className="flex gap-2">
<div className="flex gap-1">
<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"
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded 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"
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
>
Suivant
</button>
@ -611,27 +596,3 @@ 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 (
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
{display}
</span>
);
}

View File

@ -30,6 +30,8 @@ interface MetricsSummary {
medium_count: number;
low_count: number;
unique_ips: number;
known_bots_count: number;
anomalies_count: number;
}
interface BaselineMetric {
@ -135,81 +137,114 @@ export function IncidentsView() {
return (
<div className="space-y-6 animate-fade-in">
{/* Header with Quick Search */}
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
<p className="text-text-secondary text-sm mt-1">
Surveillance en temps réel - 24 dernières heures
</p>
</div>
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
</div>
</div>
{/* Baseline comparison */}
{baseline && (
<div className="grid grid-cols-3 gap-3">
{([
{ key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat },
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat },
{ key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴', tip: TIPS.risk_critical },
] as { key: keyof BaselineData; label: string; icon: string; tip: string }[]).map(({ key, label, icon, tip }) => {
const m = baseline[key];
{/* Stats unifiées — 6 cartes compact */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{/* Total détections avec comparaison hier */}
<div
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
onClick={() => navigate('/detections')}
>
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
</div>
<div className="text-xl font-bold text-text-primary">
{(metrics?.total_detections ?? 0).toLocaleString()}
</div>
{baseline && (() => {
const m = baseline.total_detections;
const up = m.pct_change > 0;
const neutral = m.pct_change === 0;
return (
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
<span className="text-xl">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div>
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString(navigator.language || undefined)}</div>
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString(navigator.language || undefined)}</div>
</div>
<div className={`text-sm font-bold px-2 py-1 rounded ${
neutral ? 'text-text-disabled' :
up ? 'text-threat-critical bg-threat-critical/10' :
'text-threat-low bg-threat-low/10'
}`}>
{neutral ? '=' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`}
</div>
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
</div>
);
})}
})()}
</div>
)}
{/* Critical Metrics */}
{metrics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard
title="CRITICAL"
value={metrics.critical_count.toLocaleString()}
subtitle={metrics.critical_count > 0 ? 'Requiert action immédiate' : 'Aucune'}
color="bg-red-500/20"
trend={metrics.critical_count > 10 ? 'up' : 'stable'}
/>
<MetricCard
title="HIGH"
value={metrics.high_count.toLocaleString()}
subtitle="Menaces élevées"
color="bg-orange-500/20"
trend="stable"
/>
<MetricCard
title="MEDIUM"
value={metrics.medium_count.toLocaleString()}
subtitle="Menaces moyennes"
color="bg-yellow-500/20"
trend="stable"
/>
<MetricCard
title="TOTAL"
value={metrics.total_detections.toLocaleString()}
subtitle={`${metrics.unique_ips.toLocaleString()} IPs uniques`}
color="bg-blue-500/20"
trend="stable"
/>
{/* IPs uniques */}
<div
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
onClick={() => navigate('/detections')}
>
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
🖥 IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
</div>
<div className="text-xl font-bold text-text-primary">
{(metrics?.unique_ips ?? 0).toLocaleString()}
</div>
{baseline && (() => {
const m = baseline.unique_ips;
const up = m.pct_change > 0;
const neutral = m.pct_change === 0;
return (
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
</div>
);
})()}
</div>
)}
{/* BOT connus */}
<div
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
onClick={() => navigate('/detections?score_type=BOT')}
>
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
<div className="text-xl font-bold text-green-400">
{(metrics?.known_bots_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-green-400/60">
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
</div>
</div>
{/* Anomalies ML */}
<div
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
onClick={() => navigate('/detections?score_type=SCORE')}
>
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
<div className="text-xl font-bold text-purple-400">
{(metrics?.anomalies_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-purple-400/60">
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
</div>
</div>
{/* HIGH */}
<div
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
onClick={() => navigate('/detections?threat_level=HIGH')}
>
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide"> HIGH</div>
<div className="text-xl font-bold text-orange-400">
{(metrics?.high_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
</div>
{/* MEDIUM */}
<div
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
onClick={() => navigate('/detections?threat_level=MEDIUM')}
>
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
<div className="text-xl font-bold text-yellow-400">
{(metrics?.medium_count ?? 0).toLocaleString()}
</div>
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
</div>
</div>
{/* Bulk Actions */}
{selectedClusters.size > 0 && (
@ -478,34 +513,6 @@ export function IncidentsView() {
);
}
// Metric Card Component
function MetricCard({
title,
value,
subtitle,
color,
trend
}: {
title: string;
value: string | number;
subtitle: string;
color: string;
trend: 'up' | 'down' | 'stable';
}) {
return (
<div className={`${color} rounded-lg p-6`}>
<div className="flex items-center justify-between mb-2">
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
<span className="text-lg">
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
</span>
</div>
<p className="text-3xl font-bold text-text-primary">{value}</p>
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
</div>
);
}
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
interface HeatmapHour {

View File

@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
}
function IPActivitySummary({ ip }: { ip: string }) {
const [data, setData] = useState<IPSummary | null>(null);
const [open, setOpen] = useState(false); // fermée par défaut
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(true);
const [data, setData] = useState<IPSummary | null>(null);
useEffect(() => {
setLoading(true);
@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent
}
function DetectionAttributesSection({ ip }: { ip: string }) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true); // ouvert par défaut
const { data, loading } = useVariability('ip', ip);
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
@ -448,38 +448,59 @@ export function InvestigationView() {
</div>
</div>
{/* Navigation ancres inter-sections */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
<span className="text-text-disabled shrink-0">Aller à :</span>
{[
{ id: 'section-attributs', label: '📡 Attributs' },
{ id: 'section-synthese', label: '🔎 Synthèse' },
{ id: 'section-reputation', label: '🌍 Réputation' },
{ id: 'section-correlations', label: '🕸 Corrélations' },
{ id: 'section-geo', label: '🌐 Géo / JA4' },
{ id: 'section-classification', label: '🏷 Classification' },
].map(({ id, label }) => (
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
{label}
</a>
))}
</div>
{/* Attributs détectés (ex-DetailsView) */}
<DetectionAttributesSection ip={ip} />
<div id="section-attributs">
<DetectionAttributesSection ip={ip} />
</div>
{/* Ligne 0 : Synthèse multi-sources */}
<IPActivitySummary ip={ip} />
{/* Synthèse multi-sources */}
<div id="section-synthese">
<IPActivitySummary ip={ip} />
</div>
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
<div className="grid grid-cols-3 gap-6 items-start">
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
<div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6 h-full">
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
<ReputationPanel ip={ip} />
</div>
<div className="col-span-2 bg-background-secondary rounded-lg p-6">
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
<CorrelationGraph ip={ip} height="600px" />
</div>
</div>
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */}
<div className="grid grid-cols-3 gap-6 items-start">
{/* Subnet / Country / JA4 */}
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
<SubnetAnalysis ip={ip} />
<CountryAnalysis ip={ip} />
<JA4Analysis ip={ip} />
</div>
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */}
<div className="grid grid-cols-2 gap-6 items-start">
{/* User-Agents (1/2) + Classification (1/2) */}
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
<UserAgentAnalysis ip={ip} />
<CorrelationSummary ip={ip} onClassify={handleClassify} />
</div>
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */}
{/* Cohérence JA4/UA (spoofing) */}
<div className="grid grid-cols-3 gap-6 items-start">
<FingerprintCoherenceWidget ip={ip} />
<div className="col-span-2 bg-background-secondary rounded-lg p-5">

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
import { formatDateShort } from '../utils/dateUtils';
@ -22,6 +23,7 @@ interface ClassificationStats {
}
export function ThreatIntelView() {
const navigate = useNavigate();
const [classifications, setClassifications] = useState<Classification[]>([]);
const [stats, setStats] = useState<ClassificationStats[]>([]);
const [loading, setLoading] = useState(true);
@ -232,20 +234,28 @@ export function ThreatIntelView() {
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Commentaire</th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
</tr>
</thead>
<tbody className="divide-y divide-background-card">
{filteredClassifications.slice(0, 50).map((classification, idx) => (
{filteredClassifications.slice(0, 50).map((classification, idx) => {
const entity = classification.ip || classification.ja4;
const isIP = !!classification.ip;
return (
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
<td className="px-4 py-3 text-sm text-text-secondary">
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
{formatDateShort(classification.created_at)}
</td>
<td className="px-4 py-3">
<div className="font-mono text-sm text-text-primary">
{classification.ip || classification.ja4}
</div>
<button
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
title={entity}
>
{entity}
</button>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
@ -254,17 +264,22 @@ export function ThreatIntelView() {
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{classification.tags.slice(0, 5).map((tag, tagIdx) => (
{classification.tags.slice(0, 4).map((tag, tagIdx) => (
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
))}
{classification.tags.length > 5 && (
<span className="text-xs text-text-secondary">+{classification.tags.length - 5}</span>
{classification.tags.length > 4 && (
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
<span className="truncate block" title={(classification as any).comment || ''}>
{(classification as any).comment || <span className="text-text-disabled"></span>}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex-1 bg-background-secondary rounded-full h-2">
<div className="flex-1 bg-background-secondary rounded-full h-2 min-w-[60px]">
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
</div>
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
@ -272,7 +287,8 @@ export function ThreatIntelView() {
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>