- Rename project to stealergram throughout - Add pyproject.toml (replaces requirements.txt split, folds pytest.ini) - Replace all em-dashes with hyphens across all source files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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()
|