maj cumulative

This commit is contained in:
SOC Analyst
2026-03-18 13:56:39 +01:00
parent 32a96966dd
commit c887846af5
18 changed files with 986 additions and 686 deletions

View File

@ -13,7 +13,7 @@ import os
from .config import settings from .config import settings
from .database import db from .database import db
from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints from .routes import metrics, detections, variability, attributes, analysis, entities, incidents, audit, reputation, fingerprints
from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary from .routes import bruteforce, tcp_spoofing, header_fingerprint, heatmap, botnets, rotation, ml_features, investigation_summary, search
# Configuration logging # Configuration logging
logging.basicConfig( logging.basicConfig(
@ -83,6 +83,7 @@ app.include_router(botnets.router)
app.include_router(rotation.router) app.include_router(rotation.router)
app.include_router(ml_features.router) app.include_router(ml_features.router)
app.include_router(investigation_summary.router) app.include_router(investigation_summary.router)
app.include_router(search.router)
# Route pour servir le frontend # Route pour servir le frontend

View File

@ -77,6 +77,10 @@ class Detection(BaseModel):
client_headers: str = "" client_headers: str = ""
asn_score: Optional[float] = None asn_score: Optional[float] = None
asn_rep_label: str = "" asn_rep_label: str = ""
first_seen: Optional[datetime] = None
last_seen: Optional[datetime] = None
unique_ja4s: Optional[List[str]] = None
unique_hosts: Optional[List[str]] = None
class DetectionsListResponse(BaseModel): class DetectionsListResponse(BaseModel):

View File

@ -19,7 +19,8 @@ async def get_detections(
asn_number: Optional[str] = Query(None, description="Filtrer par ASN"), asn_number: Optional[str] = Query(None, description="Filtrer par ASN"),
search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"), search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"),
sort_by: str = Query("detected_at", description="Trier par"), sort_by: str = Query("detected_at", description="Trier par"),
sort_order: str = Query("DESC", description="Ordre (ASC/DESC)") sort_order: str = Query("DESC", description="Ordre (ASC/DESC)"),
group_by_ip: bool = Query(False, description="Grouper par IP (first_seen/last_seen agrégés)")
): ):
""" """
Récupère la liste des détections avec pagination et filtres Récupère la liste des détections avec pagination et filtres
@ -47,7 +48,7 @@ async def get_detections(
if search: if search:
where_clauses.append( where_clauses.append(
"(src_ip ILIKE %(search)s OR ja4 ILIKE %(search)s OR host ILIKE %(search)s)" "(ilike(toString(src_ip), %(search)s) OR ilike(ja4, %(search)s) OR ilike(host, %(search)s))"
) )
params["search"] = f"%{search}%" params["search"] = f"%{search}%"
@ -66,6 +67,124 @@ async def get_detections(
# Requête principale # Requête principale
offset = (page - 1) * page_size offset = (page - 1) * page_size
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
# ── Mode groupé par IP (first_seen / last_seen depuis la DB) ────────────
if group_by_ip:
valid_sort_grouped = ["anomaly_score", "hits", "hit_velocity", "first_seen", "last_seen", "src_ip", "detected_at"]
grouped_sort = sort_by if sort_by in valid_sort_grouped else "last_seen"
# detected_at → last_seen (max(detected_at) dans le GROUP BY)
if grouped_sort == "detected_at":
grouped_sort = "last_seen"
# In outer query, min_score is exposed as anomaly_score — keep the alias
outer_sort = "min_score" if grouped_sort == "anomaly_score" else grouped_sort
# Count distinct IPs
count_ip_query = f"""
SELECT uniq(src_ip)
FROM ml_detected_anomalies
WHERE {where_clause}
"""
cr = db.query(count_ip_query, params)
total = cr.result_rows[0][0] if cr.result_rows else 0
grouped_query = f"""
SELECT
ip_data.src_ip,
ip_data.first_seen,
ip_data.last_seen,
ip_data.detection_count,
ip_data.unique_ja4s,
ip_data.unique_hosts,
ip_data.min_score AS anomaly_score,
ip_data.threat_level,
ip_data.model_name,
ip_data.country_code,
ip_data.asn_number,
ip_data.asn_org,
ip_data.hit_velocity,
ip_data.hits,
ip_data.asn_label,
ar.label AS asn_rep_label
FROM (
SELECT
src_ip,
min(detected_at) AS first_seen,
max(detected_at) AS last_seen,
count() AS detection_count,
groupUniqArray(5)(ja4) AS unique_ja4s,
groupUniqArray(5)(host) AS unique_hosts,
min(anomaly_score) AS min_score,
argMin(threat_level, anomaly_score) AS threat_level,
argMin(model_name, anomaly_score) AS model_name,
any(country_code) AS country_code,
any(asn_number) AS asn_number,
any(asn_org) AS asn_org,
max(hit_velocity) AS hit_velocity,
sum(hits) AS hits,
any(asn_label) AS asn_label
FROM ml_detected_anomalies
WHERE {where_clause}
GROUP BY src_ip
) ip_data
LEFT JOIN mabase_prod.asn_reputation ar
ON ar.src_asn = toUInt32OrZero(ip_data.asn_number)
ORDER BY {outer_sort} {sort_order}
LIMIT %(limit)s OFFSET %(offset)s
"""
params["limit"] = page_size
params["offset"] = offset
gresult = db.query(grouped_query, params)
def _label_to_score(label: str) -> float | None:
if not label: return None
mapping = {'human': 0.9, 'bot': 0.05, 'proxy': 0.25, 'vpn': 0.3,
'tor': 0.1, 'datacenter': 0.4, 'scanner': 0.05, 'malicious': 0.05}
return mapping.get(label.lower(), 0.5)
detections = []
for row in gresult.result_rows:
# row: src_ip, first_seen, last_seen, detection_count, unique_ja4s, unique_hosts,
# anomaly_score, threat_level, model_name, country_code, asn_number, asn_org,
# hit_velocity, hits, asn_label, asn_rep_label
ja4s = list(row[4]) if row[4] else []
hosts = list(row[5]) if row[5] else []
detections.append(Detection(
detected_at=row[1],
src_ip=str(row[0]),
ja4=ja4s[0] if ja4s else "",
host=hosts[0] if hosts else "",
bot_name="",
anomaly_score=float(row[6]) if row[6] else 0.0,
threat_level=row[7] or "LOW",
model_name=row[8] or "",
recurrence=int(row[3] or 0),
asn_number=str(row[10]) if row[10] else "",
asn_org=row[11] or "",
asn_detail="",
asn_domain="",
country_code=row[9] or "",
asn_label=row[14] or "",
hits=int(row[13] or 0),
hit_velocity=float(row[12]) if row[12] else 0.0,
fuzzing_index=0.0,
post_ratio=0.0,
reason="",
asn_rep_label=row[15] or "",
asn_score=_label_to_score(row[15] or ""),
first_seen=row[1],
last_seen=row[2],
unique_ja4s=ja4s,
unique_hosts=hosts,
))
total_pages = (total + page_size - 1) // page_size
return DetectionsListResponse(
items=detections, total=total, page=page,
page_size=page_size, total_pages=total_pages
)
# ── Mode individuel (comportement original) ──────────────────────────────
# Validation du tri # Validation du tri
valid_sort_columns = [ valid_sort_columns = [
"detected_at", "src_ip", "threat_level", "anomaly_score", "detected_at", "src_ip", "threat_level", "anomaly_score",
@ -74,8 +193,6 @@ async def get_detections(
if sort_by not in valid_sort_columns: if sort_by not in valid_sort_columns:
sort_by = "detected_at" sort_by = "detected_at"
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
main_query = f""" main_query = f"""
SELECT SELECT
detected_at, detected_at,

View File

@ -33,8 +33,8 @@ async def get_top_anomalies(limit: int = Query(50, ge=1, le=500)):
any(a.ja4) AS ja4, any(a.ja4) AS ja4,
any(a.host) AS host, any(a.host) AS host,
sum(a.hits) AS hits, sum(a.hits) AS hits,
round(max(uniqMerge(a.uniq_query_params)) round(uniqMerge(a.uniq_query_params)
/ greatest(max(uniqMerge(a.uniq_paths)), 1), 4) AS fuzzing_index, / greatest(uniqMerge(a.uniq_paths), 1), 4) AS fuzzing_index,
round(sum(a.hits) round(sum(a.hits)
/ greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity, / greatest(dateDiff('second', min(a.first_seen), max(a.last_seen)), 1), 2) AS hit_velocity,
round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio, round(sum(a.count_head) / greatest(sum(a.hits), 1), 4) AS head_ratio,
@ -377,17 +377,28 @@ async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
"""Points scatter plot (fuzzing_index × hit_velocity) — bypass view_ai_features_1h.""" """Points scatter plot (fuzzing_index × hit_velocity) — bypass view_ai_features_1h."""
try: try:
sql = """ sql = """
SELECT
ip,
ja4,
round(fuzzing_index, 4) AS fuzzing_index,
round(total_hits / greatest(dateDiff('second', min_first, max_last), 1), 2) AS hit_velocity,
total_hits AS hits,
round(total_count_head / greatest(total_hits, 1), 4) AS head_ratio,
correlated
FROM (
SELECT SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip, replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(ja4) AS ja4, any(ja4) AS ja4,
round(max(uniqMerge(uniq_query_params)) / greatest(max(uniqMerge(uniq_paths)), 1), 4) AS fuzzing_index, uniqMerge(uniq_query_params) / greatest(uniqMerge(uniq_paths), 1) AS fuzzing_index,
round(sum(hits) / greatest(dateDiff('second', min(first_seen), max(last_seen)), 1), 2) AS hit_velocity, sum(hits) AS total_hits,
sum(hits) AS hits, min(first_seen) AS min_first,
round(sum(count_head) / greatest(sum(hits), 1), 4) AS head_ratio, max(last_seen) AS max_last,
sum(count_head) AS total_count_head,
max(correlated_raw) AS correlated max(correlated_raw) AS correlated
FROM mabase_prod.agg_host_ip_ja4_1h FROM mabase_prod.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR WHERE window_start >= now() - INTERVAL 24 HOUR
GROUP BY src_ip GROUP BY src_ip
)
ORDER BY fuzzing_index DESC ORDER BY fuzzing_index DESC
LIMIT %(limit)s LIMIT %(limit)s
""" """

129
backend/routes/search.py Normal file
View File

@ -0,0 +1,129 @@
"""
Endpoint de recherche globale rapide — utilisé par la barre Cmd+K
"""
from fastapi import APIRouter, Query
from ..database import db
router = APIRouter(prefix="/api/search", tags=["search"])
IP_RE = r"^(\d{1,3}\.){0,3}\d{1,3}$"
@router.get("/quick")
async def quick_search(q: str = Query(..., min_length=1, max_length=100)):
"""
Recherche unifiée sur IPs, JA4, ASN, hosts.
Retourne jusqu'à 5 résultats par catégorie.
"""
q = q.strip()
pattern = f"%{q}%"
results = []
# ── IPs ──────────────────────────────────────────────────────────────────
ip_rows = db.query(
"""
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
count() AS hits,
max(detected_at) AS last_seen,
any(threat_level) AS threat_level
FROM ml_detected_anomalies
WHERE ilike(toString(src_ip), %(p)s)
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY clean_ip
ORDER BY hits DESC
LIMIT 5
""",
{"p": pattern},
)
for r in ip_rows.result_rows:
ip = str(r[0])
results.append({
"type": "ip",
"value": ip,
"label": ip,
"meta": f"{r[1]} détections · {r[3]}",
"url": f"/detections/ip/{ip}",
"investigation_url": f"/investigation/{ip}",
})
# ── JA4 fingerprints ─────────────────────────────────────────────────────
ja4_rows = db.query(
"""
SELECT
ja4,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM ml_detected_anomalies
WHERE ilike(ja4, %(p)s)
AND ja4 != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY ja4
ORDER BY hits DESC
LIMIT 5
""",
{"p": pattern},
)
for r in ja4_rows.result_rows:
results.append({
"type": "ja4",
"value": str(r[0]),
"label": str(r[0]),
"meta": f"{r[1]} détections · {r[2]} IPs",
"url": f"/investigation/ja4/{r[0]}",
})
# ── Hosts ─────────────────────────────────────────────────────────────────
host_rows = db.query(
"""
SELECT
host,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM ml_detected_anomalies
WHERE ilike(host, %(p)s)
AND host != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY host
ORDER BY hits DESC
LIMIT 5
""",
{"p": pattern},
)
for r in host_rows.result_rows:
results.append({
"type": "host",
"value": str(r[0]),
"label": str(r[0]),
"meta": f"{r[1]} hits · {r[2]} IPs",
"url": f"/detections?search={r[0]}",
})
# ── ASN ───────────────────────────────────────────────────────────────────
asn_rows = db.query(
"""
SELECT
asn_org,
asn_number,
count() AS hits,
uniq(src_ip) AS unique_ips
FROM ml_detected_anomalies
WHERE (ilike(asn_org, %(p)s) OR ilike(asn_number, %(p)s))
AND asn_org != '' AND asn_number != ''
AND detected_at >= now() - INTERVAL 24 HOUR
GROUP BY asn_org, asn_number
ORDER BY hits DESC
LIMIT 5
""",
{"p": pattern},
)
for r in asn_rows.result_rows:
results.append({
"type": "asn",
"value": str(r[1]),
"label": f"AS{r[1]}{r[0]}",
"meta": f"{r[2]} hits · {r[3]} IPs",
"url": f"/detections?asn={r[1]}",
})
return {"query": q, "results": results}

View File

@ -44,17 +44,22 @@ async def get_associated_ips(
column = type_column_map[attr_type] column = type_column_map[attr_type]
query = f""" query = f"""
SELECT DISTINCT src_ip SELECT src_ip, count() AS hit_count
FROM ml_detected_anomalies FROM ml_detected_anomalies
WHERE {column} = %(value)s WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR AND detected_at >= now() - INTERVAL 24 HOUR
ORDER BY src_ip GROUP BY src_ip
ORDER BY hit_count DESC
LIMIT %(limit)s LIMIT %(limit)s
""" """
result = db.query(query, {"value": value, "limit": limit}) result = db.query(query, {"value": value, "limit": limit})
ips = [str(row[0]) for row in result.result_rows] total_hits = sum(row[1] for row in result.result_rows) or 1
ips = [
{"ip": str(row[0]), "count": row[1], "percentage": round(row[1] * 100.0 / total_hits, 2)}
for row in result.result_rows
]
# Compter le total # Compter le total
count_query = f""" count_query = f"""
@ -491,23 +496,59 @@ async def get_variability(attr_type: str, value: str):
first_seen = stats_row[2] first_seen = stats_row[2]
last_seen = stats_row[3] last_seen = stats_row[3]
# User-Agents via view_dashboard_user_agents (source principale pour les UAs) # User-Agents depuis http_logs pour des comptes exacts par requête
# Colonnes disponibles: src_ip, ja4, hour, log_date, user_agents, requests # (view_dashboard_user_agents déduplique par heure, ce qui sous-compte les hits)
if attr_type == "ip":
_ua_where = "toString(src_ip) = %(value)s"
_ua_params: dict = {"value": value} _ua_params: dict = {"value": value}
if attr_type == "ip":
_ua_logs_where = "src_ip = toIPv4(%(value)s)"
ua_query_simple = f"""
SELECT
header_user_agent AS user_agent,
count() AS count,
round(count() * 100.0 / (
SELECT count() FROM mabase_prod.http_logs
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
), 2) AS percentage,
min(time) AS first_seen,
max(time) AS last_seen
FROM mabase_prod.http_logs
WHERE {_ua_logs_where}
AND time >= now() - INTERVAL 24 HOUR
AND header_user_agent != '' AND header_user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY count DESC
"""
ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
elif attr_type == "ja4": elif attr_type == "ja4":
_ua_where = "ja4 = %(value)s" _ua_logs_where = "ja4 = %(value)s"
_ua_params = {"value": value} ua_query_simple = f"""
SELECT
header_user_agent AS user_agent,
count() AS count,
round(count() * 100.0 / (
SELECT count() FROM mabase_prod.http_logs
WHERE {_ua_logs_where} AND time >= now() - INTERVAL 24 HOUR
), 2) AS percentage,
min(time) AS first_seen,
max(time) AS last_seen
FROM mabase_prod.http_logs
WHERE {_ua_logs_where}
AND time >= now() - INTERVAL 24 HOUR
AND header_user_agent != '' AND header_user_agent IS NOT NULL
GROUP BY user_agent
ORDER BY count DESC
LIMIT 20
"""
ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]
else: else:
# country / asn / host: pivot via ml_detected_anomalies → IPs # country / asn / host: pivot via ml_detected_anomalies → IPs, puis view UA
_ua_where = f"""toString(src_ip) IN ( _ua_where = f"""toString(src_ip) IN (
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '') SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
FROM ml_detected_anomalies FROM ml_detected_anomalies
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
)""" )"""
_ua_params = {"value": value}
ua_query_simple = f""" ua_query_simple = f"""
SELECT SELECT
ua AS user_agent, ua AS user_agent,
@ -522,9 +563,8 @@ async def get_variability(attr_type: str, value: str):
AND ua != '' AND ua != ''
GROUP BY user_agent GROUP BY user_agent
ORDER BY count DESC ORDER BY count DESC
LIMIT 10 LIMIT 20
""" """
ua_result = db.query(ua_query_simple, _ua_params) ua_result = db.query(ua_query_simple, _ua_params)
user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows] user_agents = [get_attribute_value(row, 1, 2, 3, 4) for row in ua_result.result_rows]

View File

@ -137,6 +137,7 @@ export const detectionsApi = {
search?: string; search?: string;
sort_by?: string; sort_by?: string;
sort_order?: string; sort_order?: string;
group_by_ip?: boolean;
}) => api.get<DetectionsListResponse>('/detections', { params }), }) => api.get<DetectionsListResponse>('/detections', { params }),
getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`), getDetails: (id: string) => api.get(`/detections/${encodeURIComponent(id)}`),

View File

@ -10,21 +10,21 @@ export function DetailsView() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64 text-text-secondary">
<div className="text-text-secondary">Chargement...</div> Chargement
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="bg-threat-critical_bg border border-threat-critical rounded-lg p-4"> <div className="bg-threat-critical_bg border border-threat-critical rounded-xl p-6">
<p className="text-threat-critical">Erreur: {error.message}</p> <p className="text-threat-critical font-semibold mb-4">Erreur : {error.message}</p>
<button <button
onClick={() => navigate('/detections')} onClick={() => navigate('/detections')}
className="mt-4 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg transition-colors" className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm"
> >
Retour aux détections Retour
</button> </button>
</div> </div>
); );
@ -32,141 +32,124 @@ export function DetailsView() {
if (!data) return null; if (!data) return null;
const typeLabels: Record<string, { label: string }> = { const typeLabels: Record<string, string> = {
ip: { label: 'IP' }, ip: 'IP',
ja4: { label: 'JA4' }, ja4: 'JA4',
country: { label: 'Pays' }, country: 'Pays',
asn: { label: 'ASN' }, asn: 'ASN',
host: { label: 'Host' }, host: 'Host',
user_agent: { label: 'User-Agent' }, user_agent: 'User-Agent',
}; };
const typeLabel = typeLabels[type || ''] || type;
const isIP = type === 'ip';
const isJA4 = type === 'ja4';
const typeInfo = typeLabels[type || ''] || { label: type }; const first = data.date_range.first_seen ? new Date(data.date_range.first_seen) : null;
const last = data.date_range.last_seen ? new Date(data.date_range.last_seen) : null;
const sameDate = first && last && first.getTime() === last.getTime();
const fmtDate = (d: Date) =>
d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }) +
' ' +
d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-5 animate-fade-in">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-text-secondary"> <nav className="flex items-center gap-2 text-xs text-text-secondary">
<Link to="/" className="hover:text-text-primary transition-colors">Dashboard</Link> <Link to="/" className="hover:text-text-primary">Dashboard</Link>
<span>/</span> <span>/</span>
<Link to="/detections" className="hover:text-text-primary transition-colors">Détections</Link> <Link to="/detections" className="hover:text-text-primary">Détections</Link>
<span>/</span> <span>/</span>
<span className="text-text-primary">{typeInfo.label}: {value}</span> <span className="text-text-primary">{typeLabel}: {value}</span>
</nav> </nav>
{/* En-tête */} {/* Header card */}
<div className="bg-background-secondary rounded-lg p-6"> <div className="bg-background-secondary rounded-xl p-5">
<div className="flex items-start justify-between"> <div className="flex flex-wrap items-start justify-between gap-4">
{/* Identité */}
<div> <div>
<h1 className="text-2xl font-bold text-text-primary mb-2"> <p className="text-xs font-semibold text-text-secondary uppercase tracking-wider mb-1">{typeLabel}</p>
{typeInfo.label} <p className="text-lg font-mono font-bold text-text-primary break-all">{value}</p>
</h1>
<p className="font-mono text-text-secondary break-all">{value}</p>
</div> </div>
<div className="text-right">
<div className="text-3xl font-bold text-text-primary">{data.total_detections}</div> {/* Actions */}
<div className="text-text-secondary text-sm">détections (24h)</div> <div className="flex flex-wrap gap-2">
{type === 'ip' && value && ( {isIP && (
<button <button
onClick={() => navigate(`/investigation/${encodeURIComponent(value)}`)} onClick={() => navigate(`/investigation/${encodeURIComponent(value!)}`)}
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors" className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
> >
🔍 Investigation complète 🔍 Investigation complète
</button> </button>
)} )}
{type === 'ja4' && value && ( {isJA4 && (
<button <button
onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value)}`)} onClick={() => navigate(`/investigation/ja4/${encodeURIComponent(value!)}`)}
className="mt-2 bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm transition-colors" className="bg-accent-primary hover:bg-accent-primary/80 text-white px-4 py-2 rounded-lg text-sm font-medium"
> >
🔍 Investigation JA4 🔍 Investigation JA4
</button> </button>
)} )}
</div>
</div>
{/* Stats rapides */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<StatBox
label="IPs Uniques"
value={data.unique_ips.toLocaleString()}
/>
<StatBox
label="Première détection"
value={formatDate(data.date_range.first_seen)}
/>
<StatBox
label="Dernière détection"
value={formatDate(data.date_range.last_seen)}
/>
<StatBox
label="User-Agents"
value={data.attributes.user_agents.length.toString()}
/>
</div>
</div>
{/* Insights + Variabilité côte à côte */}
<div className="grid grid-cols-3 gap-6 items-start">
{data.insights.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold text-text-primary">Insights</h2>
{data.insights.map((insight, i) => (
<InsightCard key={i} insight={insight} />
))}
</div>
)}
<div className={data.insights.length > 0 ? 'col-span-2' : 'col-span-3'}>
<VariabilityPanel attributes={data.attributes} />
</div>
</div>
{/* Bouton retour */}
<div className="flex justify-center">
<button <button
onClick={() => navigate('/detections')} onClick={() => navigate('/detections')}
className="bg-background-card hover:bg-background-card/80 text-text-primary px-6 py-3 rounded-lg transition-colors" className="bg-background-card hover:bg-background-card/70 text-text-primary px-4 py-2 rounded-lg text-sm"
> >
Retour aux détections Retour
</button> </button>
</div> </div>
</div> </div>
);
}
// Composant StatBox {/* Métriques clés */}
function StatBox({ label, value }: { label: string; value: string }) { <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-5">
return ( <Metric label="Détections (24h)" value={data.total_detections.toLocaleString()} accent />
<div className="bg-background-card rounded-lg p-4"> {!isIP && (
<div className="text-xl font-bold text-text-primary">{value}</div> <Metric label="IPs uniques" value={data.unique_ips.toLocaleString()} />
<div className="text-text-secondary text-xs">{label}</div> )}
<Metric label="User-Agents" value={(data.attributes.user_agents?.length ?? 0).toString()} />
{first && last && (
sameDate ? (
<Metric label="Détecté le" value={fmtDate(last)} />
) : (
<div className="bg-background-card rounded-xl p-3">
<p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">Période</p>
<p className="text-xs text-text-primary font-medium">{fmtDate(first)}</p>
<p className="text-[10px] text-text-secondary"> {fmtDate(last)}</p>
</div>
)
)}
</div>
</div> </div>
);
}
// Composant InsightCard {/* Insights */}
function InsightCard({ insight }: { insight: { type: string; message: string } }) { {data.insights.length > 0 && (
const styles: Record<string, string> = { <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
warning: 'bg-yellow-500/10 border-yellow-500/50 text-yellow-500', {data.insights.map((ins, i) => {
info: 'bg-blue-500/10 border-blue-500/50 text-blue-400', const s: Record<string, string> = {
success: 'bg-green-500/10 border-green-500/50 text-green-400', warning: 'bg-yellow-500/10 border-yellow-500/40 text-yellow-400',
info: 'bg-blue-500/10 border-blue-500/40 text-blue-400',
success: 'bg-green-500/10 border-green-500/40 text-green-400',
}; };
return ( return (
<div className={`${styles[insight.type] || styles.info} border rounded-lg p-4`}> <div key={i} className={`${s[ins.type] ?? s.info} border rounded-xl p-3 text-sm`}>
<span>{insight.message}</span> {ins.message}
</div>
);
})}
</div>
)}
{/* Attributs */}
<VariabilityPanel attributes={data.attributes} hideAssociatedIPs={isIP} />
</div> </div>
); );
} }
// Helper pour formater la date function Metric({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
function formatDate(dateStr: string): string { return (
const date = new Date(dateStr); <div className="bg-background-card rounded-xl p-3">
return date.toLocaleDateString('fr-FR', { <p className="text-[10px] font-semibold text-text-secondary uppercase tracking-wider mb-1">{label}</p>
day: '2-digit', <p className={`text-xl font-bold ${accent ? 'text-accent-primary' : 'text-text-primary'}`}>{value}</p>
month: '2-digit', </div>
hour: '2-digit', );
minute: '2-digit'
});
} }

