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

@ -2,7 +2,6 @@
Configuration du Dashboard Bot Detector Configuration du Dashboard Bot Detector
""" """
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
@ -12,20 +11,14 @@ class Settings(BaseSettings):
CLICKHOUSE_DB: str = "mabase_prod" CLICKHOUSE_DB: str = "mabase_prod"
CLICKHOUSE_USER: str = "admin" CLICKHOUSE_USER: str = "admin"
CLICKHOUSE_PASSWORD: str = "" CLICKHOUSE_PASSWORD: str = ""
# API # API
API_HOST: str = "0.0.0.0" API_HOST: str = "0.0.0.0"
API_PORT: int = 8000 API_PORT: int = 8000
# Frontend
FRONTEND_PORT: int = 3000
# CORS # CORS
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"] CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
# Rate limiting
RATE_LIMIT_PER_MINUTE: int = 100
class Config: class Config:
env_file = ".env" env_file = ".env"
case_sensitive = True case_sensitive = True

View File

@ -40,11 +40,6 @@ class ClickHouseClient:
client = self.connect() client = self.connect()
return client.query(query, params) return client.query(query, params)
def query_df(self, query: str, params: Optional[dict] = None):
"""Exécute une requête et retourne un DataFrame"""
client = self.connect()
return client.query_df(query, params)
def close(self): def close(self):
"""Ferme la connexion""" """Ferme la connexion"""
if self._client: if self._client:

View File

@ -87,20 +87,25 @@ app.include_router(search.router)
app.include_router(clustering.router) app.include_router(clustering.router)
# Chemin vers le fichier index.html du frontend (utilisé par serve_frontend et serve_spa)
_FRONTEND_INDEX = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html")
# Route pour servir le frontend # Route pour servir le frontend
@app.get("/") @app.get("/")
async def serve_frontend(): async def serve_frontend():
"""Sert l'application React""" """Sert l'application React"""
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html") if os.path.exists(_FRONTEND_INDEX):
if os.path.exists(frontend_path): return FileResponse(_FRONTEND_INDEX)
return FileResponse(frontend_path)
return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."} return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."}
# Servir les assets statiques # Servir les assets statiques
assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets") _assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
if os.path.exists(assets_path): if os.path.exists(_assets_path):
app.mount("/assets", StaticFiles(directory=assets_path), name="assets") try:
app.mount("/assets", StaticFiles(directory=_assets_path), name="assets")
except Exception as _e:
logger.warning(f"Impossible de monter les assets statiques : {_e}")
# Health check # Health check
@ -122,10 +127,9 @@ async def serve_spa(full_path: str):
# Ne pas intercepter les routes API # Ne pas intercepter les routes API
if full_path.startswith("api/"): if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="API endpoint not found") raise HTTPException(status_code=404, detail="API endpoint not found")
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html") if os.path.exists(_FRONTEND_INDEX):
if os.path.exists(frontend_path): return FileResponse(_FRONTEND_INDEX)
return FileResponse(frontend_path)
return {"message": "Dashboard API - Frontend non construit"} return {"message": "Dashboard API - Frontend non construit"}

View File

@ -1,7 +1,7 @@
""" """
Modèles de données pour l'API Modèles de données pour l'API
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@ -14,11 +14,6 @@ class ThreatLevel(str, Enum):
LOW = "LOW" LOW = "LOW"
class ModelName(str, Enum):
COMPLET = "Complet"
APPLICATIF = "Applicatif"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# MÉTRIQUES # MÉTRIQUES
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -169,33 +164,6 @@ class UserAgentsResponse(BaseModel):
showing: int showing: int
# ─────────────────────────────────────────────────────────────────────────────
# COMPARAISON
# ─────────────────────────────────────────────────────────────────────────────
class ComparisonMetric(BaseModel):
name: str
value1: Any
value2: Any
difference: str
trend: str # "better", "worse", "same"
class ComparisonEntity(BaseModel):
type: str
value: str
total_detections: int
unique_ips: int
avg_score: float
primary_threat: str
class ComparisonResponse(BaseModel):
entity1: ComparisonEntity
entity2: ComparisonEntity
metrics: List[ComparisonMetric]
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# CLASSIFICATIONS (SOC / ML) # CLASSIFICATIONS (SOC / ML)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -223,23 +191,13 @@ class ClassificationCreate(ClassificationBase):
class Classification(ClassificationBase): class Classification(ClassificationBase):
"""Classification complète avec métadonnées""" """Classification complète avec métadonnées"""
model_config = ConfigDict(from_attributes=True)
created_at: datetime created_at: datetime
features: dict = Field(default_factory=dict) features: dict = Field(default_factory=dict)
class Config:
from_attributes = True
class ClassificationStats(BaseModel):
"""Statistiques de classification"""
label: str
total: int
unique_ips: int
avg_confidence: float
class ClassificationsListResponse(BaseModel): class ClassificationsListResponse(BaseModel):
"""Réponse pour la liste des classifications"""
items: List[Classification] items: List[Classification]
total: int total: int

