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:
@@ -65,6 +65,25 @@ export async function getTemplates(
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch a single test template by ID. */
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
FlaskConical,
|
||||
BookOpen,
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { getTests, createTestFromTemplate, updateTest } from "../api/tests";
|
||||
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";
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
@@ -81,6 +82,8 @@ export default function AddTestToCampaignModal({
|
||||
const [templateSearch, setTemplateSearch] = useState("");
|
||||
const [filterPlatform, setFilterPlatform] = useState("");
|
||||
const [filterSeverity, setFilterSeverity] = useState("");
|
||||
const [templatePage, setTemplatePage] = useState(0);
|
||||
const TEMPLATE_PAGE_SIZE = 25;
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
// form fields (pre-filled from selected template)
|
||||
@@ -102,6 +105,7 @@ export default function AddTestToCampaignModal({
|
||||
setTemplateSearch("");
|
||||
setFilterPlatform("");
|
||||
setFilterSeverity("");
|
||||
setTemplatePage(0);
|
||||
setSelectedTemplateId(null);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -119,18 +123,32 @@ export default function AddTestToCampaignModal({
|
||||
});
|
||||
|
||||
const { data: templates, isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch],
|
||||
queryKey: ["templates", "picker", filterPlatform, filterSeverity, templateSearch, templatePage],
|
||||
queryFn: () =>
|
||||
getTemplates({
|
||||
search: templateSearch || undefined,
|
||||
platform: filterPlatform || undefined,
|
||||
severity: filterSeverity || undefined,
|
||||
limit: 100,
|
||||
offset: templatePage * TEMPLATE_PAGE_SIZE,
|
||||
limit: TEMPLATE_PAGE_SIZE,
|
||||
}),
|
||||
enabled: open && tab === "template",
|
||||
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({
|
||||
queryKey: ["template-detail", selectedTemplateId],
|
||||
queryFn: () => getTemplateById(selectedTemplateId!),
|
||||
@@ -288,7 +306,7 @@ export default function AddTestToCampaignModal({
|
||||
<input
|
||||
type="text"
|
||||
value={templateSearch}
|
||||
onChange={(e) => setTemplateSearch(e.target.value)}
|
||||
onChange={(e) => { setTemplateSearch(e.target.value); setTemplatePage(0); }}
|
||||
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"
|
||||
/>
|
||||
@@ -299,7 +317,7 @@ export default function AddTestToCampaignModal({
|
||||
</div>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">All platforms</option>
|
||||
@@ -309,7 +327,7 @@ export default function AddTestToCampaignModal({
|
||||
</select>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">All severities</option>
|
||||
@@ -372,6 +390,36 @@ export default function AddTestToCampaignModal({
|
||||
</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,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { getTemplates } from "../api/test-templates";
|
||||
import { getTemplates, getTemplateCount } from "../api/test-templates";
|
||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||
import type { TestTemplateSummary } from "../types/models";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -109,6 +109,20 @@ export default function TestCatalogPage() {
|
||||
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
|
||||
// returned per-template, no need for a separate backend round-trip.
|
||||
const templates = hideCovered
|
||||
@@ -145,11 +159,16 @@ export default function TestCatalogPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
Browse available test templates. Use a template to quickly create a security test.
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Test Catalog</h1>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
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>
|
||||
|
||||
{/* Filters bar */}
|
||||
@@ -279,7 +298,7 @@ export default function TestCatalogPage() {
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * pageSize + 1}
|
||||
{" - "}
|
||||
{page * pageSize + allTemplates.length}
|
||||
{page * pageSize + allTemplates.length} of {totalCount}
|
||||
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
||||
</p>
|
||||
<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" />
|
||||
Prev
|
||||
</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
|
||||
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"
|
||||
>
|
||||
Next
|
||||
|
||||
Reference in New Issue
Block a user