feat(clustering): animation de calcul + scroll indépendant des colonnes

Layout fix (App.tsx):
- Création de MainContent avec useLocation pour layout adaptatif
- Route /clustering : main sans padding, overflow-hidden, height=calc(100vh-3.5rem)
  → ClusteringView remplit exactement la fenêtre, colonnes scrollables indépendamment
- Autres routes : comportement inchangé (px-6 py-5 overflow-auto)

Animation de calcul (ClusteringView.tsx):
- Overlay absolu z-20 sur le canvas pendant computing || loading
- 2 anneaux concentriques contra-rotatifs (accent-primary + blue-500)
- 8 noeuds orbitaux avec animate-ping colorés selon taxonomie menace
- Emoji 🔬 pulsant au centre
- Texte : 'Clustering en cours…' + détails (31 features, toutes les IPs)
- Mise à jour toutes les 3s (texte animé)

Scroll indépendant:
- Panneau gauche : style height:100% explicite
- Sidebar droite : style height:100% explicite
- Canvas : overflow-hidden ajouté
- La main a overflow-hidden → les colonnes scrollent sans bouger les voisines

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SOC Analyst
2026-03-19 11:28:26 +01:00
parent 185518fd92
commit f83263771f
2 changed files with 103 additions and 37 deletions

View File

