Files
stealergram/web/routes/dashboard.py
anti 4c104cddd2 Add web frontend with JWT auth, RBAC, SSE dashboard, and config editor
- 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
2026-04-02 11:41:46 -03:00

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()