"""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: if value == "1": return True if value == "0": return False return 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'