- FastAPI + htmx + Jinja2 web frontend, started with --web flag - JWT HS256 auth (WEB_SECRET_KEY) with httpOnly cookies; access (15 min) + refresh (7 day) tokens; refresh rotation + JTI revocation in data/web.db - RBAC: superadmin > admin > reader enforced per route - Live SSE dashboard fed by tui/events broadcast queue - Config editor: keyword groups and channel list saved to data/runtime_config.json and hot-reloaded in-process (scorer.reload_from_config, signal_channel_changed) - config.py migrated to load groups/channels from runtime_config.json; falls back to hardcoded defaults when file absent - tui/events.py: subscribe/unsubscribe broadcast, set_bot_context/signal_channel_changed - utils/scorer.py: import config as _config (fixes local binding); reload_from_config() - utils/database.py: count_by_severity, recent_for_domains, count_by_severity_for_domains - 53 new tests (events bus, JWT lifecycle, web DB CRUD, RBAC enforcement, config round-trip); total 141 passing
109 lines
3.5 KiB
Python
109 lines
3.5 KiB
Python
"""
|
|
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()
|