feat(templates): add pagination with total counts to catalog and campaign picker
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
Test Catalog's pagination showed only 'Page N' with no way to know how many pages existed, and the campaign 'Add Test' modal's template picker had no pagination at all — with 2800+ templates in the catalog, its flat 100-row fetch made most templates unreachable unless a search happened to match. Adds GET /test-templates/count (open to any authenticated user, mirrors the list endpoint's filters) and wires real 'Page N of M' + total-available counts into both.
This commit is contained in:
@@ -58,6 +58,7 @@ from app.services.audit_service import log_action
|
|||||||
# Import from app.services.test_template_service
|
# Import from app.services.test_template_service
|
||||||
from app.services.test_template_service import (
|
from app.services.test_template_service import (
|
||||||
bulk_activate,
|
bulk_activate,
|
||||||
|
count_templates,
|
||||||
get_template_or_raise,
|
get_template_or_raise,
|
||||||
get_template_stats,
|
get_template_stats,
|
||||||
list_templates,
|
list_templates,
|
||||||
@@ -156,6 +157,35 @@ def _list_templates_handler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /test-templates/count — total matching a filter, for pagination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/count")
|
||||||
|
def _count_templates_handler(
|
||||||
|
source: Optional[str] = Query(None),
|
||||||
|
platform: Optional[str] = Query(None),
|
||||||
|
severity: Optional[str] = Query(None),
|
||||||
|
mitre_technique_id: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
is_active: Optional[bool] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Return the total count of templates matching the same filters as the
|
||||||
|
list endpoint, so the catalog UI can show a true page count."""
|
||||||
|
return {"total": count_templates(
|
||||||
|
db,
|
||||||
|
source=source,
|
||||||
|
platform=platform,
|
||||||
|
severity=severity,
|
||||||
|
mitre_technique_id=mitre_technique_id,
|
||||||
|
search=search,
|
||||||
|
is_active=is_active,
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /test-templates/stats — catalog statistics (admin)
|
# GET /test-templates/stats — catalog statistics (admin)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,6 +23,42 @@ from app.models.test import Test
|
|||||||
from app.utils import escape_like
|
from app.utils import escape_like
|
||||||
|
|
||||||
|
|
||||||
|
def _build_template_query(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
mitre_technique_id: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
):
|
||||||
|
"""Build the filtered TestTemplate query shared by list_templates() and
|
||||||
|
count_templates() — a single source of truth so a paginated page of
|
||||||
|
results and the total count it's paginated against can never drift
|
||||||
|
apart (same pattern used for tests in test_crud_service.py)."""
|
||||||
|
query = db.query(TestTemplate)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(TestTemplate.is_active == is_active)
|
||||||
|
if source:
|
||||||
|
query = query.filter(TestTemplate.source == source)
|
||||||
|
if platform:
|
||||||
|
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
|
||||||
|
if severity:
|
||||||
|
query = query.filter(TestTemplate.severity == severity)
|
||||||
|
if mitre_technique_id:
|
||||||
|
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
||||||
|
if search:
|
||||||
|
pattern = f"%{escape_like(search)}%"
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
TestTemplate.name.ilike(pattern),
|
||||||
|
TestTemplate.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return query
|
||||||
|
|
||||||
|
|
||||||
# Define function list_templates
|
# Define function list_templates
|
||||||
def list_templates(
|
def list_templates(
|
||||||
# Entry: db
|
# Entry: db
|
||||||
@@ -46,51 +82,16 @@ def list_templates(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Return paginated, filterable list of test templates."""
|
"""Return paginated, filterable list of test templates."""
|
||||||
# Assign query = db.query(TestTemplate)
|
query = _build_template_query(
|
||||||
query = db.query(TestTemplate)
|
db, source=source, platform=platform, severity=severity,
|
||||||
# Check: is_active is not None
|
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
|
||||||
if is_active is not None:
|
)
|
||||||
# Assign query = query.filter(TestTemplate.is_active == is_active)
|
|
||||||
query = query.filter(TestTemplate.is_active == is_active)
|
|
||||||
|
|
||||||
# Check: source
|
|
||||||
if source:
|
|
||||||
# Assign query = query.filter(TestTemplate.source == source)
|
|
||||||
query = query.filter(TestTemplate.source == source)
|
|
||||||
# Check: platform
|
|
||||||
if platform:
|
|
||||||
# Assign query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}...
|
|
||||||
query = query.filter(TestTemplate.platform.ilike(f"%{escape_like(platform)}%"))
|
|
||||||
# Check: severity
|
|
||||||
if severity:
|
|
||||||
# Assign query = query.filter(TestTemplate.severity == severity)
|
|
||||||
query = query.filter(TestTemplate.severity == severity)
|
|
||||||
# Check: mitre_technique_id
|
|
||||||
if mitre_technique_id:
|
|
||||||
# Assign query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
|
||||||
query = query.filter(TestTemplate.mitre_technique_id == mitre_technique_id)
|
|
||||||
# Check: search
|
|
||||||
if search:
|
|
||||||
# Assign pattern = f"%{escape_like(search)}%"
|
|
||||||
pattern = f"%{escape_like(search)}%"
|
|
||||||
# Assign query = query.filter(
|
|
||||||
query = query.filter(
|
|
||||||
or_(
|
|
||||||
TestTemplate.name.ilike(pattern),
|
|
||||||
TestTemplate.description.ilike(pattern),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Assign templates = (
|
|
||||||
templates = (
|
templates = (
|
||||||
query
|
query
|
||||||
# Chain .order_by() call
|
|
||||||
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
.order_by(TestTemplate.mitre_technique_id, TestTemplate.name)
|
||||||
# Chain .offset() call
|
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
# Chain .limit() call
|
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
# Chain .all() call
|
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,10 +109,29 @@ def list_templates(
|
|||||||
for t in templates:
|
for t in templates:
|
||||||
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
|
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
|
||||||
|
|
||||||
# Return templates
|
|
||||||
return templates
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def count_templates(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
severity: str | None = None,
|
||||||
|
mitre_technique_id: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Return the total count of templates matching the same filters as
|
||||||
|
list_templates() — lets the catalog UI show a true page count even
|
||||||
|
though the list endpoint caps how many rows it returns per request."""
|
||||||
|
query = _build_template_query(
|
||||||
|
db, source=source, platform=platform, severity=severity,
|
||||||
|
mitre_technique_id=mitre_technique_id, search=search, is_active=is_active,
|
||||||
|
)
|
||||||
|
return query.count()
|
||||||
|
|
||||||
|
|
||||||
# Define function get_template_stats
|
# Define function get_template_stats
|
||||||
def get_template_stats(db: Session) -> dict:
|
def get_template_stats(db: Session) -> dict:
|
||||||
"""Return catalog statistics: totals by source, platform, active/inactive."""
|
"""Return catalog statistics: totals by source, platform, active/inactive."""
|
||||||
|
|||||||
@@ -182,11 +182,14 @@ def test_soft_delete():
|
|||||||
|
|
||||||
|
|
||||||
def test_search_filter():
|
def test_search_filter():
|
||||||
from app.routers.test_templates import list_templates
|
from app.services.test_template_service import _build_template_query, list_templates
|
||||||
source = inspect.getsource(list_templates)
|
# The actual filter-building (including search) now lives in the shared
|
||||||
|
# _build_template_query() helper, reused by both list and count.
|
||||||
|
source = inspect.getsource(_build_template_query)
|
||||||
assert "search" in source
|
assert "search" in source
|
||||||
assert "name" in source and "description" in source
|
assert "name" in source and "description" in source
|
||||||
assert "ilike" in source
|
assert "ilike" in source
|
||||||
|
assert "_build_template_query" in inspect.getsource(list_templates)
|
||||||
print(" [PASS] Search filter searches in name and description")
|
print(" [PASS] Search filter searches in name and description")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""GET /test-templates/count — total matching the same filters as the list
|
||||||
|
endpoint, so the catalog UI can compute a true page count."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.300", "name": "Count Endpoint Technique"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_template(api, headers, mitre_technique_id, name):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/test-templates", headers,
|
||||||
|
json={"mitre_technique_id": mitre_technique_id, "name": name},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_matches_number_of_matching_templates(
|
||||||
|
client, db, api, auth_headers, red_lead_headers, technique,
|
||||||
|
):
|
||||||
|
_create_template(api, red_lead_headers, "T1059.300", "Count test template A")
|
||||||
|
_create_template(api, red_lead_headers, "T1059.300", "Count test template B")
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"get", "/api/v1/test-templates/count", auth_headers,
|
||||||
|
params={"mitre_technique_id": "T1059.300"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["total"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_respects_search_filter(client, db, api, auth_headers, red_lead_headers, technique):
|
||||||
|
_create_template(api, red_lead_headers, "T1059.300", "Unique Zebra Template")
|
||||||
|
_create_template(api, red_lead_headers, "T1059.300", "Something else entirely")
|
||||||
|
|
||||||
|
resp = api("get", "/api/v1/test-templates/count", auth_headers, params={"search": "Zebra"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_matches_list_length_when_under_page_size(
|
||||||
|
client, db, api, auth_headers, red_lead_headers, technique,
|
||||||
|
):
|
||||||
|
_create_template(api, red_lead_headers, "T1059.300", "Parity check template")
|
||||||
|
|
||||||
|
listed = api(
|
||||||
|
"get", "/api/v1/test-templates", auth_headers,
|
||||||
|
params={"mitre_technique_id": "T1059.300"},
|
||||||
|
)
|
||||||
|
counted = api(
|
||||||
|
"get", "/api/v1/test-templates/count", auth_headers,
|
||||||
|
params={"mitre_technique_id": "T1059.300"},
|
||||||
|
)
|
||||||
|
assert len(listed.json()) == counted.json()["total"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_accessible_to_any_authenticated_role(client, db, api, red_tech_headers):
|
||||||
|
"""Pagination is a basic UX need, not a lead-only privilege like /stats."""
|
||||||
|
resp = api("get", "/api/v1/test-templates/count", red_tech_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
@@ -147,8 +147,11 @@ def test_list_templates_with_filters():
|
|||||||
assert "search" in source, "List must accept search filter"
|
assert "search" in source, "List must accept search filter"
|
||||||
assert "mitre_technique_id" in source, "List must accept mitre_technique_id filter"
|
assert "mitre_technique_id" in source, "List must accept mitre_technique_id filter"
|
||||||
|
|
||||||
# Verify ilike is used for search
|
# Verify ilike is used for search — lives in the shared
|
||||||
assert "ilike" in source, "Search should use ilike for case-insensitive matching"
|
# _build_template_query() helper now, reused by list and count.
|
||||||
|
from app.services.test_template_service import _build_template_query
|
||||||
|
assert "ilike" in inspect.getsource(_build_template_query), \
|
||||||
|
"Search should use ilike for case-insensitive matching"
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ export async function getTemplates(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total count of templates matching the same filters as getTemplates() — for pagination. */
|
||||||
|
export async function getTemplateCount(
|
||||||
|
filters?: Omit<TemplateFilters, "offset" | "limit">,
|
||||||
|
): Promise<number> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.source) params.append("source", filters.source);
|
||||||
|
if (filters?.platform) params.append("platform", filters.platform);
|
||||||
|
if (filters?.severity) params.append("severity", filters.severity);
|
||||||
|
if (filters?.mitre_technique_id)
|
||||||
|
params.append("mitre_technique_id", filters.mitre_technique_id);
|
||||||
|
if (filters?.search) params.append("search", filters.search);
|
||||||
|
params.append("is_active", filters?.is_active !== undefined ? String(filters.is_active) : "true");
|
||||||
|
|
||||||
|
const { data } = await client.get<{ total: number }>(
|
||||||
|
`/test-templates/count${params.toString() ? `?${params}` : ""}`,
|
||||||
|
);
|
||||||
|
return data.total;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Detail ─────────────────────────────────────────────────────────
|
// ── Detail ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fetch a single test template by ID. */
|
/** Fetch a single test template by ID. */
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Filter,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTests, createTestFromTemplate, updateTest } from "../api/tests";
|
import { getTests, createTestFromTemplate, updateTest } from "../api/tests";
|
||||||
import { addTestToCampaign } from "../api/campaigns";
|
import { addTestToCampaign } from "../api/campaigns";
|
||||||
import { getTemplates, getTemplateById } from "../api/test-templates";
|
import { getTemplates, getTemplateCount, getTemplateById } from "../api/test-templates";
|
||||||
import type { Test, TestState, TestTemplateSummary } from "../types/models";
|
import type { Test, TestState, TestTemplateSummary } from "../types/models";
|
||||||
|
|
||||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
@@ -81,6 +82,8 @@ export default function AddTestToCampaignModal({
|
|||||||
const [templateSearch, setTemplateSearch] = useState("");
|
const [templateSearch, setTemplateSearch] = useState("");
|
||||||
const [filterPlatform, setFilterPlatform] = useState("");
|
const [filterPlatform, setFilterPlatform] = useState("");
|
||||||
const [filterSeverity, setFilterSeverity] = useState("");
|
const [filterSeverity, setFilterSeverity] = useState("");
|
||||||
|
const [templatePage, setTemplatePage] = useState(0);
|
||||||
|
const TEMPLATE_PAGE_SIZE = 25;
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||||
|
|
||||||
// form fields (pre-filled from selected template)
|
// form fields (pre-filled from selected template)
|
||||||
@@ -102,6 +105,7 @@ export default function AddTestToCampaignModal({
|
|||||||
setTemplateSearch("");
|
setTemplateSearch("");
|
||||||
setFilterPlatform("");
|
setFilterPlatform("");
|
||||||
setFilterSeverity("");
|
setFilterSeverity("");
|
||||||
|
setTemplatePage(0);
|
||||||
setSelectedTemplateId(null);
|
setSelectedTemplateId(null);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -119,18 +123,32 @@ export default function AddTestToCampaignModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: templates, isLoading: templatesLoading } = useQuery({
|
const { data: templates, isLoading: templatesLoading } = useQuery({
|
||||||
queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch],
|
queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch, templatePage],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getTemplates({
|
getTemplates({
|
||||||
search: templateSearch || undefined,
|
search: templateSearch || undefined,
|
||||||
platform: filterPlatform || undefined,
|
platform: filterPlatform || undefined,
|
||||||
severity: filterSeverity || undefined,
|
severity: filterSeverity || undefined,
|
||||||
limit: 100,
|
offset: templatePage * TEMPLATE_PAGE_SIZE,
|
||||||
|
limit: TEMPLATE_PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
enabled: open && tab === "template",
|
enabled: open && tab === "template",
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: templateTotal = 0 } = useQuery({
|
||||||
|
queryKey: ["templates", "picker-count", filterPlatform, filterSeverity, templateSearch],
|
||||||
|
queryFn: () =>
|
||||||
|
getTemplateCount({
|
||||||
|
search: templateSearch || undefined,
|
||||||
|
platform: filterPlatform || undefined,
|
||||||
|
severity: filterSeverity || undefined,
|
||||||
|
}),
|
||||||
|
enabled: open && tab === "template",
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const templateTotalPages = Math.max(1, Math.ceil(templateTotal / TEMPLATE_PAGE_SIZE));
|
||||||
|
|
||||||
const { data: fullTemplate, isLoading: fullTemplateLoading } = useQuery({
|
const { data: fullTemplate, isLoading: fullTemplateLoading } = useQuery({
|
||||||
queryKey: ["template-detail", selectedTemplateId],
|
queryKey: ["template-detail", selectedTemplateId],
|
||||||
queryFn: () => getTemplateById(selectedTemplateId!),
|
queryFn: () => getTemplateById(selectedTemplateId!),
|
||||||
@@ -288,7 +306,7 @@ export default function AddTestToCampaignModal({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={templateSearch}
|
value={templateSearch}
|
||||||
onChange={(e) => setTemplateSearch(e.target.value)}
|
onChange={(e) => { setTemplateSearch(e.target.value); setTemplatePage(0); }}
|
||||||
placeholder="Search templates by name or technique…"
|
placeholder="Search templates by name or technique…"
|
||||||
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-9 pr-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
@@ -299,7 +317,7 @@ export default function AddTestToCampaignModal({
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={filterPlatform}
|
value={filterPlatform}
|
||||||
onChange={(e) => setFilterPlatform(e.target.value)}
|
onChange={(e) => { setFilterPlatform(e.target.value); setTemplatePage(0); }}
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All platforms</option>
|
<option value="">All platforms</option>
|
||||||
@@ -309,7 +327,7 @@ export default function AddTestToCampaignModal({
|
|||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={filterSeverity}
|
value={filterSeverity}
|
||||||
onChange={(e) => setFilterSeverity(e.target.value)}
|
onChange={(e) => { setFilterSeverity(e.target.value); setTemplatePage(0); }}
|
||||||
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2.5 py-1.5 text-xs text-gray-300 focus:border-cyan-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">All severities</option>
|
<option value="">All severities</option>
|
||||||
@@ -372,6 +390,36 @@ export default function AddTestToCampaignModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!templatesLoading && templateTotal > 0 && (
|
||||||
|
<div className="flex items-center justify-between border-t border-gray-800 px-5 py-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{templatePage * TEMPLATE_PAGE_SIZE + 1}
|
||||||
|
{"-"}
|
||||||
|
{templatePage * TEMPLATE_PAGE_SIZE + (templates?.length ?? 0)} of {templateTotal}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTemplatePage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={templatePage === 0}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-2.5 py-1 text-xs text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-400">Page {templatePage + 1} of {templateTotalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTemplatePage((p) => p + 1)}
|
||||||
|
disabled={templatePage + 1 >= templateTotalPages}
|
||||||
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-2.5 py-1 text-xs text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTemplates } from "../api/test-templates";
|
import { getTemplates, getTemplateCount } from "../api/test-templates";
|
||||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||||
import type { TestTemplateSummary } from "../types/models";
|
import type { TestTemplateSummary } from "../types/models";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -109,6 +109,20 @@ export default function TestCatalogPage() {
|
|||||||
queryFn: () => getTemplates(filters),
|
queryFn: () => getTemplates(filters),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Total count matching the current filters (not just this page) — lets
|
||||||
|
// the pagination bar show a real page count instead of just "Page N".
|
||||||
|
const { data: totalCount = 0 } = useQuery({
|
||||||
|
queryKey: ["test-templates-count", search, source, platform, severity],
|
||||||
|
queryFn: () =>
|
||||||
|
getTemplateCount({
|
||||||
|
search: search || undefined,
|
||||||
|
source: source || undefined,
|
||||||
|
platform: platform || undefined,
|
||||||
|
severity: severity || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
|
||||||
// "No tests yet" is a client-side filter — existing_test_count is already
|
// "No tests yet" is a client-side filter — existing_test_count is already
|
||||||
// returned per-template, no need for a separate backend round-trip.
|
// returned per-template, no need for a separate backend round-trip.
|
||||||
const templates = hideCovered
|
const templates = hideCovered
|
||||||
@@ -145,11 +159,16 @@ export default function TestCatalogPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
<div>
|
||||||
<p className="mt-1 text-sm text-gray-400">
|
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
||||||
Browse available test templates. Use a template to quickly create a security test.
|
<p className="mt-1 text-sm text-gray-400">
|
||||||
</p>
|
Browse available test templates. Use a template to quickly create a security test.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-sm font-medium text-gray-300">
|
||||||
|
{totalCount} template{totalCount !== 1 ? "s" : ""} available
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters bar */}
|
{/* Filters bar */}
|
||||||
@@ -279,7 +298,7 @@ export default function TestCatalogPage() {
|
|||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Showing {page * pageSize + 1}
|
Showing {page * pageSize + 1}
|
||||||
{" - "}
|
{" - "}
|
||||||
{page * pageSize + allTemplates.length}
|
{page * pageSize + allTemplates.length} of {totalCount}
|
||||||
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
||||||
</p>
|
</p>
|
||||||
<label className="flex items-center gap-1.5 text-sm text-gray-500">
|
<label className="flex items-center gap-1.5 text-sm text-gray-500">
|
||||||
@@ -304,10 +323,10 @@ export default function TestCatalogPage() {
|
|||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
<span className="text-sm text-gray-400">Page {page + 1} of {totalPages}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
disabled={allTemplates.length < pageSize}
|
disabled={page + 1 >= totalPages}
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
|
|||||||
Reference in New Issue
Block a user