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:
@@ -1,7 +1,7 @@
|
|||||||
"""REST API for packages (distinct packages across scans)."""
|
"""REST API for packages (distinct packages across scans)."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from guarddog_nexus.database import get_session
|
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"])
|
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("")
|
@router.get("")
|
||||||
async def list_packages(
|
async def list_packages(
|
||||||
@@ -16,6 +24,10 @@ async def list_packages(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
ecosystem: str | None = Query(None),
|
ecosystem: str | None = Query(None),
|
||||||
flagged: bool | 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),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
subq = select(
|
subq = select(
|
||||||
@@ -33,14 +45,26 @@ async def list_packages(
|
|||||||
subq = subq.where(Scan.ecosystem == ecosystem)
|
subq = subq.where(Scan.ecosystem == ecosystem)
|
||||||
if flagged is not None:
|
if flagged is not None:
|
||||||
subq = subq.having(func.max(Scan.flagged) == flagged)
|
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_q = select(func.count()).select_from(subq.subquery())
|
||||||
total = await session.scalar(total_q)
|
total = await session.scalar(total_q)
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(subq.offset(offset).limit(limit))
|
||||||
subq.order_by(func.max(Scan.started_at).desc()).offset(offset).limit(limit)
|
|
||||||
)
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,18 +10,47 @@ from guarddog_nexus.models import Finding, Scan
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/scans", tags=["scans"])
|
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("")
|
@router.get("")
|
||||||
async def list_scans(
|
async def list_scans(
|
||||||
limit: int = Query(50, le=200),
|
limit: int = Query(50, le=200),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
flagged: bool | None = Query(None),
|
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),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
q = select(Scan)
|
q = select(Scan)
|
||||||
|
|
||||||
if flagged is not None:
|
if flagged is not None:
|
||||||
q = q.where(Scan.flagged == flagged)
|
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)))
|
total = await session.scalar(select(func.count(Scan.id)))
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ from guarddog_nexus.database import get_session
|
|||||||
from guarddog_nexus.models import Finding, Scan
|
from guarddog_nexus.models import Finding, Scan
|
||||||
|
|
||||||
router = APIRouter(tags=["web"])
|
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:
|
def _render(name: str, **context) -> HTMLResponse:
|
||||||
@@ -146,6 +153,10 @@ async def scans_list(
|
|||||||
request: Request,
|
request: Request,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
flagged: str = "",
|
flagged: str = "",
|
||||||
|
search: str = "",
|
||||||
|
status: str = "",
|
||||||
|
sort_by: str = "started_at",
|
||||||
|
sort_dir: str = "desc",
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
per_page = 50
|
per_page = 50
|
||||||
@@ -154,7 +165,17 @@ async def scans_list(
|
|||||||
q = select(Scan)
|
q = select(Scan)
|
||||||
if flagged == "1":
|
if flagged == "1":
|
||||||
q = q.where(Scan.flagged == True)
|
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()
|
scans = (await session.execute(q)).scalars().all()
|
||||||
total = await session.scalar(select(func.count(Scan.id)))
|
total = await session.scalar(select(func.count(Scan.id)))
|
||||||
@@ -166,6 +187,10 @@ async def scans_list(
|
|||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
total=total,
|
total=total,
|
||||||
flagged_filter=flagged,
|
flagged_filter=flagged,
|
||||||
|
search=search,
|
||||||
|
status_filter=status,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,6 +213,9 @@ async def packages_list(
|
|||||||
request: Request,
|
request: Request,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
flagged: str = "",
|
flagged: str = "",
|
||||||
|
search: str = "",
|
||||||
|
sort_by: str = "last_scanned_at",
|
||||||
|
sort_dir: str = "desc",
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
per_page = 50
|
per_page = 50
|
||||||
@@ -206,12 +234,23 @@ async def packages_list(
|
|||||||
|
|
||||||
if flagged == "1":
|
if flagged == "1":
|
||||||
subq = subq.having(func.max(Scan.flagged) == True)
|
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()
|
subq = subq.subquery()
|
||||||
total = await session.scalar(select(func.count()).select_from(subq))
|
total = await session.scalar(select(func.count()).select_from(subq))
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
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()
|
).all()
|
||||||
|
|
||||||
@@ -222,6 +261,9 @@ async def packages_list(
|
|||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
total=total,
|
total=total,
|
||||||
flagged_filter=flagged,
|
flagged_filter=flagged,
|
||||||
|
search=search,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
{% extends "base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<h1>Packages</h1>
|
<h1>Packages</h1>
|
||||||
|
|
||||||
<p>
|
<div class="filter-bar">
|
||||||
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
|
<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 %}
|
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div id="packages-table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>Version</th>
|
||||||
<th>Ecosystem</th>
|
<th>Ecosystem</th>
|
||||||
<th>Repo</th>
|
<th>Repo</th>
|
||||||
<th>Flagged</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">
|
||||||
<th>Findings</th>
|
Flagged <span class="sort-icon">{% if sort_by == 'flagged' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
|
||||||
<th>Last Scan</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -32,17 +50,28 @@
|
|||||||
<td>{{ p.last_scan.strftime('%Y-%m-%d %H:%M') if p.last_scan }}</td>
|
<td>{{ p.last_scan.strftime('%Y-%m-%d %H:%M') if p.last_scan }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<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><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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<small style="opacity: 0.5;">{{ total }} total packages</small>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
{% extends "base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<h1>Scans</h1>
|
<h1>Scans</h1>
|
||||||
|
|
||||||
<p>
|
<div class="filter-bar">
|
||||||
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
|
<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 %}
|
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div id="scans-table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</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">
|
||||||
<th>Package</th>
|
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>Version</th>
|
||||||
<th>Repo</th>
|
<th>Repo</th>
|
||||||
<th>Status</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">
|
||||||
<th>Findings</th>
|
Status <span class="sort-icon">{% if sort_by == 'status' %}{{ '▲' if sort_dir == 'asc' else '▼' }}{% else %}↕{% endif %}</span>
|
||||||
<th>Time</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -27,22 +54,35 @@
|
|||||||
<td>{{ s.package_name }}</td>
|
<td>{{ s.package_name }}</td>
|
||||||
<td>{{ s.package_version }}</td>
|
<td>{{ s.package_version }}</td>
|
||||||
<td>{{ s.repository }}</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>{% 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>
|
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% 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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<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><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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<small style="opacity: 0.5;">{{ total }} total scans</small>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user