diff --git a/guarddog_nexus/api/packages.py b/guarddog_nexus/api/packages.py index c3cb9af..11ae274 100644 --- a/guarddog_nexus/api/packages.py +++ b/guarddog_nexus/api/packages.py @@ -1,7 +1,7 @@ """REST API for packages (distinct packages across scans).""" from fastapi import APIRouter, Depends, Query -from sqlalchemy import func, select +from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from guarddog_nexus.database import get_session @@ -9,6 +9,14 @@ from guarddog_nexus.models import Finding, Scan router = APIRouter(prefix="/api/v1/packages", tags=["packages"]) +VALID_SORT_FIELDS = { + "name": Scan.package_name, + "version": Scan.package_version, + "last_scanned_at": Scan.started_at, + "total_findings": Scan.total_findings, + "flagged": Scan.flagged, +} + @router.get("") async def list_packages( @@ -16,6 +24,10 @@ async def list_packages( offset: int = Query(0, ge=0), ecosystem: str | None = Query(None), flagged: bool | None = Query(None), + search: str | None = Query(None), + repository: str | None = Query(None), + sort_by: str = Query("last_scanned_at"), + sort_dir: str = Query("desc"), session: AsyncSession = Depends(get_session), ): subq = select( @@ -33,14 +45,26 @@ async def list_packages( subq = subq.where(Scan.ecosystem == ecosystem) if flagged is not None: subq = subq.having(func.max(Scan.flagged) == flagged) + if repository: + subq = subq.where(Scan.repository == repository) + if search: + pattern = f"%{search}%" + subq = subq.where( + Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + ) + + sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + sort_dir = "asc" if sort_dir.lower() == "asc" else "desc" + sort_col = func.max(sort_field) + subq = subq.order_by( + sort_col.desc() if sort_dir == "desc" else sort_col.asc() + ) total_q = select(func.count()).select_from(subq.subquery()) total = await session.scalar(total_q) rows = ( - await session.execute( - subq.order_by(func.max(Scan.started_at).desc()).offset(offset).limit(limit) - ) + await session.execute(subq.offset(offset).limit(limit)) ).all() return { diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/api/scans.py index d43e680..4c45b83 100644 --- a/guarddog_nexus/api/scans.py +++ b/guarddog_nexus/api/scans.py @@ -10,18 +10,47 @@ from guarddog_nexus.models import Finding, Scan router = APIRouter(prefix="/api/v1/scans", tags=["scans"]) +VALID_SORT_FIELDS = { + "id": Scan.id, + "package_name": Scan.package_name, + "started_at": Scan.started_at, + "status": Scan.status, + "total_findings": Scan.total_findings, + "flagged": Scan.flagged, +} + @router.get("") async def list_scans( limit: int = Query(50, le=200), offset: int = Query(0, ge=0), flagged: bool | None = Query(None), + search: str | None = Query(None), + status: str | None = Query(None), + repository: str | None = Query(None), + sort_by: str = Query("started_at"), + sort_dir: str = Query("desc"), session: AsyncSession = Depends(get_session), ): q = select(Scan) + if flagged is not None: q = q.where(Scan.flagged == flagged) - q = q.order_by(Scan.started_at.desc()).offset(offset).limit(limit) + if status: + q = q.where(Scan.status == status) + if repository: + q = q.where(Scan.repository == repository) + if search: + pattern = f"%{search}%" + q = q.where( + Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + ) + + sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + sort_dir = "asc" if sort_dir.lower() == "asc" else "desc" + q = q.order_by(sort_field.desc() if sort_dir == "desc" else sort_field.asc()) + + q = q.offset(offset).limit(limit) total = await session.scalar(select(func.count(Scan.id))) diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/web/routes.py index 364522c..bf08770 100644 --- a/guarddog_nexus/web/routes.py +++ b/guarddog_nexus/web/routes.py @@ -11,7 +11,14 @@ from guarddog_nexus.database import get_session from guarddog_nexus.models import Finding, Scan router = APIRouter(tags=["web"]) -TEMPLATES: dict[str, str] = {} + +VALID_SORT_FIELDS = { + "id": Scan.id, + "package_name": Scan.package_name, + "started_at": Scan.started_at, + "status": Scan.status, + "total_findings": Scan.total_findings, +} def _render(name: str, **context) -> HTMLResponse: @@ -146,6 +153,10 @@ async def scans_list( request: Request, page: int = 1, flagged: str = "", + search: str = "", + status: str = "", + sort_by: str = "started_at", + sort_dir: str = "desc", session: AsyncSession = Depends(get_session), ): per_page = 50 @@ -154,7 +165,17 @@ async def scans_list( q = select(Scan) if flagged == "1": q = q.where(Scan.flagged == True) - q = q.order_by(Scan.started_at.desc()).offset(offset).limit(per_page) + if status: + q = q.where(Scan.status == status) + if search: + pattern = f"%{search}%" + q = q.where( + Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + ) + + sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + q = q.order_by(sort_field.desc() if sort_dir == "desc" else sort_field.asc()) + q = q.offset(offset).limit(per_page) scans = (await session.execute(q)).scalars().all() total = await session.scalar(select(func.count(Scan.id))) @@ -166,6 +187,10 @@ async def scans_list( per_page=per_page, total=total, flagged_filter=flagged, + search=search, + status_filter=status, + sort_by=sort_by, + sort_dir=sort_dir, request=request, ) @@ -188,6 +213,9 @@ async def packages_list( request: Request, page: int = 1, flagged: str = "", + search: str = "", + sort_by: str = "last_scanned_at", + sort_dir: str = "desc", session: AsyncSession = Depends(get_session), ): per_page = 50 @@ -206,12 +234,23 @@ async def packages_list( if flagged == "1": subq = subq.having(func.max(Scan.flagged) == True) + if search: + pattern = f"%{search}%" + subq = subq.where( + Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern) + ) + + sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at) + sort_col = func.max(sort_field) + subq = subq.order_by( + sort_col.desc() if sort_dir == "desc" else sort_col.asc() + ) subq = subq.subquery() total = await session.scalar(select(func.count()).select_from(subq)) rows = ( await session.execute( - select(subq).order_by(subq.c.last_scan.desc()).offset(offset).limit(per_page) + select(subq).offset(offset).limit(per_page) ) ).all() @@ -222,6 +261,9 @@ async def packages_list( per_page=per_page, total=total, flagged_filter=flagged, + search=search, + sort_by=sort_by, + sort_dir=sort_dir, request=request, ) diff --git a/guarddog_nexus/web/templates/packages_list.html b/guarddog_nexus/web/templates/packages_list.html index 629f7b5..c00e921 100644 --- a/guarddog_nexus/web/templates/packages_list.html +++ b/guarddog_nexus/web/templates/packages_list.html @@ -1,23 +1,41 @@ {% extends "base.html" %} +{% block title %}Packages — GuardDog Nexus{% endblock %} +{% block breadcrumbs %} +
+{% endblock %} {% block content %}