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>
569 lines
24 KiB
TypeScript
569 lines
24 KiB
TypeScript
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;
|
||
known_bots_count: number;
|
||
anomalies_count: 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 */}
|
||
<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>
|
||
|
||
{/* 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 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>
|
||
|
||
{/* 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 && (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
// ─── 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>
|
||
);
|
||
}
|