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 .database import db
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
logging.basicConfig(
@ -83,6 +83,7 @@ app.include_router(botnets.router)
app.include_router(rotation.router)
app.include_router(ml_features.router)
app.include_router(investigation_summary.router)
app.include_router(search.router)
# Route pour servir le frontend

View File

@ -77,6 +77,10 @@ class Detection(BaseModel):
client_headers: str = ""
asn_score: Optional[float] = None
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):

View File

@ -19,7 +19,8 @@ async def get_detections(
asn_number: Optional[str] = Query(None, description="Filtrer par ASN"),
search: Optional[str] = Query(None, description="Recherche texte (IP, JA4, Host)"),
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
@ -47,7 +48,7 @@ async def get_detections(
if search:
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}%"
@ -66,6 +67,124 @@ async def get_detections(
# Requête principale
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
valid_sort_columns = [
"detected_at", "src_ip", "threat_level", "anomaly_score",
@ -74,8 +193,6 @@ async def get_detections(
if sort_by not in valid_sort_columns:
sort_by = "detected_at"
sort_order = "DESC" if sort_order.upper() == "DESC" else "ASC"
main_query = f"""
SELECT
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.host) AS host,
sum(a.hits) AS hits,
round(max(uniqMerge(a.uniq_query_params))
/ greatest(max(uniqMerge(a.uniq_paths)), 1), 4) AS fuzzing_index,
round(uniqMerge(a.uniq_query_params)
/ greatest(uniqMerge(a.uniq_paths), 1), 4) AS fuzzing_index,
round(sum(a.hits)
/ 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,
@ -378,16 +378,27 @@ async def get_ml_scatter(limit: int = Query(200, ge=1, le=1000)):
try:
sql = """
SELECT
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(ja4) AS ja4,
round(max(uniqMerge(uniq_query_params)) / greatest(max(uniqMerge(uniq_paths)), 1), 4) AS fuzzing_index,
round(sum(hits) / greatest(dateDiff('second', min(first_seen), max(last_seen)), 1), 2) AS hit_velocity,
sum(hits) AS hits,
round(sum(count_head) / greatest(sum(hits), 1), 4) AS head_ratio,
max(correlated_raw) AS correlated
FROM mabase_prod.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
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
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
any(ja4) AS ja4,
uniqMerge(uniq_query_params) / greatest(uniqMerge(uniq_paths), 1) AS fuzzing_index,
sum(hits) AS total_hits,
min(first_seen) AS min_first,
max(last_seen) AS max_last,
sum(count_head) AS total_count_head,
max(correlated_raw) AS correlated
FROM mabase_prod.agg_host_ip_ja4_1h
WHERE window_start >= now() - INTERVAL 24 HOUR
GROUP BY src_ip
)
ORDER BY fuzzing_index DESC
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]
query = f"""
SELECT DISTINCT src_ip
SELECT src_ip, count() AS hit_count
FROM ml_detected_anomalies
WHERE {column} = %(value)s
AND detected_at >= now() - INTERVAL 24 HOUR
ORDER BY src_ip
GROUP BY src_ip
ORDER BY hit_count DESC
LIMIT %(limit)s
"""
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
count_query = f"""
@ -491,42 +496,77 @@ async def get_variability(attr_type: str, value: str):
first_seen = stats_row[2]
last_seen = stats_row[3]
# User-Agents via view_dashboard_user_agents (source principale pour les UAs)
# Colonnes disponibles: src_ip, ja4, hour, log_date, user_agents, requests
# User-Agents depuis http_logs pour des comptes exacts par requête
# (view_dashboard_user_agents déduplique par heure, ce qui sous-compte les hits)
_ua_params: dict = {"value": value}
if attr_type == "ip":
_ua_where = "toString(src_ip) = %(value)s"
_ua_params: dict = {"value": value}
_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":
_ua_where = "ja4 = %(value)s"
_ua_params = {"value": value}
_ua_logs_where = "ja4 = %(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
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:
# 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 (
SELECT DISTINCT replaceRegexpAll(toString(src_ip), '^::ffff:', '')
FROM ml_detected_anomalies
WHERE {column} = %(value)s AND detected_at >= now() - INTERVAL 24 HOUR
)"""
_ua_params = {"value": value}
ua_query_simple = f"""
SELECT
ua AS user_agent,
sum(requests) AS count,
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
min(log_date) AS first_seen,
max(log_date) AS last_seen
FROM view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {_ua_where}
AND hour >= now() - INTERVAL 24 HOUR
AND ua != ''
GROUP BY user_agent
ORDER BY count DESC
LIMIT 10
"""
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]
ua_query_simple = f"""
SELECT
ua AS user_agent,
sum(requests) AS count,
round(sum(requests) * 100.0 / sum(sum(requests)) OVER (), 2) AS percentage,
min(log_date) AS first_seen,
max(log_date) AS last_seen
FROM view_dashboard_user_agents
ARRAY JOIN user_agents AS ua
WHERE {_ua_where}
AND hour >= now() - INTERVAL 24 HOUR
AND ua != ''
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]
# JA4 fingerprints
ja4_query = f"""

View File

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

View File

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

View File

@ -42,8 +42,10 @@ export function DetectionsList() {
const page = parseInt(searchParams.get('page') || '1');
const modelName = searchParams.get('model_name') || undefined;
const search = searchParams.get('search') || undefined;
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'anomaly_score') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'asc') as SortOrder;
const sortField = (searchParams.get('sort_by') || searchParams.get('sort') || 'detected_at') as SortField;
const sortOrder = (searchParams.get('sort_order') || searchParams.get('order') || 'desc') as SortOrder;
const [groupByIP, setGroupByIP] = useState(true);
const { data, loading, error } = useDetections({
page,
@ -52,13 +54,11 @@ export function DetectionsList() {
search,
sort_by: sortField,
sort_order: sortOrder,
group_by_ip: groupByIP,
});
const [searchInput, setSearchInput] = useState(search || '');
const [showColumnSelector, setShowColumnSelector] = useState(false);
const [groupByIP, setGroupByIP] = useState(true); // Grouper par IP par défaut
// Configuration des colonnes
const [columns, setColumns] = useState<ColumnConfig[]>([
{ key: 'ip_ja4', label: 'IP / JA4', visible: true, sortable: true },
{ key: 'host', label: 'Host', visible: true, sortable: true },
@ -107,6 +107,14 @@ export function DetectionsList() {
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) {
return (
<div className="flex items-center justify-center h-64">
@ -125,59 +133,8 @@ export function DetectionsList() {
if (!data) return null;
// Traiter les données pour le regroupement par IP
const processedData = (() => {
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)
}))
};
})();
// Backend handles grouping — data is already grouped when groupByIP=true
const processedData = data;
// Build DataTable columns from visible column configs
const tableColumns: Column<DetectionRow>[] = columns
@ -352,20 +309,25 @@ export function DetectionsList() {
label: col.label,
sortable: true,
render: (_, row) =>
groupByIP && row.first_seen ? (
<div className="space-y-1">
<div className="text-xs text-text-secondary">
<span className="font-medium">Premier:</span>{' '}
{new Date(row.first_seen).toLocaleDateString('fr-FR')}{' '}
{new Date(row.first_seen).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
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="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-xs text-text-secondary">
<span className="font-medium">Dernier:</span>{' '}
{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 className="text-sm text-text-primary">
{new Date(row.detected_at).toLocaleDateString('fr-FR')}
@ -388,7 +350,7 @@ export function DetectionsList() {
<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>{groupByIP ? processedData.items.length : data.items.length}</span>
<span>{data.items.length}</span>
<span></span>
<span>{data.total} détections</span>
</div>
@ -403,8 +365,9 @@ export function DetectionsList() {
? 'bg-accent-primary text-white border-accent-primary'
: 'bg-background-card text-text-secondary border-background-card hover:text-text-primary'
}`}
title={groupByIP ? 'Passer en vue détections individuelles' : 'Passer en vue groupée par IP'}
>
{groupByIP ? '⊟ Détections individuelles' : '⊞ Grouper par IP'}
{groupByIP ? '⊞ Vue : Groupé par IP' : '⊟ Vue : Individuelle'}
</button>
{/* Sélecteur de colonnes */}
@ -486,7 +449,9 @@ export function DetectionsList() {
data={processedData.items as DetectionRow[]}
columns={tableColumns}
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)}`)}
emptyMessage="Aucune détection trouvée"
compact

View File

@ -975,30 +975,28 @@ export function FingerprintsView() {
const data = await res.json();
const items: JA4AttributeItem[] = data.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
.slice()
.sort((a, b) => b.count - a.count)
.slice(0, 30);
await Promise.all(
top30.map(async (item) => {
try {
const vRes = await fetch(
`/api/variability/ja4/${encodeURIComponent(item.value)}`
);
if (!vRes.ok) return;
const vData: VariabilityData = await vRes.json();
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
} catch {
// ignore individual errors
}
})
);
top30.forEach(async (item) => {
try {
const vRes = await fetch(
`/api/variability/ja4/${encodeURIComponent(item.value)}`
);
if (!vRes.ok) return;
const vData: VariabilityData = await vRes.json();
setVariabilityCache((prev) => new Map(prev).set(item.value, vData));
} catch {
// ignore individual errors
}
});
} catch (err) {
console.error('FingerprintsView:', err);
} finally {
setLoadingList(false);
}
};
@ -1434,7 +1432,8 @@ interface PersistentThreat {
interface JA4HistoryEntry {
ja4: string;
hits: number;
window_start: string;
first_seen: string;
last_seen: string;
}
interface SophisticationItem {
@ -1544,7 +1543,7 @@ function RotatorRow({ item }: { item: JA4Rotator }) {
<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="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>

View File

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

View File

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

View File

@ -2,66 +2,57 @@ import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface SearchResult {
type: 'ip' | 'ja4' | 'asn' | 'host' | 'user_agent';
type: 'ip' | 'ja4' | 'host' | 'asn';
value: string;
count?: number;
threat_level?: string;
label: string;
meta: string;
url: string;
investigation_url?: string;
}
interface QuickSearchProps {
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) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Détection du type de recherche
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
// Recherche via le nouvel endpoint unifié
useEffect(() => {
if (query.length < 3) {
setResults([]);
return;
}
if (query.length < 2) { setResults([]); return; }
const timer = setTimeout(async () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
setLoading(true);
try {
const type = detectType(query);
const endpoint = type === 'other' ? 'ip' : type;
const response = await fetch(`/api/attributes/${endpoint}?limit=5`);
if (response.ok) {
const data = await response.json();
const items = data.items || data || [];
setResults(Array.isArray(items) ? items : []);
} else {
setResults([]);
const res = await fetch(`/api/search/quick?q=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
setResults(data.results || []);
setSelectedIndex(-1);
}
} catch (error) {
console.error('Search error:', error);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, 250);
}, [query]);
// Raccourci clavier Cmd+K
@ -72,52 +63,39 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
inputRef.current?.focus();
setIsOpen(true);
}
if (e.key === 'Escape') {
setIsOpen(false);
setQuery('');
}
if (e.key === 'Escape') { setIsOpen(false); setQuery(''); }
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// Navigation au clavier
// Navigation clavier dans les résultats
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
if (e.key === 'ArrowDown') {
if (!isOpen || results.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, results.length - 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); }
if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1));
} 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]);
handleSelect(results[selectedIndex], (e as any).metaKey || (e as any).ctrlKey);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, results, selectedIndex]);
// Click outside
// Click en dehors
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (result: SearchResult) => {
const type = result.type || detectType(result.value);
navigate(`/investigate/${type}/${encodeURIComponent(result.value)}`);
const handleSelect = (result: SearchResult, useInvestigation = false) => {
const url = (useInvestigation && result.investigation_url) ? result.investigation_url : result.url;
navigate(url);
setIsOpen(false);
setQuery('');
onNavigate?.();
@ -125,53 +103,24 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.length >= 3) {
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';
}
if (results[0]) handleSelect(results[0]);
};
return (
<div ref={containerRef} className="relative w-full max-w-2xl">
{/* Search Bar */}
{/* Barre de recherche */}
<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">
<span className="pl-4 text-text-secondary">🔍</span>
<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">{loading ? <span className="animate-pulse"></span> : '🔍'}</span>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setSelectedIndex(-1);
}}
onChange={e => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
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"
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">
<span></span>K
@ -179,79 +128,52 @@ export function QuickSearch({ onNavigate }: QuickSearchProps) {
</div>
</form>
{/* Results Dropdown */}
{isOpen && (query.length >= 3) && (
<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">
{/* Dropdown résultats */}
{isOpen && query.length >= 2 && (
<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 ? (
<div className="py-2">
<div className="px-4 py-2 text-xs text-text-secondary border-b border-background-card">
Résultats suggérés
</div>
{results.map((result, index) => (
<button
key={`${result.type}-${result.value}`}
onClick={() => handleSelect(result)}
className={`w-full px-4 py-3 flex items-center gap-3 hover:bg-background-card transition-colors ${
index === selectedIndex ? 'bg-background-card' : ''
}`}
>
<span className="text-xl">{getTypeIcon(result.type)}</span>
<div className="flex-1 text-left">
<div className="font-mono text-sm text-text-primary">{result.value}</div>
<div className="text-xs text-text-secondary">
{result.type} {result.count && `${result.count} détections`}
<ul className="py-1">
{results.map((result, i) => (
<li key={`${result.type}-${result.value}-${i}`}>
<button
onClick={() => handleSelect(result)}
className={[
'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-lg shrink-0">{TYPE_ICON[result.type] ?? '🔍'}</span>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-text-primary truncate">{result.label}</div>
<div className="text-xs text-text-secondary">{result.meta}</div>
</div>
</div>
<span className={`px-2 py-1 rounded text-xs ${getTypeColor(result.type)}`}>
{result.type.toUpperCase()}
</span>
</button>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-bold ${TYPE_COLOR[result.type] ?? ''}`}>
{result.type.toUpperCase()}
</span>
{result.investigation_url && (
<button
className="shrink-0 text-xs text-accent-primary hover:underline ml-1"
onClick={e => { e.stopPropagation(); handleSelect(result, true); }}
title="Investigation complète"
></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 className="px-4 py-8 text-center text-text-secondary">
<div className="text-2xl mb-2">🔍</div>
<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>
)}
) : null}
{/* 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>
</div>
{/* 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>
)}

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');
if (statsResponse.ok) {
const data = await statsResponse.json();
setStats(data.items || []);
setStats(data.stats || []);
}
} catch (error) {
console.error('Error fetching threat intel:', error);
@ -248,7 +248,7 @@ export function ThreatIntelView() {
</td>
<td className="px-4 py-3">
<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>
</td>
<td className="px-4 py-3">

View File

@ -4,163 +4,146 @@ import { VariabilityAttributes, AttributeValue } from '../api/client';
interface VariabilityPanelProps {
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) {
const [showModal, setShowModal] = useState<{
type: string;
export function VariabilityPanel({ attributes, hideAssociatedIPs = false }: VariabilityPanelProps) {
const [modal, setModal] = useState<{
title: string;
items: string[];
total: number;
} | null>(null);
const [loading, setLoading] = useState(false);
// Fonction pour charger la liste des IPs associées
const loadAssociatedIPs = async (attrType: string, value: string, total: number) => {
setLoading(true);
try {
const response = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
const data = await response.json();
setShowModal({
type: 'ips',
title: `${data.total || total} IPs associées à ${value}`,
const res = await fetch(`/api/variability/${attrType}/${encodeURIComponent(value)}/ips?limit=100`);
const data = await res.json();
setModal({
title: `${data.total || total} IPs associées à ${value.length > 40 ? value.substring(0, 40) + '…' : value}`,
items: data.ips || [],
total: data.total || total,
});
} catch (error) {
console.error('Erreur chargement IPs:', error);
} catch {
// ignore
}
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 (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-text-primary">Variabilité des Attributs</h2>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-text-primary">Attributs observés</h2>
{/* JA4 Fingerprints */}
{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 */}
{/* User-Agents — plein format avec texte long */}
{attributes.user_agents && attributes.user_agents.length > 0 && (
<UASection items={attributes.user_agents} />
)}
{/* Pays */}
{attributes.countries && attributes.countries.length > 0 && (
<AttributeSection
title="Pays"
items={attributes.countries}
getValue={(item) => item.value}
getLink={(item) => `/detections/country/${encodeURIComponent(item.value)}`}
onViewAll={(value, count) => loadAssociatedIPs('country', value, count)}
showViewAll
viewAllLabel="Voir les IPs"
/>
)}
{/* Grille 2 colonnes pour les autres attributs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{sections.map((s) =>
s.items && s.items.length > 0 ? (
<AttributeSection
key={s.title}
title={s.title}
icon={s.icon}
items={s.items}
getLink={s.getLink}
attrType={s.attrType}
mono={s.mono}
hideAssociatedIPs={hideAssociatedIPs}
onLoadIPs={loadAssociatedIPs}
/>
) : null
)}
</div>
{/* 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>
{/* Modal IPs associées */}
{(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>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="text-center text-text-secondary py-8">Chargement...</div>
) : showModal.items.length > 0 ? (
<div className="space-y-2">
{showModal.items.map((item, index) => (
<div
key={index}
className="bg-background-card rounded-lg p-3 font-mono text-sm text-text-primary break-all"
<p className="text-center text-text-secondary py-8">Chargement</p>
) : modal && modal.items.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{modal.items.map((ip, i) => (
<Link
key={i}
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}
</div>
{ip}
</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 className="text-center text-text-secondary py-8">
Aucune donnée disponible
</div>
<p className="text-center text-text-secondary py-8">Aucune donnée</p>
)}
{modal && modal.total > modal.items.length && (
<p className="text-center text-text-secondary text-xs mt-4">
{modal.items.length} / {modal.total} affichées
</p>
)}
</div>
{/* Footer */}
<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 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>
@ -169,159 +152,107 @@ export function VariabilityPanel({ attributes }: VariabilityPanelProps) {
);
}
// Composant UASection — jamais de troncature, expand/collapse
function UASection({ items }: { items: AttributeValue[] }) {
/* ─── AttributeSection ─────────────────────────────────────────────────────── */
function AttributeSection({
title,
icon,
items,
getLink,
attrType,
mono,
hideAssociatedIPs,
onLoadIPs,
}: {
title: string;
icon: string;
items: AttributeValue[];
getLink: (v: AttributeValue) => string;
attrType?: string;
mono?: boolean;
hideAssociatedIPs?: boolean;
onLoadIPs: (type: string, value: string, count: number) => void;
}) {
const [showAll, setShowAll] = useState(false);
const INITIAL = 5;
const displayed = showAll ? items : items.slice(0, INITIAL);
const LIMIT = 8;
const displayed = showAll ? items : items.slice(0, LIMIT);
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})
<div className="bg-background-secondary rounded-xl p-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<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>
</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 className="space-y-2">
{displayed.map((item, i) => {
const pct = item.percentage || 0;
const barColor =
pct >= 50 ? 'bg-threat-critical' :
pct >= 25 ? 'bg-threat-high' :
pct >= 10 ? 'bg-threat-medium' : 'bg-threat-low';
return (
<div key={i}>
<div className="flex items-center gap-2 mb-1">
<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 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 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 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 && (
{items.length > LIMIT && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-4 w-full text-xs text-accent-primary hover:text-accent-primary/80 transition-colors"
className="mt-3 w-full text-xs text-accent-primary hover:text-accent-primary/80"
>
{showAll ? '↑ Réduire' : ` Voir les ${items.length - INITIAL} autres`}
{showAll ? '↑ Réduire' : `${items.length - LIMIT} de plus`}
</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);
/* ─── UASection ─────────────────────────────────────────────────────────────── */
function UASection({ items }: { items: AttributeValue[] }) {
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="bg-background-secondary rounded-xl p-4">
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-3 flex items-center gap-2">
<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">
{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>
)}
</div>
);
}
// Composant AttributeRow
function AttributeRow({
value,
getValue,
getLink,
}: {
value: AttributeValue;
getValue: (item: AttributeValue) => string;
getLink: (item: AttributeValue) => string;
}) {
const percentage = value.percentage || 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Link
to={getLink(value)}
className="text-text-primary hover:text-accent-primary transition-colors font-medium break-all text-sm leading-relaxed flex-1"
>
{getValue(value)}
</Link>
<div className="text-right">
<div className="text-text-primary font-medium">{value.count}</div>
<div className="text-text-secondary text-xs">{percentage.toFixed(1)}%</div>
</div>
</div>
<div className="w-full bg-background-card rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${getPercentageColor(percentage)}`}
style={{ width: `${percentage}%` }}
/>
{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>
);
}
// Helper pour la couleur de la barre
function getPercentageColor(percentage: number): string {
if (percentage >= 50) return 'bg-threat-critical';
if (percentage >= 25) return 'bg-threat-high';
if (percentage >= 10) return 'bg-threat-medium';
return 'bg-threat-low';
}

View File

@ -17,6 +17,7 @@ interface DataTableProps<T> {
defaultSortKey?: string;
defaultSortDir?: SortDir;
onRowClick?: (row: T) => void;
onSort?: (key: string, dir: SortDir) => void;
rowKey: keyof T | ((row: T) => string);
emptyMessage?: string;
loading?: boolean;
@ -31,6 +32,7 @@ export default function DataTable<T extends Record<string, any>>({
defaultSortKey,
defaultSortDir = 'desc',
onRowClick,
onSort,
rowKey,
emptyMessage = 'Aucune donnée disponible',
loading = false,
@ -82,7 +84,15 @@ export default function DataTable<T extends Record<string, any>>({
alignClass(col.align),
isSortable ? 'cursor-pointer hover:text-text-primary select-none' : '',
].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">
{col.label}

View File

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