@ -321,6 +321,57 @@ function RouteTracker() {
return null;
}
// ─── MainContent : layout adaptatif selon la route ───────────────────────────
// Les vues "canvas" ont besoin d'une hauteur fixe sans padding
// pour que leurs colonnes scroll indépendamment.
const FULLHEIGHT_ROUTES = ['/clustering'];
function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
const location = useLocation();
const isFullHeight = FULLHEIGHT_ROUTES.some(r => location.pathname.startsWith(r));
if (isFullHeight) {
return (
<main className="mt-14 overflow-hidden" style={{ height: 'calc(100vh - 3.5rem)' }}>
<Routes>
<Route path="/clustering" element={<ClusteringView />} />
</Routes>
</main>
);
}
return (
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/incidents" element={<IncidentsView />} />
<Route path="/pivot" element={<PivotView />} />
<Route path="/fingerprints" element={<FingerprintsView />} />
<Route path="/campaigns" element={<CampaignsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<Route path="/bruteforce" element={<BruteForceView />} />
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
<Route path="/headers" element={<HeaderFingerprintView />} />
<Route path="/heatmap" element={<Navigate to="/" replace />} />
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
<Route path="/ml-features" element={<MLFeaturesView />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigate" element={<DetectionsList />} />
<Route path="/investigate/:type/:value" element={<InvestigateRoute />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/entities/subnet/:subnet" element={<SubnetInvestigation />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
<Route path="/bulk-classify" element={<BulkClassificationRoute />} />
<Route path="/tools/correlation-graph/:ip" element={<CorrelationGraphRoute />} />
<Route path="/tools/timeline/:ip?" element={<TimelineRoute />} />
</Routes>
</main>
);
}
// ─── App ──────────────────────────────────────────────────────────────────────
export default function App() {
@ -362,36 +413,8 @@ export default function App() {
{/* Fixed top header */}
<TopHeader counts={counts} />
{/* Scrollable page content */}
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
<Routes>
<Route path="/" element={<IncidentsView />} />
<Route path="/incidents" element={<IncidentsView />} />
<Route path="/pivot" element={<PivotView />} />
<Route path="/fingerprints" element={<FingerprintsView />} />
<Route path="/campaigns" element={<CampaignsView />} />
<Route path="/threat-intel" element={<ThreatIntelView />} />
<Route path="/bruteforce" element={<BruteForceView />} />
<Route path="/tcp-spoofing" element={<TcpSpoofingView />} />
<Route path="/clustering" element={<ClusteringView />} />
<Route path="/headers" element={<HeaderFingerprintView />} />
<Route path="/heatmap" element={<Navigate to="/" replace />} />
<Route path="/botnets" element={<Navigate to="/campaigns" replace />} />
<Route path="/rotation" element={<Navigate to="/fingerprints" replace />} />
<Route path="/ml-features" element={<MLFeaturesView />} />
<Route path="/detections" element={<DetectionsList />} />
<Route path="/detections/:type/:value" element={<DetailsView />} />
<Route path="/investigate" element={<DetectionsList />} />
<Route path="/investigate/:type/:value" element={<InvestigateRoute />} />
<Route path="/investigation/ja4/:ja4" element={<JA4InvestigationView />} />
<Route path="/investigation/:ip" element={<InvestigationView />} />
<Route path="/entities/subnet/:subnet" element={<SubnetInvestigation />} />
<Route path="/entities/:type/:value" element={<EntityInvestigationView />} />
<Route path="/bulk-classify" element={<BulkClassificationRoute />} />
<Route path="/tools/correlation-graph/:ip" element={<CorrelationGraphRoute />} />
<Route path="/tools/timeline/:ip?" element={<TimelineRoute />} />
</Routes>
</main>
{/* Page content — full-height sans padding pour les vues canvas */}
<MainContent counts={counts} />
</div>
</div>
</BrowserRouter>

View File

@ -330,8 +330,8 @@ export default function ClusteringView() {
return (
<div className="flex h-full overflow-hidden bg-background text-text-primary">
{/* ── Panneau gauche ── */}
<div className="flex flex-col w-72 flex-shrink-0 border-r border-gray-700 overflow-y-auto p-4 gap-4 z-10">
{/* ── Panneau gauche (scroll indépendant) ── */}
<div className="flex flex-col w-72 flex-shrink-0 border-r border-gray-700 overflow-y-auto p-4 gap-4 z-10" style={{ height: '100%' }}>
<div>
<h2 className="text-lg font-bold mb-1">🔬 Clustering IPs</h2>
<p className="text-xs text-text-secondary">Rendu WebGL · K-means++ sur toutes les IPs</p>
@ -440,12 +440,55 @@ export default function ClusteringView() {
</div>
{/* ── Canvas WebGL (deck.gl) ── */}
<div className="flex-1 relative">
{!data && !loading && !computing && (
<div className="absolute inset-0 flex items-center justify-center text-text-secondary">
Cliquez sur <strong className="mx-1 text-white">Recalculer</strong> pour démarrer
<div className="flex-1 relative overflow-hidden">
{/* Animation de calcul — remplace le canvas pendant le traitement */}
{(computing || loading) && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-background">
{/* Noeuds pulsants animés */}
<div className="relative w-56 h-56 mb-2">
{/* Anneau tournant */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-32 h-32 rounded-full border-2 border-accent-primary/20 border-t-accent-primary animate-spin" style={{ animationDuration: '1.4s' }} />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-20 rounded-full border-2 border-blue-500/20 border-b-blue-500/80 animate-spin" style={{ animationDuration: '2.1s', animationDirection: 'reverse' }} />
</div>
{/* Noeuds orbitaux représentant les clusters */}
{([0,1,2,3,4,5,6,7] as const).map((i) => {
const angle = (i / 8) * 2 * Math.PI;
const r = 88;
const x = 50 + (r / 1.12) * Math.cos(angle);
const y = 50 + (r / 1.12) * Math.sin(angle);
const colors = ['#dc2626','#f97316','#eab308','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6'];
return (
<div key={i} className="absolute w-3 h-3 rounded-full animate-ping"
style={{
left: `${x}%`, top: `${y}%`, transform: 'translate(-50%,-50%)',
background: colors[i], opacity: 0.75,
animationDelay: `${i * 0.18}s`, animationDuration: '1.6s',
}} />
);
})}
{/* Centre */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-2xl select-none animate-pulse">🔬</div>
</div>
</div>
<p className="text-white font-semibold text-lg tracking-wide">Clustering en cours</p>
<p className="text-text-secondary text-sm mt-1">K-means++ · 31 features · toutes les IPs</p>
<p className="text-text-disabled text-xs mt-2 animate-pulse">Mise à jour automatique toutes les 3 secondes</p>
</div>
)}
{/* Message état vide */}
{!data && !loading && !computing && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-text-secondary">
<span className="text-4xl">🔬</span>
<span>Cliquez sur <strong className="text-white">Recalculer</strong> pour démarrer</span>
</div>
)}
<DeckGL
views={new OrthographicView({ id: 'ortho', controller: true })}
viewState={viewState}
@ -528,7 +571,7 @@ function ClusterSidebar({ node, ipDetails, ipTotal, ipPage, clusterPoints, onClo
};
return (
<div className="w-96 flex-shrink-0 border-l border-gray-700 bg-background-secondary flex flex-col overflow-hidden">
<div className="w-96 flex-shrink-0 border-l border-gray-700 bg-background-secondary flex flex-col overflow-hidden" style={{ height: '100%' }}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<div>