ui: добавить поиск, фильтрацию и сортировку на списки

API:
- scans.py: добавить search, status, sort_by, sort_dir параметры
- packages.py: добавить search, sort_by, sort_dir параметры
- web/routes.py: передать новые параметры в шаблоны

UI:
- scans_list.html: search input (htmx debounce), status filter dropdown,
  sortable columns с hx-get, empty state
- packages_list.html: search input (htmx debounce), sortable columns,
  empty state
- pagination сохраняет все параметры фильтрации/сортировки
This commit is contained in:
Marker689
2026-05-10 03:12:26 +03:00
parent d00cee3432
commit 00424b494a
5 changed files with 192 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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)))