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
This commit is contained in:
108
web/routes/dashboard.py
Normal file
108
web/routes/dashboard.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user