Files
guarddog-nexus/guarddog_nexus/routes/web.py

288 lines
8.1 KiB
Python

"""Web UI routes — Jinja2 + htmx pages."""
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..config import config
from ..constants import (
APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES,
DEFAULT_SORT_BY_SCANS,
DEFAULT_SORT_DIR,
WEB_PER_PAGE,
)
from ..core.nexus import parse_package_path
from ..db.engine import get_session
from ..db.models import Finding, Scan
from ..db.queries import (
build_package_list_query,
build_scan_list_query,
get_dashboard_stats,
)
from ..i18n import t as _t
router = APIRouter(tags=["web"])
_llm_locks: dict[int, asyncio.Lock] = {}
_llm_lock = asyncio.Lock()
# Cleanup interval for unused LLM locks (30 minutes)
_LLM_LOCK_CLEANUP_INTERVAL = 1800
async def _cleanup_llm_locks():
"""Periodically clean up unused LLM locks to prevent memory leaks."""
while True:
await asyncio.sleep(_LLM_LOCK_CLEANUP_INTERVAL)
for key in list(_llm_locks.keys()):
if not _llm_locks[key].locked():
_llm_locks.pop(key, None)
_jinja_env = Environment(
loader=PackageLoader(APP_PACKAGE, "web/templates"),
autoescape=select_autoescape(),
)
_jinja_env.globals["t"] = _t
_jinja_env.globals["config"] = config
def _render(name: str, **context) -> HTMLResponse:
template = _jinja_env.get_template(name)
status_code = context.pop("_status_code", 200)
return HTMLResponse(template.render(**context), status_code=status_code)
def _parse_flagged(value: str) -> bool | None:
return True if value == "1" else None
@router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, session: AsyncSession = Depends(get_session)) -> HTMLResponse:
ctx = await get_dashboard_stats(session)
return _render("dashboard.html", **ctx, request=request)
@router.get("/dashboard/stats", response_class=HTMLResponse)
async def dashboard_stats_fragment(
request: Request, session: AsyncSession = Depends(get_session)
) -> HTMLResponse:
ctx = await get_dashboard_stats(session)
return _render("dashboard_stats.html", request=request, **ctx)
@router.get("/scans", response_class=HTMLResponse)
async def scans_list(
request: Request,
page: int = 1,
flagged: str = "",
search: str = "",
status: str = "",
sort_by: str = DEFAULT_SORT_BY_SCANS,
sort_dir: str = DEFAULT_SORT_DIR,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
per_page = WEB_PER_PAGE
offset = (page - 1) * per_page
flagged_bool = _parse_flagged(flagged)
q, count_q = build_scan_list_query(
flagged=flagged_bool,
status=status or None,
search=search or None,
sort_by=sort_by,
sort_dir=sort_dir,
limit=per_page,
offset=offset,
)
scans = (await session.execute(q)).scalars().all()
total = await session.scalar(count_q)
template = "_scans_table.html" if request.headers.get("HX-Request") else "scans_list.html"
return _render(
template,
scans=scans,
page=page,
per_page=per_page,
total=total,
flagged_filter=flagged,
search=search,
status_filter=status,
sort_by=sort_by,
sort_dir=sort_dir,
request=request,
)
@router.get("/scans/{scan_id}", response_class=HTMLResponse)
async def scan_detail(
scan_id: int, request: Request, session: AsyncSession = Depends(get_session)
) -> HTMLResponse:
scan = await session.scalar(
select(Scan).where(Scan.id == scan_id).options(selectinload(Scan.findings))
)
if not scan:
return _render("404.html", request=request, _status_code=404)
return _render("scan_detail.html", scan=scan, request=request)
@router.get("/packages", response_class=HTMLResponse)
async def packages_list(
request: Request,
page: int = 1,
flagged: str = "",
search: str = "",
sort_by: str = DEFAULT_SORT_BY_PACKAGES,
sort_dir: str = DEFAULT_SORT_DIR,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
per_page = WEB_PER_PAGE
offset = (page - 1) * per_page
flagged_bool = _parse_flagged(flagged)
rows_q, total_q = build_package_list_query(
flagged=flagged_bool,
search=search or None,
sort_by=sort_by,
sort_dir=sort_dir,
limit=per_page,
offset=offset,
)
total = await session.scalar(total_q)
rows = (await session.execute(rows_q)).all()
template = "_packages_table.html" if request.headers.get("HX-Request") else "packages_list.html"
return _render(
template,
packages=rows,
page=page,
per_page=per_page,
total=total,
flagged_filter=flagged,
search=search,
sort_by=sort_by,
sort_dir=sort_dir,
request=request,
)
@router.get("/packages/{name:path}", response_class=HTMLResponse)
async def package_detail(
name: str,
request: Request,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
pkg_name, pkg_version = parse_package_path(name)
scans = (
(
await session.execute(
select(Scan)
.where(Scan.package_name == pkg_name, Scan.package_version == pkg_version)
.options(selectinload(Scan.findings))
.order_by(Scan.started_at.desc())
)
)
.scalars()
.all()
)
if not scans:
return _render("404.html", request=request, _status_code=404)
all_findings = []
for s in scans:
all_findings.extend(s.findings)
return _render(
"package_detail.html",
pkg_name=pkg_name,
pkg_version=pkg_version,
scans=scans,
findings=all_findings,
request=request,
)
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
async def analyze_finding_htmx(
finding_id: int,
request: Request,
retry: bool = False,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
from ..config import config
from ..core.llm import analyze_finding
lang = request.state.lang
if not config.llm_enabled:
msg = _t("llm_disabled", lang)
return HTMLResponse(f'<div class="llm-actions"><small class="flagged">{msg}</small></div>')
finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
if not finding:
msg = _t("llm_not_found", lang)
return HTMLResponse(
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>',
status_code=404,
)
if not retry and finding.report and finding.report.get("verdict"):
return _render(
"_llm_report_fragment.html",
report=finding.report,
finding_id=finding_id,
request=request,
)
if not retry and finding.report and finding.report.get("status") == "analyzing":
return _render("_llm_spinner.html", request=request)
async with _llm_lock:
if finding_id not in _llm_locks:
_llm_locks[finding_id] = asyncio.Lock()
lock = _llm_locks[finding_id]
if lock.locked():
async with _llm_lock:
_llm_locks.pop(finding_id, None)
return _render("_llm_spinner.html", request=request)
async with lock:
try:
finding.report = {"status": "analyzing"}
await session.commit()
report = await analyze_finding(finding.data)
finally:
async with _llm_lock:
_llm_locks.pop(finding_id, None)
if report is None:
finding.report = None
await session.commit()
msg = _t("llm_failed", lang)
return HTMLResponse(f'<div class="llm-actions"><small class="flagged">{msg}</small></div>')
finding.report = report
await session.commit()
return _render(
"_llm_report_fragment.html",
report=report,
finding_id=finding_id,
request=request,
)