Files
dashboard/frontend/src/components/IncidentsView.tsx
SOC Analyst 9ee3d01059 feat(dashboard): thème auto, config centralisée, dates UTC→TZ navigateur, tooltip Anubis
- ThemeContext: thème par défaut 'auto' (suit prefers-color-scheme du navigateur)
- config.ts: fichier de configuration centrale (API_BASE_URL, DEFAULT_THEME,
  PAGE_SIZES, seuils, description du mécanisme d'identification Anubis)
- dateUtils.ts: utilitaire partagé formatDate/formatDateShort/formatDateOnly/
  formatTimeOnly/formatNumber — convertit les dates UTC ClickHouse dans le
  fuseau horaire et la locale du navigateur (plus de 'fr-FR' hardcodé)
- tooltips.ts: ajout TIPS.anubis_identification — explique que les bots sont
  identifiés par UA (regex), IP/CIDR, ASN, pays via les règles Anubis
- DetectionsList: colonne Anubis avec icône ⓘ affichant le tooltip explicatif
- DataTable: Column.label étendu à React.ReactNode (pour JSX dans les headers)
- 24 composants mis à jour: fr-FR remplacé par locale navigateur partout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 18:01:11 +01:00

562 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoTip } from './ui/Tooltip';
import { TIPS } from './ui/tooltips';
interface IncidentCluster {
id: string;
score: number;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
total_detections: number;
unique_ips: number;
subnet?: string;
sample_ip?: string;
ja4?: string;
primary_ua?: string;
primary_target?: string;
countries: { code: string; percentage: number }[];
asn?: string;
first_seen: string;
last_seen: string;
trend: 'up' | 'down' | 'stable';
trend_percentage: number;
hits_per_second?: number;
}
interface MetricsSummary {
total_detections: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
unique_ips: number;
}
interface BaselineMetric {
today: number;
yesterday: number;
pct_change: number;
}
interface BaselineData {
total_detections: BaselineMetric;
unique_ips: BaselineMetric;
critical_alerts: BaselineMetric;
}
export function IncidentsView() {
const navigate = useNavigate();
const [clusters, setClusters] = useState<IncidentCluster[]>([]);
const [metrics, setMetrics] = useState<MetricsSummary | null>(null);
const [baseline, setBaseline] = useState<BaselineData | null>(null);
const [loading, setLoading] = useState(true);
const [selectedClusters, setSelectedClusters] = useState<Set<string>>(new Set());
useEffect(() => {
const fetchIncidents = async () => {
setLoading(true);
try {
const metricsResponse = await fetch('/api/metrics');
if (metricsResponse.ok) {
const metricsData = await metricsResponse.json();
setMetrics(metricsData.summary);
}
const baselineResponse = await fetch('/api/metrics/baseline');
if (baselineResponse.ok) {
setBaseline(await baselineResponse.json());
}
const clustersResponse = await fetch('/api/incidents/clusters');
if (clustersResponse.ok) {
const clustersData = await clustersResponse.json();
setClusters(clustersData.items || []);
}
} catch (error) {
console.error('Error fetching incidents:', error);
} finally {
setLoading(false);
}
};
fetchIncidents();
const interval = setInterval(fetchIncidents, 60000);
return () => clearInterval(interval);
}, []);
const toggleCluster = (id: string) => {
const newSelected = new Set(selectedClusters);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedClusters(newSelected);
};
const selectAll = () => {
if (selectedClusters.size === clusters.length) {
setSelectedClusters(new Set());
} else {
setSelectedClusters(new Set(clusters.map(c => c.id)));
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'CRITICAL': return 'border-red-500 bg-red-500/10';
case 'HIGH': return 'border-orange-500 bg-orange-500/10';
case 'MEDIUM': return 'border-yellow-500 bg-yellow-500/10';
case 'LOW': return 'border-green-500 bg-green-500/10';
default: return 'border-gray-500 bg-gray-500/10';
}
};
const getSeverityBadgeColor = (severity: string) => {
switch (severity) {
case 'CRITICAL': return 'bg-red-500 text-white';
case 'HIGH': return 'bg-orange-500 text-white';
case 'MEDIUM': return 'bg-yellow-500 text-white';
case 'LOW': return 'bg-green-500 text-white';
default: return 'bg-gray-500 text-white';
}
};
const getCountryFlag = (code: string) => {
return code.toUpperCase().replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-text-secondary">Chargement...</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header with Quick Search */}
<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>
</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];
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>
);
})}
</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"
/>
</div>
)}
{/* Bulk Actions */}
{selectedClusters.size > 0 && (
<div className="bg-blue-500/20 border border-blue-500 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="text-text-primary">
<span className="font-bold">{selectedClusters.size}</span> incidents sélectionnés
</div>
<div className="flex gap-2">
<button
onClick={() => {
// Bulk classification
const ips = clusters
.filter(c => selectedClusters.has(c.id))
.flatMap(c => c.subnet ? [c.subnet.split('/')[0]] : []);
navigate(`/bulk-classify?ips=${encodeURIComponent(ips.join(','))}`);
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600 transition-colors"
>
Classifier en masse
</button>
<button
onClick={() => {
// Export selected
const data = clusters.filter(c => selectedClusters.has(c.id));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export_incidents_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}}
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
>
Export JSON
</button>
<button
onClick={() => setSelectedClusters(new Set())}
className="px-4 py-2 bg-background-card text-text-primary rounded-lg text-sm hover:bg-background-card/80 transition-colors"
>
Désélectionner
</button>
</div>
</div>
</div>
)}
{/* Main content: incidents list (2/3) + top threats table (1/3) */}
<div className="grid grid-cols-3 gap-6 items-start">
{/* Incidents list — 2/3 */}
<div className="col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-text-primary">
Incidents Prioritaires
</h2>
<button
onClick={selectAll}
className="text-sm text-accent-primary hover:text-accent-primary/80"
>
{selectedClusters.size === clusters.length ? 'Tout désélectionner' : 'Tout sélectionner'}
</button>
</div>
<div className="space-y-3">
{clusters.map((cluster) => (
<div
key={cluster.id}
className={`border-2 rounded-lg p-4 transition-all hover:shadow-lg ${getSeverityColor(cluster.severity)}`}
>
<div className="flex items-start gap-4">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedClusters.has(cluster.id)}
onChange={() => toggleCluster(cluster.id)}
className="mt-1 w-4 h-4 rounded bg-background-card border-background-card text-accent-primary focus:ring-accent-primary"
/>
{/* Content */}
<div className="flex-1">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<span title={cluster.severity === 'CRITICAL' ? TIPS.risk_critical : cluster.severity === 'HIGH' ? TIPS.risk_high : cluster.severity === 'MEDIUM' ? TIPS.risk_medium : TIPS.risk_low} className={`px-2 py-1 rounded text-xs font-bold ${getSeverityBadgeColor(cluster.severity)}`}>
{cluster.severity}
</span>
<span className="text-lg font-bold text-text-primary">{cluster.id}</span>
<span className="text-text-secondary">|</span>
<span className="font-mono text-sm text-text-primary">{cluster.subnet || ''}</span>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-2xl font-bold text-text-primary">{cluster.score}/100</div>
<div className="text-xs text-text-secondary flex items-center gap-1">Score de risque<InfoTip content={TIPS.risk_score_inv} /></div>
</div>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-3">
<div>
<div className="text-xs text-text-secondary mb-1">IPs</div>
<div className="text-text-primary font-bold">{cluster.unique_ips}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1">Détections</div>
<div className="text-text-primary font-bold">{cluster.total_detections}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1">Pays</div>
<div className="text-text-primary">
{cluster.countries[0] && (
<>
{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}
</>
)}
</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">ASN<InfoTip content={TIPS.asn} /></div>
<div className="text-text-primary">AS{cluster.asn || '?'}</div>
</div>
<div>
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">Tendance<InfoTip content={TIPS.tendance} /></div>
<div className={`font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'} {cluster.trend_percentage}%
</div>
</div>
</div>
{cluster.ja4 && (
<div className="mb-3 p-2 bg-background-card rounded">
<div className="text-xs text-text-secondary mb-1 flex items-center gap-1">JA4 Principal<InfoTip content={TIPS.ja4} /></div>
<div className="font-mono text-xs text-text-primary break-all">{cluster.ja4}</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
className="px-3 py-1.5 bg-accent-primary text-white rounded text-sm hover:bg-accent-primary/80 transition-colors"
>
Investiguer
</button>
<button
onClick={() => navigate(`/entities/subnet/${encodeURIComponent((cluster.subnet || '').replace('/', '_'))}`)}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Voir détails
</button>
<button
onClick={() => {
// Quick classify
navigate(`/bulk-classify?ips=${encodeURIComponent(cluster.sample_ip || cluster.subnet?.split('/')[0] || '')}`);
}}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Classifier
</button>
<button
onClick={() => {
// Export STIX
const stixData = {
type: 'bundle',
id: `bundle--${cluster.id}`,
objects: [{
type: 'indicator',
id: `indicator--${cluster.id}`,
pattern: `[ipv4-addr:value = '${cluster.subnet?.split('/')[0] || ''}'`,
pattern_type: 'stix'
}]
};
const blob = new Blob([JSON.stringify(stixData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `stix_${cluster.id}_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}}
className="px-3 py-1.5 bg-background-card text-text-primary rounded text-sm hover:bg-background-card/80 transition-colors"
>
Export STIX
</button>
</div>
</div>
</div>
</div>
))}
{clusters.length === 0 && (
<div className="bg-background-secondary rounded-lg p-12 text-center">
<h3 className="text-xl font-semibold text-text-primary mb-2">
Aucun incident actif
</h3>
<p className="text-text-secondary">
Le système ne détecte aucun incident prioritaire en ce moment.
</p>
</div>
)}
</div>
</div>{/* end col-span-2 */}
{/* Top threats sidebar — 1/3 */}
<div className="sticky top-4">
<div className="bg-background-secondary rounded-lg overflow-hidden">
<div className="p-4 border-b border-background-card">
<h3 className="text-base font-semibold text-text-primary">🔥 Top Menaces</h3>
</div>
<div className="divide-y divide-background-card">
{clusters.slice(0, 12).map((cluster, index) => (
<div
key={cluster.id}
className="px-4 py-3 flex items-center gap-3 hover:bg-background-card/50 transition-colors cursor-pointer"
onClick={() => navigate(`/investigation/${cluster.sample_ip || cluster.subnet?.split('/')[0] || ''}`)}
>
<span className="text-text-disabled text-xs w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-text-primary truncate">
{cluster.sample_ip || cluster.subnet?.split('/')[0] || 'Unknown'}
</div>
<div className="text-xs text-text-secondary flex gap-2 mt-0.5">
{cluster.countries[0] && (
<span>{getCountryFlag(cluster.countries[0].code)} {cluster.countries[0].code}</span>
)}
<span>AS{cluster.asn || '?'}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-1.5 py-0.5 rounded text-xs font-bold ${
cluster.score > 80 ? 'bg-red-500 text-white' :
cluster.score > 60 ? 'bg-orange-500 text-white' :
cluster.score > 40 ? 'bg-yellow-500 text-white' :
'bg-green-500 text-white'
}`}>
{cluster.score}
</span>
<span className={`text-xs font-bold ${
cluster.trend === 'up' ? 'text-red-500' :
cluster.trend === 'down' ? 'text-green-500' :
'text-gray-400'
}`}>
{cluster.trend === 'up' ? '↑' : cluster.trend === 'down' ? '↓' : '→'}
</span>
</div>
</div>
))}
{clusters.length === 0 && (
<div className="px-4 py-8 text-center text-text-secondary text-sm">
Aucune menace active
</div>
)}
</div>
</div>
</div>
</div>{/* end grid */}
<div className="mt-6">
<MiniHeatmap />
</div>
</div>
);
}
// 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 {
hour: number;
hits: number;
unique_ips: number;
}
function MiniHeatmap() {
const [data, setData] = useState<HeatmapHour[]>([]);
useEffect(() => {
fetch('/api/heatmap/hourly')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setData(d.hours ?? d.items ?? []); })
.catch(() => {});
}, []);
if (data.length === 0) return null;
const maxHits = Math.max(...data.map(d => d.hits), 1);
const barColor = (hits: number) => {
const pct = (hits / maxHits) * 100;
if (pct >= 75) return 'bg-red-500/70';
if (pct >= 50) return 'bg-purple-500/60';
if (pct >= 25) return 'bg-blue-500/50';
if (pct >= 5) return 'bg-blue-400/30';
return 'bg-slate-700/30';
};
return (
<div className="bg-background-secondary border border-border rounded-lg p-4">
<div className="text-sm font-semibold text-text-primary mb-3"> Activité par heure (72h)</div>
<div className="flex items-end gap-px h-16">
{data.map((d, i) => (
<div key={i} className="relative flex-1 flex flex-col items-center justify-end group">
<div
className={`w-full rounded-sm ${barColor(d.hits)}`}
style={{ height: `${Math.max((d.hits / maxHits) * 100, 2)}%` }}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 hidden group-hover:flex bg-background-card border border-border text-xs text-text-primary rounded px-2 py-1 whitespace-nowrap z-10 pointer-events-none">
{d.hits.toLocaleString()} hits {d.unique_ips} IPs
</div>
<div className="text-[9px] text-text-disabled mt-0.5 leading-none">
{[0, 6, 12, 18].includes(d.hour) ? `${d.hour}h` : '\u00a0'}
</div>
</div>
))}
</div>
</div>
);
}