View File

@ -1,9 +1,9 @@
""" """
Endpoints pour l'analyse de corrélations et la classification SOC Endpoints pour l'analyse de corrélations et la classification SOC
""" """
from collections import defaultdict
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List from typing import Optional, List
from datetime import datetime
import ipaddress import ipaddress
import json import json
@ -17,6 +17,14 @@ from ..models import (
router = APIRouter(prefix="/api/analysis", tags=["analysis"]) router = APIRouter(prefix="/api/analysis", tags=["analysis"])
# Mapping code ISO → nom lisible (utilisé par analyze_ip_country et analyze_country)
_COUNTRY_NAMES: dict[str, str] = {
"CN": "China", "US": "United States", "DE": "Germany",
"FR": "France", "RU": "Russia", "GB": "United Kingdom",
"NL": "Netherlands", "IN": "India", "BR": "Brazil",
"JP": "Japan", "KR": "South Korea", "IT": "Italy",
"ES": "Spain", "CA": "Canada", "AU": "Australia"
}
# ============================================================================= # =============================================================================
# ANALYSE SUBNET / ASN # ANALYSE SUBNET / ASN
@ -122,15 +130,6 @@ async def analyze_ip_country(ip: str):
ip_country_code = ip_result.result_rows[0][0] ip_country_code = ip_result.result_rows[0][0]
asn_number = ip_result.result_rows[0][1] asn_number = ip_result.result_rows[0][1]
# Noms des pays
country_names = {
"CN": "China", "US": "United States", "DE": "Germany",
"FR": "France", "RU": "Russia", "GB": "United Kingdom",
"NL": "Netherlands", "IN": "India", "BR": "Brazil",
"JP": "Japan", "KR": "South Korea", "IT": "Italy",
"ES": "Spain", "CA": "Canada", "AU": "Australia"
}
# Répartition des autres pays du même ASN # Répartition des autres pays du même ASN
asn_countries_query = """ asn_countries_query = """
SELECT SELECT
@ -150,7 +149,7 @@ async def analyze_ip_country(ip: str):
asn_countries = [ asn_countries = [
{ {
"code": row[0], "code": row[0],
"name": country_names.get(row[0], row[0]), "name": _COUNTRY_NAMES.get(row[0], row[0]),
"count": row[1], "count": row[1],
"percentage": round((row[1] / total * 100), 2) if total > 0 else 0.0 "percentage": round((row[1] / total * 100), 2) if total > 0 else 0.0
} }
@ -160,7 +159,7 @@ async def analyze_ip_country(ip: str):
return { return {
"ip_country": { "ip_country": {
"code": ip_country_code, "code": ip_country_code,
"name": country_names.get(ip_country_code, ip_country_code) "name": _COUNTRY_NAMES.get(ip_country_code, ip_country_code)
}, },
"asn_countries": asn_countries "asn_countries": asn_countries
} }
@ -196,19 +195,10 @@ async def analyze_country(days: int = Query(1, ge=1, le=30)):
# Calculer le total pour le pourcentage # Calculer le total pour le pourcentage
total = sum(row[1] for row in top_result.result_rows) total = sum(row[1] for row in top_result.result_rows)
# Noms des pays (mapping simple)
country_names = {
"CN": "China", "US": "United States", "DE": "Germany",
"FR": "France", "RU": "Russia", "GB": "United Kingdom",
"NL": "Netherlands", "IN": "India", "BR": "Brazil",
"JP": "Japan", "KR": "South Korea", "IT": "Italy",
"ES": "Spain", "CA": "Canada", "AU": "Australia"
}
top_countries = [ top_countries = [
CountryData( CountryData(
code=row[0], code=row[0],
name=country_names.get(row[0], row[0]), name=_COUNTRY_NAMES.get(row[0], row[0]),
count=row[1], count=row[1],
percentage=round((row[1] / total * 100), 2) if total > 0 else 0.0 percentage=round((row[1] / total * 100), 2) if total > 0 else 0.0
) )
@ -311,7 +301,6 @@ async def analyze_ja4(ip: str):
subnets_result = db.query(subnets_query, {"ja4": ja4}) subnets_result = db.query(subnets_query, {"ja4": ja4})
# Grouper par subnet /24 # Grouper par subnet /24
from collections import defaultdict
subnet_counts = defaultdict(int) subnet_counts = defaultdict(int)
for row in subnets_result.result_rows: for row in subnets_result.result_rows:
ip_addr = str(row[0]) ip_addr = str(row[0])
@ -439,24 +428,24 @@ async def get_classification_recommendation(ip: str):
# Récupérer les analyses # Récupérer les analyses
try: try:
subnet_analysis = await analyze_subnet(ip) subnet_analysis = await analyze_subnet(ip)
except: except Exception:
subnet_analysis = None subnet_analysis = None
try: try:
country_analysis = await analyze_country(1) country_analysis = await analyze_country(1)
except: except Exception:
country_analysis = None country_analysis = None
try: try:
ja4_analysis = await analyze_ja4(ip) ja4_analysis = await analyze_ja4(ip)
except: except Exception:
ja4_analysis = None ja4_analysis = None
try: try:
ua_analysis = await analyze_user_agents(ip) ua_analysis = await analyze_user_agents(ip)
except: except Exception:
ua_analysis = None ua_analysis = None
# Indicateurs par défaut # Indicateurs par défaut
indicators = CorrelationIndicators( indicators = CorrelationIndicators(
subnet_ips_count=subnet_analysis.total_in_subnet if subnet_analysis else 0, subnet_ips_count=subnet_analysis.total_in_subnet if subnet_analysis else 0,

View File

@ -1,12 +1,14 @@
""" """
Routes pour l'audit et les logs d'activité Routes pour l'audit et les logs d'activité
""" """
import logging
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from typing import List, Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime
from ..database import db from ..database import db
router = APIRouter(prefix="/api/audit", tags=["audit"]) router = APIRouter(prefix="/api/audit", tags=["audit"])
logger = logging.getLogger(__name__)
@router.post("/logs") @router.post("/logs")
@ -50,8 +52,8 @@ async def create_audit_log(
try: try:
db.query(insert_query, params) db.query(insert_query, params)
except Exception as e: except Exception as e:
# Table might not exist yet, log warning # La table peut ne pas encore exister — on logue mais on ne bloque pas l'appelant
print(f"Warning: Could not insert audit log: {e}") logger.warning(f"Could not insert audit log: {e}")
return { return {
"status": "success", "status": "success",

View File

@ -6,22 +6,20 @@ Clustering d'IPs multi-métriques — WebGL / deck.gl backend.
- Calcul en background thread + cache 30 min - Calcul en background thread + cache 30 min
- Endpoints : /clusters, /status, /cluster/{id}/points - Endpoints : /clusters, /status, /cluster/{id}/points
""" """
from __future__ import annotations
import math import math
import time import time
import logging import logging
import threading import threading
from collections import Counter from collections import Counter
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any from typing import Any
import numpy as np import numpy as np
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from ..database import db from ..database import db
from ..services.clustering_engine import ( from ..services.clustering_engine import (
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES, FEATURE_NAMES,
build_feature_vector, kmeans_pp, pca_2d, compute_hulls, build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
name_cluster, risk_score_from_centroid, standardize, name_cluster, risk_score_from_centroid, standardize,
risk_to_gradient_color, risk_to_gradient_color,

View File

@ -2,9 +2,7 @@
Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param) Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param)
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Optional, List, Dict, Any from typing import Optional, List
from datetime import datetime
import json
from ..database import db from ..database import db
from ..models import ( from ..models import (
@ -16,18 +14,10 @@ from ..models import (
router = APIRouter(prefix="/api/entities", tags=["Entities"]) router = APIRouter(prefix="/api/entities", tags=["Entities"])
db = db # Ensemble des types d'entités valides
VALID_ENTITY_TYPES = frozenset({
# Mapping des types d'entités 'ip', 'ja4', 'user_agent', 'client_header', 'host', 'path', 'query_param'
ENTITY_TYPES = { })
'ip': 'ip',
'ja4': 'ja4',
'user_agent': 'user_agent',
'client_header': 'client_header',
'host': 'host',
'path': 'path',
'query_param': 'query_param'
}
def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]: def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]:

View File

@ -10,7 +10,6 @@ Objectifs:
qui usurpent des UA de navigateurs légitimes qui usurpent des UA de navigateurs légitimes
""" """
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import Optional
import re import re
from ..database import db from ..database import db

View File

@ -1,11 +1,11 @@
""" """
Routes pour la gestion des incidents clusterisés Routes pour la gestion des incidents clusterisés
""" """
import hashlib
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime
from ..database import db from ..database import db
from ..models import BaseModel
router = APIRouter(prefix="/api/incidents", tags=["incidents"]) router = APIRouter(prefix="/api/incidents", tags=["incidents"])
@ -83,7 +83,6 @@ async def get_incident_clusters(
# Collect sample IPs to fetch real UA and trend data in bulk # Collect sample IPs to fetch real UA and trend data in bulk
sample_ips = [row[10] for row in result.result_rows if row[10]] sample_ips = [row[10] for row in result.result_rows if row[10]]
subnets_list = [row[0] for row in result.result_rows]
# Fetch real primary UA per sample IP from view_dashboard_entities # Fetch real primary UA per sample IP from view_dashboard_entities
ua_by_ip: dict = {} ua_by_ip: dict = {}
@ -182,7 +181,7 @@ async def get_incident_clusters(
primary_ua = ua_by_ip.get(sample_ip, "") primary_ua = ua_by_ip.get(sample_ip, "")
clusters.append({ clusters.append({
"id": f"INC-{datetime.now().strftime('%Y%m%d')}-{len(clusters)+1:03d}", "id": f"INC-{hashlib.md5(subnet.encode()).hexdigest()[:8].upper()}",
"score": risk_score, "score": risk_score,
"severity": severity, "severity": severity,
"total_detections": row[1], "total_detections": row[1],
@ -213,22 +212,13 @@ async def get_incident_clusters(
@router.get("/{cluster_id}") @router.get("/{cluster_id}")
async def get_incident_details(cluster_id: str): async def get_incident_details(cluster_id: str):
""" """
Récupère les détails d'un incident spécifique Récupère les détails d'un incident spécifique.
Non encore implémenté — les détails par cluster seront disponibles dans une prochaine version.
""" """
try: raise HTTPException(
# Extraire le subnet du cluster_id (simplifié) status_code=501,
# Dans une implémentation réelle, on aurait une table de mapping detail="Détails par incident non encore implémentés. Utilisez /api/incidents/clusters pour la liste."
)
return {
"id": cluster_id,
"details": "Implementation en cours",
"timeline": [],
"entities": [],
"classifications": []
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.post("/{cluster_id}/classify") @router.post("/{cluster_id}/classify")
@ -239,34 +229,38 @@ async def classify_incident(
comment: str = "" comment: str = ""
): ):
""" """
Classe un incident rapidement Classe un incident rapidement.
Non encore implémenté — utilisez /api/analysis/{ip}/classify pour classifier une IP.
""" """
try: raise HTTPException(
# Implementation future - sauvegarde dans la table classifications status_code=501,
return { detail="Classification par incident non encore implémentée. Utilisez /api/analysis/{ip}/classify."
"status": "success", )
"cluster_id": cluster_id,
"label": label,
"tags": tags or [],
"comment": comment
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
@router.get("") @router.get("")
async def list_incidents( async def list_incidents(
status: str = Query("active", description="Statut des incidents"), status: str = Query("active", description="Statut des incidents"),
severity: str = Query(None, description="Filtrer par sévérité"), severity: Optional[str] = Query(None, description="Filtrer par sévérité (LOW/MEDIUM/HIGH/CRITICAL)"),
hours: int = Query(24, ge=1, le=168) hours: int = Query(24, ge=1, le=168)
): ):
""" """
Liste tous les incidents avec filtres Liste tous les incidents avec filtres.
Délègue à get_incident_clusters ; le filtre severity est appliqué post-requête.
""" """
try: try:
# Redirige vers clusters pour l'instant result = await get_incident_clusters(hours=hours, limit=100)
return await get_incident_clusters(hours=hours, limit=50) items = result["items"]
if severity:
sev_upper = severity.upper()
items = [c for c in items if c.get("severity") == sev_upper]
return {
"items": items,
"total": len(items),
"period_hours": hours,
}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}") raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")

View File

@ -1,7 +1,6 @@
""" """
Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes
""" """
import math
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from ..database import db from ..database import db
@ -110,7 +109,7 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
try: try:
sql = """ sql = """
SELECT SELECT
replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip, r.ip,
r.distinct_ja4_count, r.distinct_ja4_count,
coalesce(rec.recurrence, 0) AS recurrence, coalesce(rec.recurrence, 0) AS recurrence,
coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits, coalesce(bf.bruteforce_hits, 0) AS bruteforce_hits,
@ -119,18 +118,26 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
+ coalesce(rec.recurrence, 0) * 20 + coalesce(rec.recurrence, 0) * 20
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5) + least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
), 1) AS sophistication_score ), 1) AS sophistication_score
FROM mabase_prod.view_host_ip_ja4_rotation r FROM (
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
distinct_ja4_count
FROM mabase_prod.view_host_ip_ja4_rotation
) r
LEFT JOIN ( LEFT JOIN (
SELECT src_ip, count() AS recurrence SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
count() AS recurrence
FROM mabase_prod.ml_detected_anomalies FINAL FROM mabase_prod.ml_detected_anomalies FINAL
GROUP BY src_ip GROUP BY ip
) rec USING(src_ip) ) rec ON r.ip = rec.ip
LEFT JOIN ( LEFT JOIN (
SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip, SELECT
sum(hits) AS bruteforce_hits replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
sum(hits) AS bruteforce_hits
FROM mabase_prod.view_form_bruteforce_detected FROM mabase_prod.view_form_bruteforce_detected
GROUP BY src_ip GROUP BY ip
) bf USING(src_ip) ) bf ON r.ip = bf.ip
ORDER BY sophistication_score DESC ORDER BY sophistication_score DESC
LIMIT %(limit)s LIMIT %(limit)s
""" """

View File

@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
} }
return ( 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> <Routes>
<Route path="/" element={<IncidentsView />} /> <Route path="/" element={<IncidentsView />} />
<Route path="/incidents" 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 [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters'); const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
const [minIPs, setMinIPs] = useState(3); const [minIPs, setMinIPs] = useState(1);
const [severityFilter, setSeverityFilter] = useState<string>('all'); const [severityFilter, setSeverityFilter] = useState<string>('all');
// Fetch clusters on mount // 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 { useNavigate, useSearchParams } from 'react-router-dom';
import { useDetections } from '../hooks/useDetections'; import { useDetections } from '../hooks/useDetections';
import DataTable, { Column } from './ui/DataTable'; import DataTable, { Column } from './ui/DataTable';
@ -55,6 +55,14 @@ export function DetectionsList() {
const scoreType = searchParams.get('score_type') || undefined; const scoreType = searchParams.get('score_type') || undefined;
const [groupByIP, setGroupByIP] = useState(true); 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({ const { data, loading, error } = useDetections({
page, page,
@ -157,60 +165,36 @@ export function DetectionsList() {
key: 'src_ip', key: 'src_ip',
label: col.label, label: col.label,
sortable: true, sortable: true,
render: (_, row) => ( width: 'w-[220px] min-w-[180px]',
<div> render: (_, row) => {
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div> const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? ( const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
<div className="mt-1 space-y-1"> return (
<div className="text-xs text-text-secondary font-medium"> <div>
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''} <div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
</div> <div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => ( {ja4Label}
<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>
)}
</div> </div>
) : ( </div>
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal"> );
{row.ja4 || '-'} },
</div>
)}
</div>
),
}; };
case 'host': case 'host':
return { return {
key: 'host', key: 'host',
label: col.label, label: col.label,
sortable: true, sortable: true,
render: (_, row) => width: 'w-[180px] min-w-[140px]',
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? ( render: (_, row) => {
<div className="space-y-1"> const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
<div className="text-xs text-text-secondary font-medium"> const primary = hosts[0] ?? '—';
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''} const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
</div> return (
{row.unique_hosts.slice(0, 3).map((host, idx) => ( <div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md"> {primary}<span className="text-text-disabled text-xs">{extra}</span>
{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>
)}
</div> </div>
) : ( );
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md"> },
{row.host || '-'}
</div>
),
}; };
case 'client_headers': case 'client_headers':
return { return {
@ -257,24 +241,18 @@ export function DetectionsList() {
</span> </span>
), ),
sortable: false, sortable: false,
width: 'w-[140px]',
render: (_, row) => { render: (_, row) => {
const name = row.anubis_bot_name; const name = row.anubis_bot_name;
const action = row.anubis_bot_action; const action = row.anubis_bot_action;
const category = row.anubis_bot_category;
if (!name) return <span className="text-text-disabled text-xs"></span>; if (!name) return <span className="text-text-disabled text-xs"></span>;
const actionColor = const actionColor =
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' : action === 'ALLOW' ? 'text-green-400' :
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' : action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
return ( return (
<div className="space-y-0.5"> <div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}> <span className={`text-xs font-medium ${actionColor}`}>{name}</span>
<span className="font-medium">{name}</span> {action && <span className="text-[10px] text-text-disabled ml-1">· {action}</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> </div>
); );
}, },
@ -330,13 +308,11 @@ export function DetectionsList() {
key: 'asn_org', key: 'asn_org',
label: col.label, label: col.label,
sortable: true, sortable: true,
width: 'w-[150px]',
render: (_, row) => ( render: (_, row) => (
<div> <div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div> <span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || ''}</span>
{row.asn_number && ( {row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
)}
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
</div> </div>
), ),
}; };
@ -358,34 +334,18 @@ export function DetectionsList() {
key: 'detected_at', key: 'detected_at',
label: col.label, label: col.label,
sortable: true, sortable: true,
render: (_, row) => width: 'w-[110px]',
groupByIP && row.first_seen ? (() => { render: (_, row) => {
const first = new Date(row.first_seen!); if (groupByIP && row.first_seen) {
const last = new Date(row.last_seen!); const last = new Date(row.last_seen!);
const sameTime = first.getTime() === last.getTime(); return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
const fmt = (d: Date) => formatDate(d.toISOString()); }
return sameTime ? ( return (
<div className="text-xs text-text-secondary">{fmt(last)}</div> <div className="text-xs text-text-secondary whitespace-nowrap">
) : ( {formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
<div className="space-y-1"> </div>
<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>
</>
),
}; };
default: default:
return { key: col.key, label: col.label, sortable: col.sortable }; return { key: col.key, label: col.label, sortable: col.sortable };
@ -393,118 +353,143 @@ export function DetectionsList() {
}); });
return ( return (
<div className="space-y-4 animate-fade-in"> <div className="space-y-2 animate-fade-in">
{/* En-tête */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> {/* ── Barre unique : titre + pills + filtres + recherche ── */}
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
<div className="flex items-center gap-2 text-sm text-text-secondary"> {/* Titre + compteur */}
<span>{data.items.length}</span> <div className="flex items-center gap-2 shrink-0">
<span></span> <span className="font-semibold text-text-primary">Détections</span>
<span>{data.total} détections</span> <span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
</div> {data.total.toLocaleString()}
</span>
</div> </div>
<div className="flex gap-2"> <div className="w-px h-5 bg-background-card shrink-0" />
{/* Toggle Grouper par IP */}
{/* 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 <button
onClick={() => setGroupByIP(!groupByIP)} onClick={() => setSearchParams({})}
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${ 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
? '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 ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'} Effacer
</button> </button>
)}
{/* Sélecteur de colonnes */} {/* Spacer */}
<div className="relative"> <div className="flex-1" />
<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>
{/* Recherche */} {/* Toggle grouper */}
<form onSubmit={handleSearch} className="flex gap-2"> <button
<input onClick={() => setGroupByIP(!groupByIP)}
type="text" className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
value={searchInput} groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
onChange={(e) => setSearchInput(e.target.value)} }`}
placeholder="Rechercher IP, JA4, Host..." title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
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" >
/> {groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
<button </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>
{/* Filtres */} {/* Sélecteur colonnes */}
<div className="bg-background-secondary rounded-lg p-4"> <div className="relative shrink-0">
<div className="flex flex-wrap gap-3 items-center"> <button
<select onClick={() => setShowColumnSelector(!showColumnSelector)}
value={modelName || ''} 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"
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"
> >
<option value="">Tous modèles</option> Colonnes
<option value="Complet">Complet</option> </button>
<option value="Applicatif">Applicatif</option> {showColumnSelector && (
</select> <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 => (
<select <label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
value={scoreType || ''} <input
onChange={(e) => handleFilterChange('score_type', e.target.value)} type="checkbox"
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary" checked={col.visible}
> onChange={() => toggleColumn(col.key)}
<option value="">Tous types de score</option> className="rounded bg-background-card border-background-card text-accent-primary"
<option value="BOT">🟢 BOT seulement</option> />
<option value="REGLE">🔴 RÈGLE seulement</option> <span className="text-xs text-text-primary">{col.label}</span>
<option value="BOT_REGLE">BOT + RÈGLE</option> </label>
<option value="SCORE">Score numérique seulement</option> ))}
</select> </div>
{(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>
)} )}
</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> </div>
{/* Tableau */} {/* ── Tableau ── */}
<div className="bg-background-secondary rounded-lg overflow-x-auto"> <div className="bg-background-secondary rounded-lg overflow-x-auto">
<DataTable<DetectionRow> <DataTable<DetectionRow>
data={processedData.items as DetectionRow[]} data={processedData.items as DetectionRow[]}
@ -519,24 +504,24 @@ export function DetectionsList() {
/> />
</div> </div>
{/* Pagination */} {/* ── Pagination ── */}
{data.total_pages > 1 && ( {data.total_pages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between text-sm">
<p className="text-text-secondary text-sm"> <p className="text-text-secondary text-xs">
Page {data.page} sur {data.total_pages} ({data.total} détections) Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
</p> </p>
<div className="flex gap-2"> <div className="flex gap-1">
<button <button
onClick={() => handlePageChange(data.page - 1)} onClick={() => handlePageChange(data.page - 1)}
disabled={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 Précédent
</button> </button>
<button <button
onClick={() => handlePageChange(data.page + 1)} onClick={() => handlePageChange(data.page + 1)}
disabled={data.page === data.total_pages} 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 Suivant
</button> </button>
@ -611,27 +596,3 @@ function getFlag(countryCode: string): string {
const code = countryCode.toUpperCase(); const code = countryCode.toUpperCase();
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397)); 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; medium_count: number;
low_count: number; low_count: number;
unique_ips: number; unique_ips: number;
known_bots_count: number;
anomalies_count: number;
} }
interface BaselineMetric { interface BaselineMetric {
@ -135,81 +137,114 @@ export function IncidentsView() {
return ( return (
<div className="space-y-6 animate-fade-in"> <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 className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1> <h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
<p className="text-text-secondary text-sm mt-1"> <p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
Surveillance en temps réel - 24 dernières heures </div>
</p>
</div>
</div> </div>
{/* Baseline comparison */} {/* Stats unifiées — 6 cartes compact */}
{baseline && ( <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div className="grid grid-cols-3 gap-3"> {/* Total détections avec comparaison hier */}
{([ <div
{ key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat }, 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"
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat }, onClick={() => navigate('/detections')}
{ 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 }) => { <div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
const m = baseline[key]; 📊 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 up = m.pct_change > 0;
const neutral = m.pct_change === 0; const neutral = m.pct_change === 0;
return ( return (
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3"> <div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
<span className="text-xl">{icon}</span> {neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
<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>
); );
})} })()}
</div> </div>
)}
{/* Critical Metrics */} {/* IPs uniques */}
{metrics && ( <div
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> 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"
<MetricCard onClick={() => navigate('/detections')}
title="CRITICAL" >
value={metrics.critical_count.toLocaleString()} <div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
subtitle={metrics.critical_count > 0 ? 'Requiert action immédiate' : 'Aucune'} 🖥 IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
color="bg-red-500/20" </div>
trend={metrics.critical_count > 10 ? 'up' : 'stable'} <div className="text-xl font-bold text-text-primary">
/> {(metrics?.unique_ips ?? 0).toLocaleString()}
<MetricCard </div>
title="HIGH" {baseline && (() => {
value={metrics.high_count.toLocaleString()} const m = baseline.unique_ips;
subtitle="Menaces élevées" const up = m.pct_change > 0;
color="bg-orange-500/20" const neutral = m.pct_change === 0;
trend="stable" return (
/> <div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
<MetricCard {neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `${m.pct_change}%`} vs hier
title="MEDIUM" </div>
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> </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 */} {/* Bulk Actions */}
{selectedClusters.size > 0 && ( {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 ───────────────────────────────────────────────────────────── // ─── Mini Heatmap ─────────────────────────────────────────────────────────────
interface HeatmapHour { interface HeatmapHour {

View File

@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
} }
function IPActivitySummary({ ip }: { ip: string }) { 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 [loading, setLoading] = useState(true);
const [open, setOpen] = useState(true); const [data, setData] = useState<IPSummary | null>(null);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent
} }
function DetectionAttributesSection({ ip }: { ip: string }) { 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 { data, loading } = useVariability('ip', ip);
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null; const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
@ -448,38 +448,59 @@ export function InvestigationView() {
</div> </div>
</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) */} {/* Attributs détectés (ex-DetailsView) */}
<DetectionAttributesSection ip={ip} /> <div id="section-attributs">
<DetectionAttributesSection ip={ip} />
</div>
{/* Ligne 0 : Synthèse multi-sources */} {/* Synthèse multi-sources */}
<IPActivitySummary ip={ip} /> <div id="section-synthese">
<IPActivitySummary ip={ip} />
</div>
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */} {/* Réputation (1/3) + Graph de corrélations (2/3) */}
<div className="grid grid-cols-3 gap-6 items-start"> <div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
<div className="bg-background-secondary rounded-lg p-6 h-full"> <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> <h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
<ReputationPanel ip={ip} /> <ReputationPanel ip={ip} />
</div> </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> <h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
<CorrelationGraph ip={ip} height="600px" /> <CorrelationGraph ip={ip} height="600px" />
</div> </div>
</div> </div>
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */} {/* Subnet / Country / JA4 */}
<div className="grid grid-cols-3 gap-6 items-start"> <div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
<SubnetAnalysis ip={ip} /> <SubnetAnalysis ip={ip} />
<CountryAnalysis ip={ip} /> <CountryAnalysis ip={ip} />
<JA4Analysis ip={ip} /> <JA4Analysis ip={ip} />
</div> </div>
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */} {/* User-Agents (1/2) + Classification (1/2) */}
<div className="grid grid-cols-2 gap-6 items-start"> <div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
<UserAgentAnalysis ip={ip} /> <UserAgentAnalysis ip={ip} />
<CorrelationSummary ip={ip} onClassify={handleClassify} /> <CorrelationSummary ip={ip} onClassify={handleClassify} />
</div> </div>
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */} {/* Cohérence JA4/UA (spoofing) */}
<div className="grid grid-cols-3 gap-6 items-start"> <div className="grid grid-cols-3 gap-6 items-start">
<FingerprintCoherenceWidget ip={ip} /> <FingerprintCoherenceWidget ip={ip} />
<div className="col-span-2 bg-background-secondary rounded-lg p-5"> <div className="col-span-2 bg-background-secondary rounded-lg p-5">

View File

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