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:
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user