feat(dashboard): complete SOC dashboard with full monitoring and workflows

- models.html: Full rewrite — 6 KPIs, scoring volume timeline, anomaly rate
  chart, threat breakdown per model, enhanced model cards with validation gate
- classify.html: SOC workflow — suggested unclassified IPs, quick-classify
  buttons, classification stats pie, pre-fill from URL params
- traffic.html: Clickable rows → ip_detail, column sorting, status column,
  search filter, doc tooltips on all chart sections
- scores.html: Search input, clickable rows → ip_detail, LEGITIMATE_BROWSER
  filter button, doc tooltips on distribution + scatter charts
- ip_detail.html: Resource cascade section (headless browser detection),
  status column in HTTP logs table
- detections.html: Doc tooltips on threat/reason/ASN chart sections
- features.html: Doc tooltips on radar/importance/scatter sections
- api.py: 4 new endpoints — /api/models/timeline, /api/models/threats,
  /api/classify/stats, /api/classify/suggested. Traffic API: status + search.

46 routes total. All tests pass (dashboard + bot-detector 36/36).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
toto
2026-04-09 01:25:01 +02:00
parent 396baa90d2
commit 63ba6d203c
8 changed files with 711 additions and 142 deletions

View File

@ -35,7 +35,7 @@ _SCORE_SORT_COLS = {
"asn_org", "country_code", "browser_family",
}
_TRAFFIC_SORT_COLS = {
"time", "src_ip", "method", "host", "path", "http_version",
"time", "src_ip", "method", "status", "host", "path", "http_version",
"header_user_agent", "ja4", "src_country_code",
}
_ORDER_VALUES = {"ASC", "DESC"}
@ -315,6 +315,8 @@ async def traffic(
method: str | None = Query(None),
host: str | None = Query(None),
http_version: str | None = Query(None),
status: int | None = Query(None),
search: str | None = Query(None),
) -> dict[str, Any]:
sort = _validate_sort(sort, _TRAFFIC_SORT_COLS, "time")
order = _validate_order(order)
@ -335,6 +337,18 @@ async def traffic(
where_clauses.append("http_version = {http_version:String}")
params["http_version"] = http_version
if status is not None:
where_clauses.append("status = {status:UInt16}")
params["status"] = status
if search:
where_clauses.append(
"(toString(src_ip) LIKE {search:String} "
"OR path LIKE {search:String} "
"OR header_user_agent LIKE {search:String})"
)
params["search"] = f"%{search}%"
where = " AND ".join(where_clauses)
try:
@ -344,7 +358,7 @@ async def traffic(
)
rows = query(
f"SELECT time, toString(src_ip) AS src_ip, method, host, path, "
f"SELECT time, toString(src_ip) AS src_ip, method, status, host, path, "
f"http_version, header_user_agent, ja4, src_country_code "
f"FROM {_DB_LOGS}.http_logs "
f"WHERE {where} ORDER BY {sort} {order} "
@ -392,7 +406,7 @@ async def ip_detail(ip: str) -> dict[str, Any]:
)
http_logs = query(
f"SELECT time, method, host, path, http_version, header_user_agent, ja4 "
f"SELECT time, method, status, host, path, http_version, header_user_agent, ja4 "
f"FROM {_DB_LOGS}.http_logs "
"WHERE src_ip = toIPv4OrZero({ip:String}) "
"AND time >= now() - INTERVAL 1 DAY "
@ -739,6 +753,98 @@ async def models() -> dict[str, Any]:
return {"models": model_info, "scoring_stats": scoring_stats}
# ---------------------------------------------------------------------------
# GET /api/models/timeline — Scoring volume over time per model
# ---------------------------------------------------------------------------
@router.get("/models/timeline")
async def models_timeline() -> dict[str, Any]:
"""Volume de scoring horaire par modèle (7 jours)."""
try:
rows = query(
f"SELECT toStartOfHour(detected_at) AS hour, "
f"model_name, count() AS cnt, "
f"avg(anomaly_score) AS avg_score, "
f"countIf(threat_level IN ('HIGH','CRITICAL')) AS anomalies "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY hour, model_name "
"ORDER BY hour"
)
return {"timeline": rows}
except Exception as exc:
logger.exception("models timeline query failed")
return {"timeline": []}
# ---------------------------------------------------------------------------
# GET /api/models/threats — Threat breakdown per model
# ---------------------------------------------------------------------------
@router.get("/models/threats")
async def models_threats() -> dict[str, Any]:
"""Répartition des niveaux de menace par modèle."""
try:
rows = query(
f"SELECT model_name, threat_level, count() AS cnt "
f"FROM {_DB}.ml_all_scores "
"WHERE detected_at >= now() - INTERVAL 7 DAY "
"GROUP BY model_name, threat_level "
"ORDER BY model_name, cnt DESC"
)
return {"threats": rows}
except Exception as exc:
logger.exception("models threats query failed")
return {"threats": []}
# ---------------------------------------------------------------------------
# GET /api/classify/stats — Classification summary stats
# ---------------------------------------------------------------------------
@router.get("/classify/stats")
async def classify_stats() -> dict[str, Any]:
"""Statistiques de classification SOC."""
try:
_ensure_feedback_table()
rows = query(
f"SELECT classification, count() AS cnt "
f"FROM {_DB}.soc_feedback "
"GROUP BY classification"
)
total = sum(r["cnt"] for r in rows)
return {"stats": rows, "total": total}
except Exception:
return {"stats": [], "total": 0}
# ---------------------------------------------------------------------------
# GET /api/classify/suggested — Top unclassified IPs
# ---------------------------------------------------------------------------
@router.get("/classify/suggested")
async def classify_suggested() -> dict[str, Any]:
"""IPs détectées non encore classifiées, triées par sévérité."""
try:
_ensure_feedback_table()
rows = query(
f"SELECT toString(d.src_ip) AS src_ip, "
f"max(d.anomaly_score) AS worst_score, "
f"max(d.threat_level) AS threat_level, "
f"count() AS detection_count, "
f"any(d.ja4) AS ja4, any(d.host) AS host, "
f"any(d.asn_org) AS asn_org, any(d.country_code) AS country_code "
f"FROM {_DB}.ml_detected_anomalies AS d "
f"LEFT JOIN {_DB}.soc_feedback AS f "
f"ON d.src_ip = f.src_ip "
"WHERE d.detected_at >= now() - INTERVAL 3 DAY "
"AND f.src_ip IS NULL "
"GROUP BY d.src_ip "
"ORDER BY worst_score DESC "
"LIMIT 20"
)
return {"suggested": rows}
except Exception as exc:
logger.exception("classify suggested query failed")
return {"suggested": []}
# ---------------------------------------------------------------------------
# POST /api/classify — SOC analyst feedback
# ---------------------------------------------------------------------------