View File

@ -42,8 +42,10 @@ export function DetectionsList() {
const page = parseInt(searchParams.get('page') || '1'); const page = parseInt(searchParams.get('page') || '1');
const modelName = searchParams.get('model_name') || undefined; const modelName = searchParams.get('model_name') || undefined;
const search = searchParams.get('search') || undefined; const search = searchParams.get('search') || undefined;
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'anomaly_score') as SortField; const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'asc') as SortOrder; const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
const [groupByIP, setGroupByIP] = useState(true);
const { data, loading, error } = useDetections({ const { data, loading, error } = useDetections({
page, page,
@ -52,13 +54,11 @@ export function DetectionsList() {
search, search,
sort_by: sortField, sort_by: sortField,
sort_order: sortOrder, sort_order: sortOrder,
group_by_ip: groupByIP,
}); });
const [searchInput, setSearchInput] = useState(search || ''); const [searchInput, setSearchInput] = useState(search || '');
const [showColumnSelector, setShowColumnSelector] = useState(false); const [showColumnSelector, setShowColumnSelector] = useState(false);
const [groupByIP, setGroupByIP] = useState(true); // Grouper par IP par défaut
// Configuration des colonnes
const [columns, setColumns] = useState<ColumnConfig[]>([ const [columns, setColumns] = useState<ColumnConfig[]>([
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true }, { key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
{ key: 'host', label: 'Host', visible: true, sortable: true }, { key: 'host', label: 'Host', visible: true, sortable: true },
@ -107,6 +107,14 @@ export function DetectionsList() {
setSearchParams(newParams); setSearchParams(newParams);
}; };
const handleSort = (key: string, dir: 'asc' | 'desc') => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort_by', key);
newParams.set('sort_order', dir);
newParams.set('page', '1');
setSearchParams(newParams);
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@ -125,59 +133,8 @@ export function DetectionsList() {
if (!data) return null; if (!data) return null;
// Traiter les données pour le regroupement par IP // Backend handles grouping — data is already grouped when groupByIP=true
const processedData = (() => { const processedData = data;
if (!groupByIP) {
return data;
}
// Grouper par IP
const ipGroups = new Map<string, typeof data.items[0]>();
const ipStats = new Map<string, {
first: Date;
last: Date;
count: number;
ja4s: Set<string>;
hosts: Set<string>;
clientHeaders: Set<string>;
}>();
data.items.forEach(item => {
if (!ipGroups.has(item.src_ip)) {
ipGroups.set(item.src_ip, item);
ipStats.set(item.src_ip, {
first: new Date(item.detected_at),
last: new Date(item.detected_at),
count: 1,
ja4s: new Set([item.ja4 || '']),
hosts: new Set([item.host || '']),
clientHeaders: new Set([item.client_headers || ''])
});
} else {
const stats = ipStats.get(item.src_ip)!;
const itemDate = new Date(item.detected_at);
if (itemDate < stats.first) stats.first = itemDate;
if (itemDate > stats.last) stats.last = itemDate;
stats.count++;
if (item.ja4) stats.ja4s.add(item.ja4);
if (item.host) stats.hosts.add(item.host);
if (item.client_headers) stats.clientHeaders.add(item.client_headers);
}
});
return {
...data,
items: Array.from(ipGroups.values()).map(item => ({
...item,
hits: ipStats.get(item.src_ip)!.count,
first_seen: ipStats.get(item.src_ip)!.first.toISOString(),
last_seen: ipStats.get(item.src_ip)!.last.toISOString(),
unique_ja4s: Array.from(ipStats.get(item.src_ip)!.ja4s),
unique_hosts: Array.from(ipStats.get(item.src_ip)!.hosts),
unique_client_headers: Array.from(ipStats.get(item.src_ip)!.clientHeaders)
}))
};
})();
// Build DataTable columns from visible column configs // Build DataTable columns from visible column configs
const tableColumns: Column<DetectionRow>[] = columns const tableColumns: Column<DetectionRow>[] = columns
@ -352,20 +309,25 @@ export function DetectionsList() {
label: col.label, label: col.label,
sortable: true, sortable: true,
render: (_, row) => render: (_, row) =>
groupByIP && row.first_seen ? ( groupByIP && row.first_seen ? (() => {
const first = new Date(row.first_seen!);
const last = new Date(row.last_seen!);
const sameTime = first.getTime() === last.getTime();
const fmt = (d: Date) =>
`${d.toLocaleDateString('fr-FR')} ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
return sameTime ? (
<div className="text-xs text-text-secondary">{fmt(last)}</div>
) : (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs text-text-secondary"> <div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span>{' '} <span className="font-medium">Premier:</span> {fmt(first)}
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div> </div>
<div className="text-xs text-text-secondary"> <div className="text-xs text-text-secondary">
<span className="font-medium">Dernier:</span>{' '} <span className="font-medium">Dernier:</span> {fmt(last)}
{new Date(row.last_seen!).toLocaleDateString('fr-FR')}{' '}
{new Date(row.last_seen!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</div> </div>
</div> </div>
) : ( );
})() : (
<> <>
<div className="text-sm text-text-primary"> <div className="text-sm text-text-primary">
{new Date(row.detected_at).toLocaleDateString('fr-FR')} {new Date(row.detected_at).toLocaleDateString('fr-FR')}
@ -388,7 +350,7 @@ export function DetectionsList() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-text-primary">Détections</h1> <h1 className="text-2xl font-bold text-text-primary">Détections</h1>
<div className="flex items-center gap-2 text-sm text-text-secondary"> <div className="flex items-center gap-2 text-sm text-text-secondary">
<span>{groupByIP ? processedData.items.length : data.items.length}</span> <span>{data.items.length}</span>
<span></span> <span></span>
<span>{data.total} détections</span> <span>{data.total} détections</span>
</div> </div>
@ -403,8 +365,9 @@ export function DetectionsList() {
? 'bg-accent-primary text-white border-accent-primary' ? 'bg-accent-primary text-white border-accent-primary'
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary' : 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
}`} }`}
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
> >
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'} {groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
</button> </button>
{/* Sélecteur de colonnes */} {/* Sélecteur de colonnes */}
@ -486,7 +449,9 @@ export function DetectionsList() {
data={processedData.items as DetectionRow[]} data={processedData.items as DetectionRow[]}
columns={tableColumns} columns={tableColumns}
rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`} rowKey={(row) => `${row.src_ip}-${row.detected_at}-${groupByIP ? 'g' : 'i'}`}
defaultSortKey="anomaly_score" defaultSortKey={sortField}
defaultSortDir={sortOrder}
onSort={handleSort}
onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)} onRowClick={(row) => navigate(`/detections/ip/${encodeURIComponent(row.src_ip)}`)}
emptyMessage="Aucune détection trouvée" emptyMessage="Aucune détection trouvée"
compact compact

View File

@ -975,15 +975,15 @@ export function FingerprintsView() {
const data = await res.json(); const data = await res.json();
const items: JA4AttributeItem[] = data.items || []; const items: JA4AttributeItem[] = data.items || [];
setJa4List(items); setJa4List(items);
setLoadingList(false); // afficher la liste immédiatement
// Auto-enrich top 30 by count // Enrichissement variability en arrière-plan (sans bloquer le rendu)
const top30 = items const top30 = items
.slice() .slice()
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
.slice(0, 30); .slice(0, 30);
await Promise.all( top30.forEach(async (item) => {
top30.map(async (item) => {
try { try {
const vRes = await fetch( const vRes = await fetch(
`/api/variability/ja4/${encodeURIComponent(item.value)}` `/api/variability/ja4/${encodeURIComponent(item.value)}`
@ -994,11 +994,9 @@ export function FingerprintsView() {
} catch { } catch {
// ignore individual errors // ignore individual errors
} }
}) });
);
} catch (err) { } catch (err) {
console.error('FingerprintsView:', err); console.error('FingerprintsView:', err);
} finally {
setLoadingList(false); setLoadingList(false);
} }
}; };
@ -1434,7 +1432,8 @@ interface PersistentThreat {
interface JA4HistoryEntry { interface JA4HistoryEntry {
ja4: string; ja4: string;
hits: number; hits: number;
window_start: string; first_seen: string;
last_seen: string;
} }
interface SophisticationItem { interface SophisticationItem {
@ -1544,7 +1543,7 @@ function RotatorRow({ item }: { item: JA4Rotator }) {
<div key={idx} className="flex items-center gap-3 text-xs"> <div key={idx} className="flex items-center gap-3 text-xs">
<span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span> <span className="font-mono text-text-primary bg-background-secondary border border-border rounded px-2 py-0.5">{entry.ja4}</span>
<span className="text-text-secondary">{formatNumber(entry.hits)} hits</span> <span className="text-text-secondary">{formatNumber(entry.hits)} hits</span>
<span className="text-text-disabled">{formatDate(entry.window_start)}</span> <span className="text-text-disabled">{formatDate(entry.first_seen)} {formatDate(entry.last_seen)}</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DataTable, { Column } from './ui/DataTable'; import DataTable, { Column } from './ui/DataTable';
@ -88,6 +88,7 @@ export function HeaderFingerprintView() {
const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({}); const [clusterIPsMap, setClusterIPsMap] = useState<Record<string, ClusterIP[]>>({});
const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set()); const [loadingHashes, setLoadingHashes] = useState<Set<string>>(new Set());
const [ipErrors, setIpErrors] = useState<Record<string, string>>({}); const [ipErrors, setIpErrors] = useState<Record<string, string>>({});
const expandedPanelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const fetchClusters = async () => { const fetchClusters = async () => {
@ -113,6 +114,7 @@ export function HeaderFingerprintView() {
return; return;
} }
setExpandedHash(hash); setExpandedHash(hash);
setTimeout(() => expandedPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
if (clusterIPsMap[hash] !== undefined) return; if (clusterIPsMap[hash] !== undefined) return;
setLoadingHashes((prev) => new Set(prev).add(hash)); setLoadingHashes((prev) => new Set(prev).add(hash));
try { try {
@ -289,13 +291,14 @@ export function HeaderFingerprintView() {
loading={loading} loading={loading}
emptyMessage="Aucun cluster détecté" emptyMessage="Aucun cluster détecté"
compact compact
maxHeight="max-h-[480px]"
/> />
)} )}
</div> </div>
{/* Expanded IPs panel */} {/* Expanded IPs panel */}
{expandedHash && ( {expandedHash && (
<div className="bg-background-secondary rounded-lg border border-border overflow-hidden"> <div ref={expandedPanelRef} className="bg-background-secondary rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between"> <div className="px-4 py-3 border-b border-border flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary"> <span className="text-sm font-semibold text-text-primary">
IPs du cluster{' '} IPs du cluster{' '}

View File

@ -56,10 +56,10 @@ export function JA4InvestigationView() {
unique_ips: ipsData.total || 0, unique_ips: ipsData.total || 0,
first_seen: baseData.date_range?.first_seen || '', first_seen: baseData.date_range?.first_seen || '',
last_seen: baseData.date_range?.last_seen || '', last_seen: baseData.date_range?.last_seen || '',
top_ips: ipsData.ips?.slice(0, 10).map((ip: string) => ({ top_ips: ipsData.ips?.slice(0, 10).map((item: any) => ({
ip, ip: typeof item === 'string' ? item : item.ip,
count: 0, count: typeof item === 'string' ? 0 : (item.count || 0),
percentage: 0 percentage: typeof item === 'string' ? 0 : (item.percentage || 0)
})) || [], })) || [],
top_countries: countriesData.items?.map((item: any) => ({ top_countries: countriesData.items?.map((item: any) => ({
code: item.value, code: item.value,

View File

@ -2,66 +2,57 @@ import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
interface SearchResult { interface SearchResult {
type: 'ip' | 'ja4' | 'asn' | 'host' | 'user_agent'; type: 'ip' | 'ja4' | 'host' | 'asn';
value: string; value: string;
count?: number; label: string;
threat_level?: string; meta: string;
url: string;
investigation_url?: string;
} }
interface QuickSearchProps { interface QuickSearchProps {
onNavigate?: () => void; onNavigate?: () => void;
} }
const TYPE_ICON: Record<string, string> = { ip: '🌐', ja4: '🔏', host: '🖥️', asn: '🏢' };
const TYPE_COLOR: Record<string, string> = {
ip: 'bg-blue-500/20 text-blue-400',
ja4: 'bg-purple-500/20 text-purple-400',
host: 'bg-green-500/20 text-green-400',
asn: 'bg-orange-500/20 text-orange-400',
};
export function QuickSearch({ onNavigate }: QuickSearchProps) { export function QuickSearch({ onNavigate }: QuickSearchProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useNavigate(); const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Détection du type de recherche // Recherche via le nouvel endpoint unifié
const detectType = (value: string): 'ip' | 'ja4' | 'asn' | 'host' | 'other' => {
// IPv4 pattern
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value)) return 'ip';
// IPv6 pattern (simplified)
if (/^[0-9a-fA-F:]+$/.test(value) && value.includes(':')) return 'ip';
// JA4 pattern
if (/^t[0-9a-f]{2}[0-9a-f]{4}/.test(value)) return 'ja4';
// ASN pattern
if (/^AS?\d+$/i.test(value)) return 'asn';
// Host pattern
if (/\.[a-z]{2,}$/.test(value)) return 'host';
return 'other';
};
// Recherche automatique
useEffect(() => { useEffect(() => {
if (query.length < 3) { if (query.length < 2) { setResults([]); return; }
setResults([]);
return;
}
const timer = setTimeout(async () => { if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoading(true);
try { try {
const type = detectType(query); const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
const endpoint = type === 'other' ? 'ip' : type; if (res.ok) {
const response = await fetch(`/api/attributes/${endpoint}?limit=5`); const data = await res.json();
if (response.ok) { setResults(data.results || []);
const data = await response.json(); setSelectedIndex(-1);
const items = data.items || data || [];
setResults(Array.isArray(items) ? items : []);
} else {
setResults([]);
} }
} catch (error) { } catch {
console.error('Search error:', error);
setResults([]); setResults([]);
} finally {
setLoading(false);
} }
}, 300); }, 250);
return () => clearTimeout(timer);
}, [query]); }, [query]);
// Raccourci clavier Cmd+K // Raccourci clavier Cmd+K
@ -72,52 +63,39 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
inputRef.current?.focus(); inputRef.current?.focus();
setIsOpen(true); setIsOpen(true);
} }
if (e.key === 'Escape') { if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
setIsOpen(false);
setQuery('');
}
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
// Navigation au clavier // Navigation clavier dans les résultats
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return; if (!isOpen || results.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowDown') { if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault(); e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault();
handleSelect(results[selectedIndex]);
} }
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex]); }, [isOpen, results, selectedIndex]);
// Click outside // Click en dehors
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
setIsOpen(false);
}
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
const handleSelect = (result: SearchResult) => { const handleSelect = (result: SearchResult, useInvestigation = false) => {
const type = result.type || detectType(result.value); const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
navigate(`/investigate/${type}/${encodeURIComponent(result.value)}`); navigate(url);
setIsOpen(false); setIsOpen(false);
setQuery(''); setQuery('');
onNavigate?.(); onNavigate?.();
@ -125,53 +103,24 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (query.length >= 3) { if (results[0]) handleSelect(results[0]);
const type = detectType(query);
navigate(`/investigate/${type}/${encodeURIComponent(query)}`);
setIsOpen(false);
onNavigate?.();
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'ip': return '🌐';
case 'ja4': return '🔐';
case 'asn': return '🏢';
case 'host': return '🖥️';
case 'user_agent': return '🤖';
default: return '🔍';
}
};
const getTypeColor = (type: string) => {
switch (type) {
case 'ip': return 'bg-blue-500/20 text-blue-400';
case 'ja4': return 'bg-purple-500/20 text-purple-400';
case 'asn': return 'bg-orange-500/20 text-orange-400';
case 'host': return 'bg-green-500/20 text-green-400';
default: return 'bg-gray-500/20 text-gray-400';
}
}; };
return ( return (
<div ref={containerRef} className="relative w-full max-w-2xl"> <div ref={containerRef} className="relative w-full max-w-2xl">
{/* Search Bar */} {/* Barre de recherche */}
<form onSubmit={handleSubmit} className="relative"> <form onSubmit={handleSubmit} className="relative">
<div className="flex items-center bg-background-card border border-background-card rounded-lg focus:border-accent-primary transition-colors"> <div className="flex items-center bg-background-card border border-background-card rounded-lg focus-within:border-accent-primary transition-colors">
<span className="pl-4 text-text-secondary">🔍</span> <span className="pl-4 text-text-secondary">{loading ? <span className="animate-pulse"></span> : '🔍'}</span>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={query} value={query}
onChange={(e) => { onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
setQuery(e.target.value);
setIsOpen(true);
setSelectedIndex(-1);
}}
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)" placeholder="Rechercher IP, JA4, ASN, Host... (Cmd+K)"
className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none" className="flex-1 bg-transparent border-none px-4 py-3 text-text-primary placeholder-text-secondary focus:outline-none"
autoComplete="off"
/> />
<kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card"> <kbd className="hidden md:inline-flex items-center gap-1 px-2 py-1.5 mr-2 text-xs text-text-secondary bg-background-secondary rounded border border-background-card">
<span></span>K <span></span>K
@ -179,79 +128,52 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
</div> </div>
</form> </form>
{/* Results Dropdown */} {/* Dropdown résultats */}
{isOpen && (query.length >= 3) && ( {isOpen && query.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto"> <div className="absolute top-full left-0 right-0 mt-2 bg-background-secondary border border-background-card rounded-xl shadow-2xl z-50 max-h-96 overflow-y-auto">
{results.length > 0 ? ( {results.length > 0 ? (
<div className="py-2"> <ul className="py-1">
<div className="px-4 py-2 text-xs text-text-secondary border-b border-background-card"> {results.map((result, i) => (
Résultats suggérés <li key={`${result.type}-${result.value}-${i}`}>
</div>
{results.map((result, index) => (
<button <button
key={`${result.type}-${result.value}`}
onClick={() => handleSelect(result)} onClick={() => handleSelect(result)}
className={`w-full px-4 py-3 flex items-center gap-3 hover:bg-background-card transition-colors ${ className={[
index === selectedIndex ? 'bg-background-card' : '' 'w-full flex items-center gap-3 px-4 py-2.5 transition-colors text-left',
}`} i === selectedIndex ? 'bg-accent-primary/10 border-l-2 border-accent-primary' : 'hover:bg-background-card/50 border-l-2 border-transparent',
].join(' ')}
onMouseEnter={() => setSelectedIndex(i)}
> >
<span className="text-xl">{getTypeIcon(result.type)}</span> <span className="text-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
<div className="flex-1 text-left"> <div className="flex-1 min-w-0">
<div className="font-mono text-sm text-text-primary">{result.value}</div> <div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
<div className="text-xs text-text-secondary"> <div className="text-xs text-text-secondary">{result.meta}</div>
{result.type} {result.count && `${result.count} détections`}
</div> </div>
</div> <span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
<span className={`px-2 py-1 rounded text-xs ${getTypeColor(result.type)}`}>
{result.type.toUpperCase()} {result.type.toUpperCase()}
</span> </span>
</button> {result.investigation_url && (
))} <button
</div> className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
) : ( onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
<div className="px-4 py-8 text-center text-text-secondary"> title="Investigation complète"
<div className="text-2xl mb-2">🔍</div> ></button>
<div className="text-sm">
Tapez pour rechercher une IP, JA4, ASN, Host...
</div>
<div className="text-xs mt-2">
Appuyez sur Entrée pour rechercher "{query}"
</div>
</div>
)} )}
{/* Quick Actions */}
<div className="border-t border-background-card px-4 py-3">
<div className="text-xs text-text-secondary mb-2">Actions rapides</div>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => {
navigate('/incidents?threat_level=CRITICAL');
setIsOpen(false);
}}
className="px-3 py-1.5 bg-threat-critical/20 text-threat-critical rounded text-xs hover:bg-threat-critical/30 transition-colors"
>
🔴 Menaces Critiques
</button>
<button
onClick={() => {
navigate('/detections');
setIsOpen(false);
}}
className="px-3 py-1.5 bg-accent-primary/20 text-accent-primary rounded text-xs hover:bg-accent-primary/30 transition-colors"
>
🔍 Investigation avancée
</button>
<button
onClick={() => {
navigate('/threat-intel');
setIsOpen(false);
}}
className="px-3 py-1.5 bg-purple-500/20 text-purple-400 rounded text-xs hover:bg-purple-500/30 transition-colors"
>
📚 Threat Intel
</button> </button>
</li>
))}
</ul>
) : !loading ? (
<div className="px-4 py-6 text-center text-text-disabled text-sm">
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
</div> </div>
) : null}
{/* Hints */}
<div className="border-t border-background-card px-4 py-2 flex items-center gap-3 text-xs text-text-disabled">
<span><kbd className="bg-background-card px-1 rounded"></kbd> naviguer</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> ouvrir</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> investigation</span>
<span className="ml-auto opacity-60">24h</span>
</div> </div>
</div> </div>
)} )}

View File

@ -0,0 +1,182 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
interface SearchResult {
type: 'ip' | 'ja4' | 'host' | 'asn';
value: string;
label: string;
meta: string;
url: string;
investigation_url?: string;
}
const TYPE_ICON: Record<string, string> = {
ip: '🌐',
ja4: '🔏',
host: '🖥️',
asn: '🏢',
};
const TYPE_LABEL: Record<string, string> = {
ip: 'IP',
ja4: 'JA4',
host: 'Host',
asn: 'ASN',
};
interface SearchModalProps {
open: boolean;
onClose: () => void;
}
export default function SearchModal({ open, onClose }: SearchModalProps) {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Focus input when modal opens
useEffect(() => {
if (open) {
setQuery('');
setResults([]);
setSelected(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
const search = useCallback(async (q: string) => {
if (q.length < 2) { setResults([]); return; }
setLoading(true);
try {
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(q)}`);
if (!res.ok) return;
const data = await res.json();
setResults(data.results || []);
setSelected(0);
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
if (debounce.current) clearTimeout(debounce.current);
debounce.current = setTimeout(() => search(val), 200);
};
const go = (result: SearchResult, useInvestigation = false) => {
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
navigate(url);
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)); }
if (e.key === 'Enter' && results[selected]) {
go(results[selected], e.metaKey || e.ctrlKey);
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
{/* Modal */}
<div
className="relative w-full max-w-2xl bg-background-secondary border border-background-card rounded-2xl shadow-2xl overflow-hidden"
onClick={e => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
{/* Input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-background-card">
<span className="text-text-disabled text-lg">🔍</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
placeholder="Rechercher IP, JA4, host, ASN..."
className="flex-1 bg-transparent text-text-primary placeholder-text-disabled outline-none text-base"
autoComplete="off"
/>
{loading && (
<span className="text-text-disabled text-xs animate-pulse"></span>
)}
<kbd className="hidden sm:inline text-xs text-text-disabled bg-background-card px-1.5 py-0.5 rounded border border-border">
Esc
</kbd>
</div>
{/* Results */}
{results.length > 0 && (
<ul className="max-h-96 overflow-y-auto py-2">
{results.map((r, i) => (
<li key={`${r.type}-${r.value}-${i}`}>
<button
className={[
'w-full flex items-start gap-3 px-4 py-2.5 text-left transition-colors',
i === selected
? 'bg-accent-primary/10 border-l-2 border-accent-primary'
: 'hover:bg-background-card/50 border-l-2 border-transparent',
].join(' ')}
onMouseEnter={() => setSelected(i)}
onClick={() => go(r)}
>
<span className="mt-0.5 text-base">{TYPE_ICON[r.type]}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase text-text-disabled tracking-wider">
{TYPE_LABEL[r.type]}
</span>
<span className="font-mono text-sm text-text-primary truncate">{r.label}</span>
</div>
<div className="text-xs text-text-secondary mt-0.5">{r.meta}</div>
</div>
{r.investigation_url && (
<button
className="shrink-0 text-xs text-accent-primary hover:underline"
onClick={e => { e.stopPropagation(); go(r, true); }}
title="Ouvrir l'investigation complète"
>
Investigation
</button>
)}
</button>
</li>
))}
</ul>
)}
{/* Empty state */}
{query.length >= 2 && !loading && results.length === 0 && (
<div className="px-4 py-8 text-center text-text-disabled text-sm">
Aucun résultat pour <span className="font-mono text-text-secondary">"{query}"</span>
</div>
)}
{/* Footer hints */}
<div className="px-4 py-2 border-t border-background-card flex items-center gap-4 text-xs text-text-disabled">
<span><kbd className="bg-background-card px-1 rounded"></kbd> naviguer</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> ouvrir</span>
<span><kbd className="bg-background-card px-1 rounded"></kbd> investigation</span>
<span className="ml-auto">Recherche sur les 24 dernières heures</span>
</div>
</div>
</div>
);
}

View File

@ -41,7 +41,7 @@ export function ThreatIntelView() {
const statsResponse = await fetch('/api/analysis/classifications/stats'); const statsResponse = await fetch('/api/analysis/classifications/stats');
if (statsResponse.ok) { if (statsResponse.ok) {
const data = await statsResponse.json(); const data = await statsResponse.json();
setStats(data.items || []); setStats(data.stats || []);
} }
} catch (error) { } catch (error) {
console.error('Error fetching threat intel:', error); console.error('Error fetching threat intel:', error);
@ -248,7 +248,7 @@ export function ThreatIntelView() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}> <span className={`px-2 py-1 rounded text-xs font-bold ${getLabelColor(classification.label)}`}>
{getLabelIcon(classification.label)} {classification.label.toUpperCase()} {getLabelIcon(classification.label)} {(classification.label ?? '').toUpperCase()}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">

View File

@ -4,324 +4,255 @@ import { VariabilityAttributes, AttributeValue } from '../api/client';
interface VariabilityPanelProps { interface VariabilityPanelProps {
attributes: VariabilityAttributes; attributes: VariabilityAttributes;
/** When true, hides the "Voir IPs associées" button (e.g. when already on an IP page) */
hideAssociatedIPs?: boolean;
} }
export function VariabilityPanel({ attributes }: VariabilityPanelProps) { export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
const [showModal, setShowModal] = useState<{ const [modal, setModal] = useState<{
type: string;
title: string; title: string;
items: string[]; items: string[];
total: number; total: number;
} | null>(null); } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Fonction pour charger la liste des IPs associées
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => { const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`); const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
const data = await response.json(); const data = await res.json();
setShowModal({ setModal({
type: 'ips', title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
title: `${data.total || total} IPs associées à ${value}`,
items: data.ips || [], items: data.ips || [],
total: data.total || total, total: data.total || total,
}); });
} catch (error) { } catch {
console.error('Erreur chargement IPs:', error); // ignore
} }
setLoading(false); setLoading(false);
}; };
const sections: Array<{
title: string;
icon: string;
items: AttributeValue[] | undefined;
getLink: (v: AttributeValue) => string;
attrType?: string;
mono?: boolean;
}> = [
{
title: 'JA4 Fingerprints',
icon: '🔏',
items: attributes.ja4,
getLink: (v) => `/investigation/ja4/${encodeURIComponent(v.value)}`,
attrType: 'ja4',
mono: true,
},
{
title: 'Hosts ciblés',
icon: '🌐',
items: attributes.hosts,
getLink: (v) => `/detections/host/${encodeURIComponent(v.value)}`,
attrType: 'host',
mono: true,
},
{
title: 'ASN',
icon: '🏢',
items: attributes.asns,
getLink: (v) => {
const n = v.value.match(/AS(\d+)/)?.[1] || v.value;
return `/detections/asn/${encodeURIComponent(n)}`;
},
attrType: 'asn',
},
{
title: 'Pays',
icon: '🌍',
items: attributes.countries,
getLink: (v) => `/detections/country/${encodeURIComponent(v.value)}`,
attrType: 'country',
},
{
title: 'Niveaux de menace',
icon: '⚠️',
items: attributes.threat_levels,
getLink: (v) => `/detections?threat_level=${encodeURIComponent(v.value)}`,
},
];
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2> <h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
{/* JA4 Fingerprints */} {/* User-Agents — plein format avec texte long */}
{attributes.ja4 && attributes.ja4.length > 0 && (
<AttributeSection
title="JA4 Fingerprints"
items={attributes.ja4}
getValue={(item) => item.value}
getLink={(item) => `/investigation/ja4/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('ja4', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* User-Agents */}
{attributes.user_agents && attributes.user_agents.length > 0 && ( {attributes.user_agents && attributes.user_agents.length > 0 && (
<UASection items={attributes.user_agents} /> <UASection items={attributes.user_agents} />
)} )}
{/* Pays */} {/* Grille 2 colonnes pour les autres attributs */}
{attributes.countries && attributes.countries.length > 0 && ( <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sections.map((s) =>
s.items && s.items.length > 0 ? (
<AttributeSection <AttributeSection
title="Pays" key={s.title}
items={attributes.countries} title={s.title}
getValue={(item) => item.value} icon={s.icon}
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`} items={s.items}
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)} getLink={s.getLink}
showViewAll attrType={s.attrType}
viewAllLabel="Voir les IPs" mono={s.mono}
hideAssociatedIPs={hideAssociatedIPs}
onLoadIPs={loadAssociatedIPs}
/> />
) : null
)} )}
{/* ASN */}
{attributes.asns && attributes.asns.length > 0 && (
<AttributeSection
title="ASN"
items={attributes.asns}
getValue={(item) => item.value}
getLink={(item) => {
const asnNumber = item.value.match(/AS(\d+)/)?.[1] || item.value;
return `/detections/asn/${encodeURIComponent(asnNumber)}`;
}}
onViewAll={(value, count) => loadAssociatedIPs('asn', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Hosts */}
{attributes.hosts && attributes.hosts.length > 0 && (
<AttributeSection
title="Hosts"
items={attributes.hosts}
getValue={(item) => item.value}
getLink={(item) => `/detections/host/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('host', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Threat Levels */}
{attributes.threat_levels && attributes.threat_levels.length > 0 && (
<AttributeSection
title="Niveaux de Menace"
items={attributes.threat_levels}
getValue={(item) => item.value}
getLink={(item) => `/detections?threat_level=${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('threat_level', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Modal pour afficher la liste complète */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-background-secondary rounded-lg max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-background-card">
<h3 className="text-xl font-semibold text-text-primary">{showModal.title}</h3>
<button
onClick={() => setShowModal(null)}
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
>
×
</button>
</div> </div>
{/* Content */} {/* Modal IPs associées */}
<div className="flex-1 overflow-y-auto p-6"> {(modal || loading) && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-background-secondary rounded-xl max-w-2xl w-full max-h-[80vh] flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-background-card">
<h3 className="font-semibold text-text-primary">{modal?.title ?? 'Chargement…'}</h3>
<button onClick={() => setModal(null)} className="text-text-secondary hover:text-text-primary text-2xl leading-none">×</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{loading ? ( {loading ? (
<div className="text-center text-text-secondary py-8">Chargement...</div> <p className="text-center text-text-secondary py-8">Chargement</p>
) : showModal.items.length > 0 ? ( ) : modal && modal.items.length > 0 ? (
<div className="space-y-2"> <div className="grid grid-cols-2 gap-2">
{showModal.items.map((item, index) => ( {modal.items.map((ip, i) => (
<div <Link
key={index} key={i}
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all" to={`/detections/ip/${ip}`}
onClick={() => setModal(null)}
className="bg-background-card hover:bg-background-card/70 rounded px-3 py-2 font-mono text-sm text-accent-primary transition-colors"
> >
{item} {ip}
</div> </Link>
))} ))}
{showModal.total > showModal.items.length && (
<p className="text-center text-text-secondary text-sm mt-4">
Affichage de {showModal.items.length} sur {showModal.total} éléments
</p>
)}
</div> </div>
) : ( ) : (
<div className="text-center text-text-secondary py-8"> <p className="text-center text-text-secondary py-8">Aucune donnée</p>
Aucune donnée disponible
</div>
)} )}
</div> {modal && modal.total > modal.items.length && (
<p className="text-center text-text-secondary text-xs mt-4">
{/* Footer */} {modal.items.length} / {modal.total} affichées
<div className="p-4 border-t border-background-card text-right">
<button
onClick={() => setShowModal(null)}
className="bg-accent-primary hover:bg-accent-primary/80 text-white px-6 py-2 rounded-lg transition-colors"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Composant UASection — jamais de troncature, expand/collapse
function UASection({ items }: { items: AttributeValue[] }) {
const [showAll, setShowAll] = useState(false);
const INITIAL = 5;
const displayed = showAll ? items : items.slice(0, INITIAL);
return (
<div className="bg-background-secondary rounded-lg p-6">
<h3 className="text-lg font-medium text-text-primary mb-4">
User-Agents ({items.length})
</h3>
<div className="space-y-3">
{displayed.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-start justify-between gap-4">
<div className="text-text-primary font-medium text-xs font-mono break-all leading-relaxed flex-1">
{item.value}
</div>
<div className="text-right shrink-0">
<div className="text-text-primary font-medium">{item.count}</div>
<div className="text-text-secondary text-xs">{item.percentage?.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className="h-2 rounded-full bg-threat-medium transition-all"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{items.length > INITIAL && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
>
{showAll ? '↑ Réduire' : `↓ Voir les ${items.length - INITIAL} autres`}
</button>
)}
</div>
);
}
// Composant AttributeSection
function AttributeSection({
title,
items,
getValue,
getLink,
onViewAll,
showViewAll = false,
viewAllLabel = 'Voir les IPs',
}: {
title: string;
items: AttributeValue[];
getValue: (item: AttributeValue) => string;
getLink: (item: AttributeValue) => string;
onViewAll?: (value: string, count: number) => void;
showViewAll?: boolean;
viewAllLabel?: string;
}) {
const displayItems = items.slice(0, 10);
return (
<div className="bg-background-secondary rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-text-primary">
{title} ({items.length})
</h3>
{showViewAll && items.length > 0 && (
<select
onChange={(e) => {
if (e.target.value && onViewAll) {
const item = items.find(i => i.value === e.target.value);
if (item) {
onViewAll(item.value, item.count);
}
}
}}
defaultValue=""
className="bg-background-card border border-background-card rounded-lg px-3 py-1 text-sm text-text-primary focus:outline-none focus:border-accent-primary"
>
<option value="">{viewAllLabel}...</option>
{displayItems.map((item, idx) => (
<option key={idx} value={item.value}>
{getValue(item).substring(0, 40)}{getValue(item).length > 40 ? '...' : ''}
</option>
))}
</select>
)}
</div>
<div className="space-y-3">
{displayItems.map((item, index) => (
<AttributeRow
key={index}
value={item}
getValue={getValue}
getLink={getLink}
/>
))}
</div>
{items.length > 10 && (
<p className="text-text-secondary text-sm mt-4 text-center">
... et {items.length - 10} autres (top 10 affiché)
</p> </p>
)} )}
</div> </div>
<div className="px-6 py-4 border-t border-background-card text-right">
<button onClick={() => setModal(null)} className="bg-accent-primary hover:bg-accent-primary/80 text-white px-5 py-2 rounded-lg text-sm">Fermer</button>
</div>
</div>
</div>
)}
</div>
); );
} }
// Composant AttributeRow /* ─── AttributeSection ─────────────────────────────────────────────────────── */
function AttributeRow({ function AttributeSection({
value, title,
getValue, icon,
items,
getLink, getLink,
attrType,
mono,
hideAssociatedIPs,
onLoadIPs,
}: { }: {
value: AttributeValue; title: string;
getValue: (item: AttributeValue) => string; icon: string;
getLink: (item: AttributeValue) => string; items: AttributeValue[];
getLink: (v: AttributeValue) => string;
attrType?: string;
mono?: boolean;
hideAssociatedIPs?: boolean;
onLoadIPs: (type: string, value: string, count: number) => void;
}) { }) {
const percentage = value.percentage || 0; const [showAll, setShowAll] = useState(false);
const LIMIT = 8;
const displayed = showAll ? items : items.slice(0, LIMIT);
return ( return (
<div className="space-y-1"> <div className="bg-background-secondary rounded-xl p-4">
<div className="flex items-center justify-between"> <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<Link <span>{icon}</span> {title} <span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
to={getLink(value)} </h3>
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1" <div className="space-y-2">
> {displayed.map((item, i) => {
{getValue(value)} const pct = item.percentage || 0;
</Link> const barColor =
<div className="text-right"> pct >= 50 ? 'bg-threat-critical' :
<div className="text-text-primary font-medium">{value.count}</div> pct >= 25 ? 'bg-threat-high' :
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div> pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2"> return (
<div <div key={i}>
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`} <div className="flex items-center gap-2 mb-1">
style={{ width: `${percentage}%` }} <Link
/> to={getLink(item)}
className={`flex-1 text-xs hover:text-accent-primary transition-colors text-text-primary truncate ${mono ? 'font-mono' : ''}`}
title={item.value}
>
{item.value}
</Link>
<span className="text-xs text-text-secondary shrink-0">{item.count} ({pct.toFixed(0)}%)</span>
{!hideAssociatedIPs && attrType && (
<button
onClick={() => onLoadIPs(attrType, item.value, item.count)}
className="shrink-0 text-xs text-text-secondary hover:text-accent-primary transition-colors"
title="Voir les IPs associées"
>
👥
</button>
)}
</div> </div>
<div className="w-full bg-background-card rounded-full h-1.5">
<div className={`h-1.5 rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
{items.length > LIMIT && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
>
{showAll ? '↑ Réduire' : `${items.length - LIMIT} de plus`}
</button>
)}
</div> </div>
); );
} }
// Helper pour la couleur de la barre /* ─── UASection ─────────────────────────────────────────────────────────────── */
function getPercentageColor(percentage: number): string { function UASection({ items }: { items: AttributeValue[] }) {
if (percentage >= 50) return 'bg-threat-critical'; return (
if (percentage >= 25) return 'bg-threat-high'; <div className="bg-background-secondary rounded-xl p-4">
if (percentage >= 10) return 'bg-threat-medium'; <h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
return 'bg-threat-low'; <span>🖥</span> User-Agents
<span className="ml-auto bg-background-card px-2 py-0.5 rounded-full text-xs font-mono">{items.length}</span>
</h3>
<div className="space-y-3">
{items.map((item, i) => {
const pct = item.percentage || 0;
return (
<div key={i}>
<div className="flex items-start gap-2 mb-1">
<span className="flex-1 text-xs font-mono text-text-primary break-all leading-relaxed">{item.value}</span>
<span className="shrink-0 text-xs text-text-secondary">{item.count} ({pct.toFixed(1)}%)</span>
</div>
<div className="w-full bg-background-card rounded-full h-1.5">
<div className="h-1.5 rounded-full bg-threat-medium" style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
</div>
);
} }

View File

@ -17,6 +17,7 @@ interface DataTableProps<T> {
defaultSortKey?: string; defaultSortKey?: string;
defaultSortDir?: SortDir; defaultSortDir?: SortDir;
onRowClick?: (row: T) => void; onRowClick?: (row: T) => void;
onSort?: (key: string, dir: SortDir) => void;
rowKey: keyof T | ((row: T) => string); rowKey: keyof T | ((row: T) => string);
emptyMessage?: string; emptyMessage?: string;
loading?: boolean; loading?: boolean;
@ -31,6 +32,7 @@ export default function DataTable<T extends Record<string, any>>({
defaultSortKey, defaultSortKey,
defaultSortDir = 'desc', defaultSortDir = 'desc',
onRowClick, onRowClick,
onSort,
rowKey, rowKey,
emptyMessage = 'Aucune donnée disponible', emptyMessage = 'Aucune donnée disponible',
loading = false, loading = false,
@ -82,7 +84,15 @@ export default function DataTable<T extends Record<string, any>>({
alignClass(col.align), alignClass(col.align),
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '', isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
].join(' ')} ].join(' ')}
onClick={isSortable ? () => handleSort(col.key as keyof T) : undefined} onClick={isSortable ? () => {
handleSort(col.key as keyof T);
if (onSort) {
const newDir = String(sortKey) === col.key
? (sortDir === 'asc' ? 'desc' : 'asc')
: 'desc';
onSort(col.key, newDir);
}
} : undefined}
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{col.label} {col.label}

View File

@ -11,6 +11,7 @@ interface UseDetectionsParams {
search?: string; search?: string;
sort_by?: string; sort_by?: string;
sort_order?: string; sort_order?: string;
group_by_ip?: boolean;
} }
export function useDetections(params: UseDetectionsParams = {}) { export function useDetections(params: UseDetectionsParams = {}) {
@ -42,6 +43,7 @@ export function useDetections(params: UseDetectionsParams = {}) {
params.search, params.search,
params.sort_by, params.sort_by,
params.sort_order, params.sort_order,
params.group_by_ip,
]); ]);
return { data, loading, error }; return { data, loading, error };