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
"""
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

View File

@ -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:

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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,

View File

@ -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]:

View File

@ -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

View File

@ -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)}")

View File

@ -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,
sum(hits) AS bruteforce_hits
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
"""

View File

@ -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 />} />

View File

@ -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

View File

@ -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) => (
<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>
{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>
)}
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 whitespace-nowrap">{row.src_ip}</div>
<div className="font-mono text-xs text-text-disabled truncate max-w-[200px]" title={ja4s.join(' | ')}>
{ja4Label}
</div>
) : (
<div className="font-mono text-xs text-text-secondary break-all whitespace-normal">
{row.ja4 || '-'}
</div>
)}
</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' : ''}
</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>
)}
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>
) : (
<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>
</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>
</>
),
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>
);
},
};
default:
return { key: col.key, label: col.label, sortable: col.sortable };
@ -393,118 +353,143 @@ 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
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
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'}
onClick={() => setSearchParams({})}
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 ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
Effacer
</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>
{/* Spacer */}
<div className="flex-1" />
{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>
{/* 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>
{/* 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">
<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"
{/* 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"
>
<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-lg px-4 py-2 text-text-primary 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>
</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"
>
Effacer filtres
</button>
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>
{/* 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 */}
{/* ── 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>
);
}

View File

@ -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>
</div>
<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 {

View File

@ -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) */}
<DetectionAttributesSection ip={ip} />
<div id="section-attributs">
<DetectionAttributesSection ip={ip} />
</div>
{/* Ligne 0 : Synthèse multi-sources */}
<IPActivitySummary ip={ip} />
{/* 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">

View File

@ -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>