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

View File

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

View File

@@ -1,23 +1,41 @@
{% extends "base.html" %}
{% block title %}Packages — GuardDog Nexus{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="/">Home</a>
<span class="separator">/</span>
<span>Packages</span>
</div>
{% endblock %}
{% block content %}
<h1>Packages</h1>
<p>
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
<div class="filter-bar">
<input type="text" id="search-input" placeholder="Search packages..." value="{{ search }}" hx-get="/packages" hx-trigger="input changed, keyup[entered] delay:300ms" hx-target="#packages-table-container" hx-swap="innerHTML">
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a>
</p>
</div>
<div id="packages-table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th class="sortable {% if sort_by == 'name' %}active{% endif %}" hx-get="/packages?page=1&flagged={{ flagged_filter }}&search={{ search }}&sort_by=name&sort_dir={% if sort_by == 'name' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#packages-table-container" hx-swap="innerHTML">
Name <span class="sort-icon">{% if sort_by == 'name' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th>Version</th>
<th>Ecosystem</th>
<th>Repo</th>
<th>Flagged</th>
<th>Findings</th>
<th>Last Scan</th>
<th class="sortable {% if sort_by == 'flagged' %}active{% endif %}" hx-get="/packages?page=1&flagged={{ flagged_filter }}&search={{ search }}&sort_by=flagged&sort_dir={% if sort_by == 'flagged' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#packages-table-container" hx-swap="innerHTML">
Flagged <span class="sort-icon">{% if sort_by == 'flagged' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th class="sortable {% if sort_by == 'total_findings' %}active{% endif %}" hx-get="/packages?page=1&flagged={{ flagged_filter }}&search={{ search }}&sort_by=total_findings&sort_dir={% if sort_by == 'total_findings' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#packages-table-container" hx-swap="innerHTML">
Findings <span class="sort-icon">{% if sort_by == 'total_findings' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th class="sortable {% if sort_by == 'last_scanned_at' %}active{% endif %}" hx-get="/packages?page=1&flagged={{ flagged_filter }}&search={{ search }}&sort_by=last_scanned_at&sort_dir={% if sort_by == 'last_scanned_at' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#packages-table-container" hx-swap="innerHTML">
Last Scan <span class="sort-icon">{% if sort_by == 'last_scanned_at' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
</tr>
</thead>
<tbody>
@@ -32,17 +50,28 @@
<td>{{ p.last_scan.strftime('%Y-%m-%d %H:%M') if p.last_scan }}</td>
</tr>
{% endfor %}
{% if not packages %}
<tr>
<td colspan="7" class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
<h3>No packages found</h3>
<small>Try adjusting your search or filters.</small>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li><small>Page {{ page }} of {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
<small style="opacity: 0.5;">{{ total }} total packages</small>
{% endblock %}

View File

@@ -1,23 +1,50 @@
{% extends "base.html" %}
{% block title %}Scans — GuardDog Nexus{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="/">Home</a>
<span class="separator">/</span>
<span>Scans</span>
</div>
{% endblock %}
{% block content %}
<h1>Scans</h1>
<p>
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
<div class="filter-bar">
<input type="text" id="search-input" placeholder="Search packages..." value="{{ search }}" hx-get="/scans" hx-trigger="input changed, keyup[entered] delay:300ms" hx-target="#scans-table-container" hx-swap="innerHTML">
<select id="status-filter" hx-get="/scans" hx-trigger="change" hx-target="#scans-table-container" hx-swap="innerHTML">
<option value="">All Statuses</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Pending</option>
<option value="scanning" {% if status_filter == 'scanning' %}selected{% endif %}>Scanning</option>
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>Completed</option>
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Failed</option>
</select>
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" class="filter-btn" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a>
</p>
</div>
<div id="scans-table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Package</th>
<th class="sortable {% if sort_by == 'id' %}active{% endif %}" hx-get="/scans?page=1&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by=id&sort_dir={% if sort_by == 'id' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#scans-table-container" hx-swap="innerHTML">
ID <span class="sort-icon">{% if sort_by == 'id' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th class="sortable {% if sort_by == 'package_name' %}active{% endif %}" hx-get="/scans?page=1&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by=package_name&sort_dir={% if sort_by == 'package_name' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#scans-table-container" hx-swap="innerHTML">
Package <span class="sort-icon">{% if sort_by == 'package_name' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th>Version</th>
<th>Repo</th>
<th>Status</th>
<th>Findings</th>
<th>Time</th>
<th class="sortable {% if sort_by == 'status' %}active{% endif %}" hx-get="/scans?page=1&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by=status&sort_dir={% if sort_by == 'status' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#scans-table-container" hx-swap="innerHTML">
Status <span class="sort-icon">{% if sort_by == 'status' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th class="sortable {% if sort_by == 'total_findings' %}active{% endif %}" hx-get="/scans?page=1&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by=total_findings&sort_dir={% if sort_by == 'total_findings' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#scans-table-container" hx-swap="innerHTML">
Findings <span class="sort-icon">{% if sort_by == 'total_findings' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
<th class="sortable {% if sort_by == 'started_at' %}active{% endif %}" hx-get="/scans?page=1&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by=started_at&sort_dir={% if sort_by == 'started_at' and sort_dir == 'asc' %}desc{% else %}asc{% endif %}" hx-target="#scans-table-container" hx-swap="innerHTML">
Time <span class="sort-icon">{% if sort_by == 'started_at' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
</th>
</tr>
</thead>
<tbody>
@@ -27,22 +54,35 @@
<td>{{ s.package_name }}</td>
<td>{{ s.package_version }}</td>
<td>{{ s.repository }}</td>
<td><span class="status-{{ s.status }}">{{ s.status }}</span></td>
<td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %}
</td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
</tr>
{% endfor %}
{% if not scans %}
<tr>
<td colspan="7" class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<h3>No scans found</h3>
<small>Try adjusting your search or filters.</small>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li><small>Page {{ page }} of {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
<small style="opacity: 0.5;">{{ total }} total scans</small>
{% endblock %}