""" web/routes/dashboard.py - Dashboard views and SSE live stream. GET / → redirect to /dashboard GET /dashboard → overview: all groups, stats, live hit feed GET /dashboard/groups/{group_id} → per-group hits + filtered SSE GET /api/stream → SSE event stream (one hit per event) GET /api/stats → JSON severity counts (for hx-trigger refresh) """ import asyncio import json import queue from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse, StreamingResponse import config from tui import events as bus from utils import database as hitdb from web.dependencies import require_role router = APIRouter() def _templates(request: Request): return request.app.state.templates def _hit_to_dict(hit) -> dict: reasons_raw = hit["reasons"] or "" reasons = [r.strip() for r in reasons_raw.split("|") if r.strip()] return { "severity": hit["severity"], "score": hit["score"], "raw": hit["raw"], "source": hit["source"], "filename": hit["filename"], "reasons": reasons, "timestamp": hit["timestamp"], } @router.get("/") async def root(): return RedirectResponse(url="/dashboard") @router.get("/dashboard") async def dashboard(request: Request, user=Depends(require_role("reader"))): hits = [_hit_to_dict(h) for h in hitdb.recent(limit=50)] counts = hitdb.count_by_severity() groups = config.KEYWORD_GROUPS return _templates(request).TemplateResponse( request, "dashboard.html", {"user": dict(user), "hits": hits, "counts": counts, "groups": groups}, ) @router.get("/dashboard/groups/{group_id}") async def group_detail(request: Request, group_id: str, user=Depends(require_role("reader"))): groups = config.KEYWORD_GROUPS group = next((g for g in groups if g["id"] == group_id), None) if group is None: raise HTTPException(status_code=404, detail="Group not found") patterns = [p["regex"] for p in group.get("patterns", [])] hits = [_hit_to_dict(h) for h in hitdb.recent_for_domains(patterns, limit=100)] counts = hitdb.count_by_severity_for_domains(patterns) return _templates(request).TemplateResponse( request, "group_detail.html", {"user": dict(user), "group": group, "hits": hits, "counts": counts}, ) @router.get("/api/stream") async def event_stream(request: Request, _user=Depends(require_role("reader"))): """Server-Sent Events: one data frame per EvHit.""" q = bus.subscribe() async def generate(): try: while True: if await request.is_disconnected(): break try: ev = q.get_nowait() if isinstance(ev, bus.EvHit): payload = json.dumps({ "severity": ev.severity, "raw": ev.raw, "source": ev.source, "filename": ev.filename, "reasons": ev.reasons, }) yield f"data: {payload}\n\n" except queue.Empty: await asyncio.sleep(0.1) finally: bus.unsubscribe(q) return StreamingResponse(generate(), media_type="text/event-stream") @router.get("/api/stats") async def stats(_user=Depends(require_role("reader"))): return hitdb.count_by_severity()