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:
@ -2,7 +2,6 @@
|
||||
Configuration du Dashboard Bot Detector
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@ -17,15 +16,9 @@ class Settings(BaseSettings):
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8000
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT: int = 3000
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_PER_MINUTE: int = 100
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
@ -40,11 +40,6 @@ class ClickHouseClient:
|
||||
client = self.connect()
|
||||
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):
|
||||
"""Ferme la connexion"""
|
||||
if self._client:
|
||||
|
||||
@ -87,20 +87,25 @@ app.include_router(search.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
|
||||
@app.get("/")
|
||||
async def serve_frontend():
|
||||
"""Sert l'application React"""
|
||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "index.html")
|
||||
if os.path.exists(frontend_path):
|
||||
return FileResponse(frontend_path)
|
||||
if os.path.exists(_FRONTEND_INDEX):
|
||||
return FileResponse(_FRONTEND_INDEX)
|
||||
return {"message": "Dashboard API - Frontend non construit. Voir /docs pour l'API."}
|
||||
|
||||
|
||||
# Servir les assets statiques
|
||||
assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
|
||||
if os.path.exists(assets_path):
|
||||
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
|
||||
_assets_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist", "assets")
|
||||
if os.path.exists(_assets_path):
|
||||
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
|
||||
@ -123,9 +128,8 @@ async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
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_path):
|
||||
return FileResponse(frontend_path)
|
||||
if os.path.exists(_FRONTEND_INDEX):
|
||||
return FileResponse(_FRONTEND_INDEX)
|
||||
return {"message": "Dashboard API - Frontend non construit"}
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""
|
||||
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 datetime import datetime
|
||||
from enum import Enum
|
||||
@ -14,11 +14,6 @@ class ThreatLevel(str, Enum):
|
||||
LOW = "LOW"
|
||||
|
||||
|
||||
class ModelName(str, Enum):
|
||||
COMPLET = "Complet"
|
||||
APPLICATIF = "Applicatif"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# MÉTRIQUES
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -169,33 +164,6 @@ class UserAgentsResponse(BaseModel):
|
||||
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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -223,23 +191,13 @@ class ClassificationCreate(ClassificationBase):
|
||||
|
||||
class Classification(ClassificationBase):
|
||||
"""Classification complète avec métadonnées"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
created_at: datetime
|
||||
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):
|
||||
"""Réponse pour la liste des classifications"""
|
||||
items: List[Classification]
|
||||
total: int
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""
|
||||
Endpoints pour l'analyse de corrélations et la classification SOC
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
import json
|
||||
|
||||
@ -17,6 +17,14 @@ from ..models import (
|
||||
|
||||
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
|
||||
@ -122,15 +130,6 @@ async def analyze_ip_country(ip: str):
|
||||
ip_country_code = ip_result.result_rows[0][0]
|
||||
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
|
||||
asn_countries_query = """
|
||||
SELECT
|
||||
@ -150,7 +149,7 @@ async def analyze_ip_country(ip: str):
|
||||
asn_countries = [
|
||||
{
|
||||
"code": row[0],
|
||||
"name": country_names.get(row[0], row[0]),
|
||||
"name": _COUNTRY_NAMES.get(row[0], row[0]),
|
||||
"count": row[1],
|
||||
"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 {
|
||||
"ip_country": {
|
||||
"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
|
||||
}
|
||||
@ -196,19 +195,10 @@ async def analyze_country(days: int = Query(1, ge=1, le=30)):
|
||||
# Calculer le total pour le pourcentage
|
||||
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 = [
|
||||
CountryData(
|
||||
code=row[0],
|
||||
name=country_names.get(row[0], row[0]),
|
||||
name=_COUNTRY_NAMES.get(row[0], row[0]),
|
||||
count=row[1],
|
||||
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})
|
||||
|
||||
# Grouper par subnet /24
|
||||
from collections import defaultdict
|
||||
subnet_counts = defaultdict(int)
|
||||
for row in subnets_result.result_rows:
|
||||
ip_addr = str(row[0])
|
||||
@ -439,22 +428,22 @@ async def get_classification_recommendation(ip: str):
|
||||
# Récupérer les analyses
|
||||
try:
|
||||
subnet_analysis = await analyze_subnet(ip)
|
||||
except:
|
||||
except Exception:
|
||||
subnet_analysis = None
|
||||
|
||||
try:
|
||||
country_analysis = await analyze_country(1)
|
||||
except:
|
||||
except Exception:
|
||||
country_analysis = None
|
||||
|
||||
try:
|
||||
ja4_analysis = await analyze_ja4(ip)
|
||||
except:
|
||||
except Exception:
|
||||
ja4_analysis = None
|
||||
|
||||
try:
|
||||
ua_analysis = await analyze_user_agents(ip)
|
||||
except:
|
||||
except Exception:
|
||||
ua_analysis = None
|
||||
|
||||
# Indicateurs par défaut
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"""
|
||||
Routes pour l'audit et les logs d'activité
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ..database import db
|
||||
|
||||
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/logs")
|
||||
@ -50,8 +52,8 @@ async def create_audit_log(
|
||||
try:
|
||||
db.query(insert_query, params)
|
||||
except Exception as e:
|
||||
# Table might not exist yet, log warning
|
||||
print(f"Warning: Could not insert audit log: {e}")
|
||||
# La table peut ne pas encore exister — on logue mais on ne bloque pas l'appelant
|
||||
logger.warning(f"Could not insert audit log: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
@ -6,22 +6,20 @@ Clustering d'IPs multi-métriques — WebGL / deck.gl backend.
|
||||
- Calcul en background thread + cache 30 min
|
||||
- Endpoints : /clusters, /status, /cluster/{id}/points
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional, Any
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
from ..services.clustering_engine import (
|
||||
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES,
|
||||
FEATURE_NAMES,
|
||||
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
|
||||
name_cluster, risk_score_from_centroid, standardize,
|
||||
risk_to_gradient_color,
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
Routes pour l'investigation d'entités (IP, JA4, User-Agent, Client-Header, Host, Path, Query-Param)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import Optional, List
|
||||
|
||||
from ..database import db
|
||||
from ..models import (
|
||||
@ -16,18 +14,10 @@ from ..models import (
|
||||
|
||||
router = APIRouter(prefix="/api/entities", tags=["Entities"])
|
||||
|
||||
db = db
|
||||
|
||||
# Mapping des types d'entités
|
||||
ENTITY_TYPES = {
|
||||
'ip': 'ip',
|
||||
'ja4': 'ja4',
|
||||
'user_agent': 'user_agent',
|
||||
'client_header': 'client_header',
|
||||
'host': 'host',
|
||||
'path': 'path',
|
||||
'query_param': 'query_param'
|
||||
}
|
||||
# Ensemble des types d'entités valides
|
||||
VALID_ENTITY_TYPES = frozenset({
|
||||
'ip', 'ja4', 'user_agent', 'client_header', 'host', 'path', 'query_param'
|
||||
})
|
||||
|
||||
|
||||
def get_entity_stats(entity_type: str, entity_value: str, hours: int = 24) -> Optional[EntityStats]:
|
||||
|
||||
@ -10,7 +10,6 @@ Objectifs:
|
||||
qui usurpent des UA de navigateurs légitimes
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
import re
|
||||
|
||||
from ..database import db
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
"""
|
||||
Routes pour la gestion des incidents clusterisés
|
||||
"""
|
||||
import hashlib
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from ..database import db
|
||||
from ..models import BaseModel
|
||||
|
||||
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
|
||||
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
|
||||
ua_by_ip: dict = {}
|
||||
@ -182,7 +181,7 @@ async def get_incident_clusters(
|
||||
primary_ua = ua_by_ip.get(sample_ip, "")
|
||||
|
||||
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,
|
||||
"severity": severity,
|
||||
"total_detections": row[1],
|
||||
@ -213,22 +212,13 @@ async def get_incident_clusters(
|
||||
@router.get("/{cluster_id}")
|
||||
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:
|
||||
# Extraire le subnet du cluster_id (simplifié)
|
||||
# Dans une implémentation réelle, on aurait une table de mapping
|
||||
|
||||
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)}")
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Détails par incident non encore implémentés. Utilisez /api/incidents/clusters pour la liste."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{cluster_id}/classify")
|
||||
@ -239,34 +229,38 @@ async def classify_incident(
|
||||
comment: str = ""
|
||||
):
|
||||
"""
|
||||
Classe un incident rapidement
|
||||
Classe un incident rapidement.
|
||||
Non encore implémenté — utilisez /api/analysis/{ip}/classify pour classifier une IP.
|
||||
"""
|
||||
try:
|
||||
# Implementation future - sauvegarde dans la table classifications
|
||||
return {
|
||||
"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)}")
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Classification par incident non encore implémentée. Utilisez /api/analysis/{ip}/classify."
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_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)
|
||||
):
|
||||
"""
|
||||
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:
|
||||
# Redirige vers clusters pour l'instant
|
||||
return await get_incident_clusters(hours=hours, limit=50)
|
||||
result = await get_incident_clusters(hours=hours, limit=100)
|
||||
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:
|
||||
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"""
|
||||
Endpoints pour la détection de la rotation de fingerprints JA4 et des menaces persistantes
|
||||
"""
|
||||
import math
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
@ -110,7 +109,7 @@ async def get_sophistication(limit: int = Query(50, ge=1, le=500)):
|
||||
try:
|
||||
sql = """
|
||||
SELECT
|
||||
replaceRegexpAll(toString(r.src_ip), '^::ffff:', '') AS ip,
|
||||
r.ip,
|
||||
r.distinct_ja4_count,
|
||||
coalesce(rec.recurrence, 0) AS recurrence,
|
||||
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
|
||||
+ least(30.0, log(coalesce(bf.bruteforce_hits, 0) + 1) * 5)
|
||||
), 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 (
|
||||
SELECT src_ip, count() AS recurrence
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
count() AS recurrence
|
||||
FROM mabase_prod.ml_detected_anomalies FINAL
|
||||
GROUP BY src_ip
|
||||
) rec USING(src_ip)
|
||||
GROUP BY ip
|
||||
) rec ON r.ip = rec.ip
|
||||
LEFT JOIN (
|
||||
SELECT replaceRegexpAll(toString(src_ip),'^::ffff:','') AS src_ip,
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
sum(hits) AS bruteforce_hits
|
||||
FROM mabase_prod.view_form_bruteforce_detected
|
||||
GROUP BY src_ip
|
||||
) bf USING(src_ip)
|
||||
GROUP BY ip
|
||||
) bf ON r.ip = bf.ip
|
||||
ORDER BY sophistication_score DESC
|
||||
LIMIT %(limit)s
|
||||
"""
|
||||
|
||||
@ -367,7 +367,7 @@ function MainContent({ counts: _counts }: { counts: AlertCounts | null }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 px-6 py-5 mt-14 overflow-auto">
|
||||
<main className="flex-1 px-4 py-3 mt-14 overflow-auto">
|
||||
<Routes>
|
||||
<Route path="/" element={<IncidentsView />} />
|
||||
<Route path="/incidents" element={<IncidentsView />} />
|
||||
|
||||
@ -114,7 +114,7 @@ export function CampaignsView() {
|
||||
const [subnetLoading, setSubnetLoading] = useState<Set<string>>(new Set());
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('clusters');
|
||||
const [minIPs, setMinIPs] = useState(3);
|
||||
const [minIPs, setMinIPs] = useState(1);
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
|
||||
// Fetch clusters on mount
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDetections } from '../hooks/useDetections';
|
||||
import DataTable, { Column } from './ui/DataTable';
|
||||
@ -55,6 +55,14 @@ export function DetectionsList() {
|
||||
const scoreType = searchParams.get('score_type') || undefined;
|
||||
|
||||
const [groupByIP, setGroupByIP] = useState(true);
|
||||
const [threatDist, setThreatDist] = useState<{threat_level: string; count: number; percentage: number}[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/metrics/threats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.items) setThreatDist(d.items); })
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
const { data, loading, error } = useDetections({
|
||||
page,
|
||||
@ -157,60 +165,36 @@ export function DetectionsList() {
|
||||
key: 'src_ip',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
width: 'w-[220px] min-w-[180px]',
|
||||
render: (_, row) => {
|
||||
const ja4s = groupByIP && row.unique_ja4s?.length ? row.unique_ja4s : row.ja4 ? [row.ja4] : [];
|
||||
const ja4Label = ja4s.length > 1 ? `${ja4s.length} JA4` : ja4s[0] ?? '—';
|
||||
return (
|
||||
<div>
|
||||
<div className="font-mono text-sm text-text-primary">{row.src_ip}</div>
|
||||
{groupByIP && row.unique_ja4s && row.unique_ja4s.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_ja4s.length} JA4{row.unique_ja4s.length > 1 ? 's' : ''} unique{row.unique_ja4s.length > 1 ? 's' : ''}
|
||||
<div className="font-mono text-sm text-text-primary whitespace-nowrap">{row.src_ip}</div>
|
||||
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
|
||||
{ja4Label}
|
||||
</div>
|
||||
{row.unique_ja4s.slice(0, 3).map((ja4, idx) => (
|
||||
<div key={idx} className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{ja4}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_ja4s.length > 3 && (
|
||||
<div className="font-mono text-xs text-text-disabled">
|
||||
+{row.unique_ja4s.length - 3} autre{row.unique_ja4s.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
|
||||
{row.ja4 || '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'host':
|
||||
return {
|
||||
key: 'host',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.unique_hosts && row.unique_hosts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary font-medium">
|
||||
{row.unique_hosts.length} Host{row.unique_hosts.length > 1 ? 's' : ''} unique{row.unique_hosts.length > 1 ? 's' : ''}
|
||||
width: 'w-[180px] min-w-[140px]',
|
||||
render: (_, row) => {
|
||||
const hosts = groupByIP && row.unique_hosts?.length ? row.unique_hosts : row.host ? [row.host] : [];
|
||||
const primary = hosts[0] ?? '—';
|
||||
const extra = hosts.length > 1 ? ` +${hosts.length - 1}` : '';
|
||||
return (
|
||||
<div className="truncate max-w-[175px] text-sm text-text-primary" title={hosts.join(', ')}>
|
||||
{primary}<span className="text-text-disabled text-xs">{extra}</span>
|
||||
</div>
|
||||
{row.unique_hosts.slice(0, 3).map((host, idx) => (
|
||||
<div key={idx} className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{host}
|
||||
</div>
|
||||
))}
|
||||
{row.unique_hosts.length > 3 && (
|
||||
<div className="text-xs text-text-disabled">
|
||||
+{row.unique_hosts.length - 3} autre{row.unique_hosts.length - 3 > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-primary break-all whitespace-normal max-w-md">
|
||||
{row.host || '-'}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
case 'client_headers':
|
||||
return {
|
||||
@ -257,24 +241,18 @@ export function DetectionsList() {
|
||||
</span>
|
||||
),
|
||||
sortable: false,
|
||||
width: 'w-[140px]',
|
||||
render: (_, row) => {
|
||||
const name = row.anubis_bot_name;
|
||||
const action = row.anubis_bot_action;
|
||||
const category = row.anubis_bot_category;
|
||||
if (!name) return <span className="text-text-disabled text-xs">—</span>;
|
||||
const actionColor =
|
||||
action === 'ALLOW' ? 'bg-green-500/15 text-green-400 border-green-500/30' :
|
||||
action === 'DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30' :
|
||||
'bg-yellow-500/15 text-yellow-400 border-yellow-500/30';
|
||||
action === 'ALLOW' ? 'text-green-400' :
|
||||
action === 'DENY' ? 'text-red-400' : 'text-yellow-400';
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${actionColor}`}>
|
||||
<span className="font-medium">{name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{action && <span className="text-[10px] text-text-secondary">{action}</span>}
|
||||
{category && <span className="text-[10px] text-text-disabled">· {category}</span>}
|
||||
</div>
|
||||
<div className="truncate max-w-[135px]" title={`${name} · ${action}`}>
|
||||
<span className={`text-xs font-medium ${actionColor}`}>{name}</span>
|
||||
{action && <span className="text-[10px] text-text-disabled ml-1">· {action}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -330,13 +308,11 @@ export function DetectionsList() {
|
||||
key: 'asn_org',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
width: 'w-[150px]',
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div className="text-sm text-text-primary">{row.asn_org || row.asn_number || '-'}</div>
|
||||
{row.asn_number && (
|
||||
<div className="text-xs text-text-secondary">AS{row.asn_number}</div>
|
||||
)}
|
||||
<AsnRepBadge score={row.asn_score} label={row.asn_rep_label} />
|
||||
<div className="truncate max-w-[145px]" title={`${row.asn_org ?? ''} AS${row.asn_number ?? ''}`}>
|
||||
<span className="text-sm text-text-primary">{row.asn_org || `AS${row.asn_number}` || '—'}</span>
|
||||
{row.asn_number && <span className="text-xs text-text-disabled ml-1">AS{row.asn_number}</span>}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -358,34 +334,18 @@ export function DetectionsList() {
|
||||
key: 'detected_at',
|
||||
label: col.label,
|
||||
sortable: true,
|
||||
render: (_, row) =>
|
||||
groupByIP && row.first_seen ? (() => {
|
||||
const first = new Date(row.first_seen!);
|
||||
width: 'w-[110px]',
|
||||
render: (_, row) => {
|
||||
if (groupByIP && row.first_seen) {
|
||||
const last = new Date(row.last_seen!);
|
||||
const sameTime = first.getTime() === last.getTime();
|
||||
const fmt = (d: Date) => formatDate(d.toISOString());
|
||||
return sameTime ? (
|
||||
<div className="text-xs text-text-secondary">{fmt(last)}</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Premier:</span> {fmt(first)}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary">
|
||||
<span className="font-medium">Dernier:</span> {fmt(last)}
|
||||
</div>
|
||||
return <div className="text-xs text-text-secondary whitespace-nowrap">{formatDate(last.toISOString())}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-text-secondary whitespace-nowrap">
|
||||
{formatDateOnly(row.detected_at)} {formatTimeOnly(row.detected_at)}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<>
|
||||
<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:
|
||||
return { key: col.key, label: col.label, sortable: col.sortable };
|
||||
@ -393,88 +353,58 @@ export function DetectionsList() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* En-tête */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Détections</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>{data.items.length}</span>
|
||||
<span>→</span>
|
||||
<span>{data.total} détections</span>
|
||||
</div>
|
||||
<div className="space-y-2 animate-fade-in">
|
||||
|
||||
{/* ── Barre unique : titre + pills + filtres + recherche ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 bg-background-secondary rounded-lg px-3 py-2">
|
||||
|
||||
{/* Titre + compteur */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="font-semibold text-text-primary">Détections</span>
|
||||
<span className="text-xs text-text-disabled bg-background-card rounded px-1.5 py-0.5">
|
||||
{data.total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Toggle Grouper par IP */}
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{/* Pills distribution */}
|
||||
{threatDist.map(({ threat_level, count, percentage }) => {
|
||||
const label = threat_level === 'KNOWN_BOT' ? '🤖 BOT' :
|
||||
threat_level === 'ANUBIS_DENY' ? '🔴 RÈGLE' :
|
||||
threat_level === 'HIGH' ? '⚠️ HIGH' :
|
||||
threat_level === 'MEDIUM' ? '📊 MED' :
|
||||
threat_level === 'CRITICAL' ? '🔥 CRIT' : threat_level;
|
||||
const style = threat_level === 'KNOWN_BOT' ? 'bg-green-500/15 text-green-400 border-green-500/30 hover:bg-green-500/25' :
|
||||
threat_level === 'ANUBIS_DENY' ? 'bg-red-500/15 text-red-400 border-red-500/30 hover:bg-red-500/25' :
|
||||
threat_level === 'HIGH' ? 'bg-orange-500/15 text-orange-400 border-orange-500/30 hover:bg-orange-500/25' :
|
||||
threat_level === 'MEDIUM' ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30 hover:bg-yellow-500/25' :
|
||||
threat_level === 'CRITICAL' ? 'bg-red-700/15 text-red-300 border-red-700/30 hover:bg-red-700/25' :
|
||||
'bg-background-card text-text-secondary border-background-card';
|
||||
const filterVal = threat_level === 'KNOWN_BOT' ? 'BOT' : threat_level === 'ANUBIS_DENY' ? 'REGLE' : null;
|
||||
const active = filterVal && scoreType === filterVal;
|
||||
return (
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`border rounded-lg px-4 py-2 text-sm transition-colors ${
|
||||
groupByIP
|
||||
? 'bg-accent-primary text-white border-accent-primary'
|
||||
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
|
||||
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' : ''}`}
|
||||
>
|
||||
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
|
||||
{label} <span className="font-bold">{count.toLocaleString()}</span>
|
||||
<span className="opacity-50">{percentage.toFixed(0)}%</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sélecteur de colonnes */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="bg-background-card hover:bg-background-card/80 border border-background-card rounded-lg px-4 py-2 text-text-primary transition-colors"
|
||||
>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
<div className="w-px h-5 bg-background-card shrink-0" />
|
||||
|
||||
{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 */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Rechercher IP, JA4, Host..."
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-64"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="bg-background-secondary rounded-lg p-4">
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Filtres select */}
|
||||
<select
|
||||
value={modelName || ''}
|
||||
onChange={(e) => handleFilterChange('model_name', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
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>
|
||||
@ -484,27 +414,82 @@ export function DetectionsList() {
|
||||
<select
|
||||
value={scoreType || ''}
|
||||
onChange={(e) => handleFilterChange('score_type', e.target.value)}
|
||||
className="bg-background-card border border-background-card rounded-lg px-4 py-2 text-text-primary focus:outline-none focus:border-accent-primary"
|
||||
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 types de score</option>
|
||||
<option value="BOT">🟢 BOT seulement</option>
|
||||
<option value="REGLE">🔴 RÈGLE seulement</option>
|
||||
<option value="BOT_REGLE">BOT + RÈGLE</option>
|
||||
<option value="SCORE">Score numérique seulement</option>
|
||||
<option value="">Tous scores</option>
|
||||
<option value="BOT">🟢 BOT</option>
|
||||
<option value="REGLE">🔴 RÈGLE</option>
|
||||
<option value="BOT_REGLE">BOT+RÈGLE</option>
|
||||
<option value="SCORE">Score num.</option>
|
||||
</select>
|
||||
|
||||
{(modelName || scoreType || search || sortField !== 'detected_at') && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
className="text-xs text-text-secondary hover:text-text-primary bg-background-card rounded px-2 py-1 border border-background-card transition-colors"
|
||||
>
|
||||
Effacer filtres
|
||||
✕ Effacer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Toggle grouper */}
|
||||
<button
|
||||
onClick={() => setGroupByIP(!groupByIP)}
|
||||
className={`text-xs border rounded px-2 py-1 transition-colors shrink-0 ${
|
||||
groupByIP ? 'bg-accent-primary text-white border-accent-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
|
||||
}`}
|
||||
title={groupByIP ? 'Vue individuelle' : 'Vue groupée par IP'}
|
||||
>
|
||||
{groupByIP ? '⊞ Groupé' : '⊟ Individuel'}
|
||||
</button>
|
||||
|
||||
{/* Sélecteur colonnes */}
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setShowColumnSelector(!showColumnSelector)}
|
||||
className="text-xs bg-background-card hover:bg-background-card/80 border border-background-card rounded px-2 py-1 text-text-primary transition-colors"
|
||||
>
|
||||
Colonnes ▾
|
||||
</button>
|
||||
{showColumnSelector && (
|
||||
<div className="absolute right-0 mt-1 w-44 bg-background-secondary border border-background-card rounded-lg shadow-lg z-20 p-2">
|
||||
{columns.map(col => (
|
||||
<label key={col.key} className="flex items-center gap-2 px-2 py-1 hover:bg-background-card rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.visible}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
className="rounded bg-background-card border-background-card text-accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-primary">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{/* Recherche */}
|
||||
<form onSubmit={handleSearch} className="flex gap-1 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="IP, JA4, Host..."
|
||||
className="bg-background-card border border-background-card rounded px-2 py-1 text-xs text-text-primary placeholder-text-disabled focus:outline-none focus:border-accent-primary w-40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-primary hover:bg-accent-primary/80 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="bg-background-secondary rounded-lg overflow-x-auto">
|
||||
<DataTable<DetectionRow>
|
||||
data={processedData.items as DetectionRow[]}
|
||||
@ -519,24 +504,24 @@ export function DetectionsList() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{/* ── Pagination ── */}
|
||||
{data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-text-secondary text-sm">
|
||||
Page {data.page} sur {data.total_pages} ({data.total} détections)
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-text-secondary text-xs">
|
||||
Page {data.page}/{data.total_pages} · {data.total.toLocaleString()} détections
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
disabled={data.page === 1}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
← Précédent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
disabled={data.page === data.total_pages}
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary px-4 py-2 rounded-lg transition-colors"
|
||||
className="bg-background-card hover:bg-background-card/80 disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
Suivant →
|
||||
</button>
|
||||
@ -611,27 +596,3 @@ function getFlag(countryCode: string): string {
|
||||
const code = countryCode.toUpperCase();
|
||||
return code.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
|
||||
// Badge de réputation ASN
|
||||
function AsnRepBadge({ score, label }: { score?: number | null; label?: string }) {
|
||||
if (score == null) return null;
|
||||
let bg: string;
|
||||
let text: string;
|
||||
let display: string;
|
||||
if (score < 0.3) {
|
||||
bg = 'bg-threat-critical/20';
|
||||
text = 'text-threat-critical';
|
||||
} else if (score < 0.6) {
|
||||
bg = 'bg-threat-medium/20';
|
||||
text = 'text-threat-medium';
|
||||
} else {
|
||||
bg = 'bg-threat-low/20';
|
||||
text = 'text-threat-low';
|
||||
}
|
||||
display = label || (score < 0.3 ? 'malicious' : score < 0.6 ? 'suspect' : 'ok');
|
||||
return (
|
||||
<span className={`mt-1 inline-block text-xs px-1.5 py-0.5 rounded ${bg} ${text}`}>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,6 +30,8 @@ interface MetricsSummary {
|
||||
medium_count: number;
|
||||
low_count: number;
|
||||
unique_ips: number;
|
||||
known_bots_count: number;
|
||||
anomalies_count: number;
|
||||
}
|
||||
|
||||
interface BaselineMetric {
|
||||
@ -135,81 +137,114 @@ export function IncidentsView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header with Quick Search */}
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">SOC Dashboard</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">
|
||||
Surveillance en temps réel - 24 dernières heures
|
||||
</p>
|
||||
<p className="text-text-secondary text-sm mt-1">Surveillance en temps réel · 24 dernières heures</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baseline comparison */}
|
||||
{baseline && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([
|
||||
{ key: 'total_detections', label: 'Détections 24h', icon: '📊', tip: TIPS.total_detections_stat },
|
||||
{ key: 'unique_ips', label: 'IPs uniques', icon: '🖥️', tip: TIPS.unique_ips_stat },
|
||||
{ key: 'critical_alerts', label: 'Alertes CRITICAL', icon: '🔴', tip: TIPS.risk_critical },
|
||||
] as { key: keyof BaselineData; label: string; icon: string; tip: string }[]).map(({ key, label, icon, tip }) => {
|
||||
const m = baseline[key];
|
||||
{/* Stats unifiées — 6 cartes compact */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{/* Total détections avec comparaison hier */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
📊 Total 24h<InfoTip content={TIPS.total_detections_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.total_detections ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.total_detections;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div key={key} className="bg-background-card border border-border rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-xl">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-text-disabled uppercase tracking-wide flex items-center gap-1">{label}<InfoTip content={tip} /></div>
|
||||
<div className="text-xl font-bold text-text-primary">{m.today.toLocaleString(navigator.language || undefined)}</div>
|
||||
<div className="text-xs text-text-secondary">hier: {m.yesterday.toLocaleString(navigator.language || undefined)}</div>
|
||||
</div>
|
||||
<div className={`text-sm font-bold px-2 py-1 rounded ${
|
||||
neutral ? 'text-text-disabled' :
|
||||
up ? 'text-threat-critical bg-threat-critical/10' :
|
||||
'text-threat-low bg-threat-low/10'
|
||||
}`}>
|
||||
{neutral ? '=' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`}
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Critical Metrics */}
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
title="CRITICAL"
|
||||
value={metrics.critical_count.toLocaleString()}
|
||||
subtitle={metrics.critical_count > 0 ? 'Requiert action immédiate' : 'Aucune'}
|
||||
color="bg-red-500/20"
|
||||
trend={metrics.critical_count > 10 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
title="HIGH"
|
||||
value={metrics.high_count.toLocaleString()}
|
||||
subtitle="Menaces élevées"
|
||||
color="bg-orange-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
<MetricCard
|
||||
title="MEDIUM"
|
||||
value={metrics.medium_count.toLocaleString()}
|
||||
subtitle="Menaces moyennes"
|
||||
color="bg-yellow-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
<MetricCard
|
||||
title="TOTAL"
|
||||
value={metrics.total_detections.toLocaleString()}
|
||||
subtitle={`${metrics.unique_ips.toLocaleString()} IPs uniques`}
|
||||
color="bg-blue-500/20"
|
||||
trend="stable"
|
||||
/>
|
||||
{/* IPs uniques */}
|
||||
<div
|
||||
className="bg-background-card border border-border rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-accent-primary/50 transition-colors"
|
||||
onClick={() => navigate('/detections')}
|
||||
>
|
||||
<div className="text-[10px] text-text-disabled uppercase tracking-wide flex items-center gap-1">
|
||||
🖥️ IPs uniques<InfoTip content={TIPS.unique_ips_stat} />
|
||||
</div>
|
||||
<div className="text-xl font-bold text-text-primary">
|
||||
{(metrics?.unique_ips ?? 0).toLocaleString()}
|
||||
</div>
|
||||
{baseline && (() => {
|
||||
const m = baseline.unique_ips;
|
||||
const up = m.pct_change > 0;
|
||||
const neutral = m.pct_change === 0;
|
||||
return (
|
||||
<div className={`text-[10px] font-medium ${neutral ? 'text-text-disabled' : up ? 'text-threat-critical' : 'text-threat-low'}`}>
|
||||
{neutral ? '= même' : up ? `▲ +${m.pct_change}%` : `▼ ${m.pct_change}%`} vs hier
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* BOT connus */}
|
||||
<div
|
||||
className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-green-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=BOT')}
|
||||
>
|
||||
<div className="text-[10px] text-green-400/80 uppercase tracking-wide">🤖 BOT nommés</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{(metrics?.known_bots_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-green-400/60">
|
||||
{metrics ? Math.round((metrics.known_bots_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies ML */}
|
||||
<div
|
||||
className="bg-purple-500/10 border border-purple-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-purple-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?score_type=SCORE')}
|
||||
>
|
||||
<div className="text-[10px] text-purple-400/80 uppercase tracking-wide">🔬 Anomalies ML</div>
|
||||
<div className="text-xl font-bold text-purple-400">
|
||||
{(metrics?.anomalies_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-400/60">
|
||||
{metrics ? Math.round((metrics.anomalies_count / metrics.total_detections) * 100) : 0}% du total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HIGH */}
|
||||
<div
|
||||
className="bg-orange-500/10 border border-orange-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-orange-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=HIGH')}
|
||||
>
|
||||
<div className="text-[10px] text-orange-400/80 uppercase tracking-wide">⚠️ HIGH</div>
|
||||
<div className="text-xl font-bold text-orange-400">
|
||||
{(metrics?.high_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-orange-400/60">Menaces élevées</div>
|
||||
</div>
|
||||
|
||||
{/* MEDIUM */}
|
||||
<div
|
||||
className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2.5 flex flex-col gap-0.5 cursor-pointer hover:border-yellow-500/60 transition-colors"
|
||||
onClick={() => navigate('/detections?threat_level=MEDIUM')}
|
||||
>
|
||||
<div className="text-[10px] text-yellow-400/80 uppercase tracking-wide">📊 MEDIUM</div>
|
||||
<div className="text-xl font-bold text-yellow-400">
|
||||
{(metrics?.medium_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-[10px] text-yellow-400/60">Menaces moyennes</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedClusters.size > 0 && (
|
||||
@ -478,34 +513,6 @@ export function IncidentsView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Metric Card Component
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
color,
|
||||
trend
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}) {
|
||||
return (
|
||||
<div className={`${color} rounded-lg p-6`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-text-secondary text-sm font-medium">{title}</h3>
|
||||
<span className="text-lg">
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-text-primary">{value}</p>
|
||||
<p className="text-text-disabled text-xs mt-2">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Mini Heatmap ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface HeatmapHour {
|
||||
|
||||
@ -74,9 +74,9 @@ function MiniTimeline({ data }: { data: { hour: number; hits: number }[] }) {
|
||||
}
|
||||
|
||||
function IPActivitySummary({ ip }: { ip: string }) {
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
const [open, setOpen] = useState(false); // fermée par défaut
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [data, setData] = useState<IPSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@ -327,7 +327,7 @@ function Metric({ label, value, accent }: { label: string; value: string; accent
|
||||
}
|
||||
|
||||
function DetectionAttributesSection({ ip }: { ip: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(true); // ouvert par défaut
|
||||
const { data, loading } = useVariability('ip', ip);
|
||||
|
||||
const first = data?.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
|
||||
@ -448,38 +448,59 @@ export function InvestigationView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation ancres inter-sections */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 text-xs font-medium sticky top-0 z-10 bg-background py-2">
|
||||
<span className="text-text-disabled shrink-0">Aller à :</span>
|
||||
{[
|
||||
{ id: 'section-attributs', label: '📡 Attributs' },
|
||||
{ id: 'section-synthese', label: '🔎 Synthèse' },
|
||||
{ id: 'section-reputation', label: '🌍 Réputation' },
|
||||
{ id: 'section-correlations', label: '🕸️ Corrélations' },
|
||||
{ id: 'section-geo', label: '🌐 Géo / JA4' },
|
||||
{ id: 'section-classification', label: '🏷️ Classification' },
|
||||
].map(({ id, label }) => (
|
||||
<a key={id} href={`#${id}`} className="shrink-0 px-3 py-1 rounded-full bg-background-card text-text-secondary hover:text-text-primary hover:bg-background-secondary transition-colors">
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributs détectés (ex-DetailsView) */}
|
||||
<div id="section-attributs">
|
||||
<DetectionAttributesSection ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 0 : Synthèse multi-sources */}
|
||||
{/* Synthèse multi-sources */}
|
||||
<div id="section-synthese">
|
||||
<IPActivitySummary ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 1 : Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
{/* Réputation (1/3) + Graph de corrélations (2/3) */}
|
||||
<div id="section-reputation" className="grid grid-cols-3 gap-6 items-start">
|
||||
<div className="bg-background-secondary rounded-lg p-6 h-full">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">🌍 Réputation IP</h3>
|
||||
<ReputationPanel ip={ip} />
|
||||
</div>
|
||||
<div className="col-span-2 bg-background-secondary rounded-lg p-6">
|
||||
<div id="section-correlations" className="col-span-2 bg-background-secondary rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">🕸️ Graph de Corrélations</h3>
|
||||
<CorrelationGraph ip={ip} height="600px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 2 : Subnet / Country / JA4 (3 colonnes) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
{/* Subnet / Country / JA4 */}
|
||||
<div id="section-geo" className="grid grid-cols-3 gap-6 items-start">
|
||||
<SubnetAnalysis ip={ip} />
|
||||
<CountryAnalysis ip={ip} />
|
||||
<JA4Analysis ip={ip} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 3 : User-Agents (1/2) + Classification (1/2) */}
|
||||
<div className="grid grid-cols-2 gap-6 items-start">
|
||||
{/* User-Agents (1/2) + Classification (1/2) */}
|
||||
<div id="section-classification" className="grid grid-cols-2 gap-6 items-start">
|
||||
<UserAgentAnalysis ip={ip} />
|
||||
<CorrelationSummary ip={ip} onClassify={handleClassify} />
|
||||
</div>
|
||||
|
||||
{/* Ligne 4 : Cohérence JA4/UA (spoofing) */}
|
||||
{/* Cohérence JA4/UA (spoofing) */}
|
||||
<div className="grid grid-cols-3 gap-6 items-start">
|
||||
<FingerprintCoherenceWidget ip={ip} />
|
||||
<div className="col-span-2 bg-background-secondary rounded-lg p-5">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { InfoTip } from './ui/Tooltip';
|
||||
import { TIPS } from './ui/tooltips';
|
||||
import { formatDateShort } from '../utils/dateUtils';
|
||||
@ -22,6 +23,7 @@ interface ClassificationStats {
|
||||
}
|
||||
|
||||
export function ThreatIntelView() {
|
||||
const navigate = useNavigate();
|
||||
const [classifications, setClassifications] = useState<Classification[]>([]);
|
||||
const [stats, setStats] = useState<ClassificationStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -232,20 +234,28 @@ export function ThreatIntelView() {
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Entité</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Label</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Tags</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Commentaire</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase"><span className="flex items-center gap-1">Confiance<InfoTip content={TIPS.confiance} /></span></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-text-secondary uppercase">Analyste</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-background-card">
|
||||
{filteredClassifications.slice(0, 50).map((classification, idx) => (
|
||||
{filteredClassifications.slice(0, 50).map((classification, idx) => {
|
||||
const entity = classification.ip || classification.ja4;
|
||||
const isIP = !!classification.ip;
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-background-card/50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">
|
||||
<td className="px-4 py-3 text-sm text-text-secondary whitespace-nowrap">
|
||||
{formatDateShort(classification.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-mono text-sm text-text-primary">
|
||||
{classification.ip || classification.ja4}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(isIP ? `/investigation/${encodeURIComponent(entity!)}` : `/investigation/ja4/${encodeURIComponent(entity!)}`)}
|
||||
className="font-mono text-sm text-accent-primary hover:underline text-left truncate max-w-[160px] block"
|
||||
title={entity}
|
||||
>
|
||||
{entity}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
|
||||
@ -254,17 +264,22 @@ export function ThreatIntelView() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{classification.tags.slice(0, 5).map((tag, tagIdx) => (
|
||||
{classification.tags.slice(0, 4).map((tag, tagIdx) => (
|
||||
<span key={tagIdx} className={`px-2 py-0.5 rounded text-xs ${getTagColor(tag)}`}>{tag}</span>
|
||||
))}
|
||||
{classification.tags.length > 5 && (
|
||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 5}</span>
|
||||
{classification.tags.length > 4 && (
|
||||
<span className="text-xs text-text-secondary">+{classification.tags.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary max-w-[200px]">
|
||||
<span className="truncate block" title={(classification as any).comment || ''}>
|
||||
{(classification as any).comment || <span className="text-text-disabled">—</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-2">
|
||||
<div className="flex-1 bg-background-secondary rounded-full h-2 min-w-[60px]">
|
||||
<div className="h-2 rounded-full bg-accent-primary" style={{ width: `${classification.confidence * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-primary font-bold">{(classification.confidence * 100).toFixed(0)}%</span>
|
||||
@ -272,7 +287,8 @@ export function ThreatIntelView() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{classification.analyst}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user