From de12a763671ae0c506d1df948decba16ecd4fcc3 Mon Sep 17 00:00:00 2001 From: certctl-bot Date: Sat, 25 Apr 2026 17:38:54 +0000 Subject: [PATCH] feat(web): expand CertificatesPage filters + reusable DataTable pagination (F-1 master) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two 2026-04-24 audit findings (P2): - cat-e-610251c8f72d: CertificatesPage exposed only 5 of the backend handler's 17 supported query filters. Audit recommended minimum-add: team_id (already first-class elsewhere), expires_before (drives the "expiring in N days" workflow), and sort (sort by notAfter for the most common operator triage). Fix: 3 new useState hooks + 3 new filter UIs in the toolbar + 3 new param wires. Remaining filters (agent_id, expires_after, created_after, updated_after, cursor, fields, sort_desc) deferred until a consumer use case demands them — over-stuffing the toolbar is its own UX cost. - cat-k-e85d1099b2d7: CertificatesPage rendered the first 50 certs returned by the backend with no way to advance. Backend response carries {data, total, page, per_page} — a pure render gap. Fix: lifted pagination into the reusable DataTable component as an opt-in `pagination?` prop. CertificatesPage is the first consumer; TargetsPage / IssuersPage / OwnersPage / others can adopt by passing the same prop. DataTable changes: - New `PaginationProps` interface (page, perPage, total, onPageChange, onPerPageChange?, perPageOptions?). - New optional `pagination?` prop on DataTable. - New `PaginationControls` subcomponent rendered in the table footer when `pagination` is set and `total > 0`. Renders "Showing X–Y of Z" + per-page selector + page counter + Prev/Next buttons. Disabling logic guards both boundaries. CertificatesPage changes: - 3 new filter useState hooks: teamFilter, expiresBefore, sortBy. - 2 new pagination useState hooks: page (1), perPage (50). - Added 4th cohort hook: getTeams via useQuery (mirrors the existing issuers/owners/profiles filter-data pattern). - params object gains team_id, expires_before, sort, page, per_page. - 3 new filter UIs in the toolbar (team select, expires_before date picker, sort select). - DataTable gets the new pagination prop. - Filter changes reset page=1 to keep results visible. Verification: - tsc --noEmit — clean - vitest run — 9 files, 302 tests passing (no regression) - golangci-lint v2.11.4 run ./... — 0 issues - All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1) pass Audit findings closed: - cat-e-610251c8f72d (P2) - cat-k-e85d1099b2d7 (P2) Deferred follow-ups: - 8 backend filters (agent_id, expires_after, created_after, updated_after, cursor, fields, sort_desc, plus secondary sort fields) deferred until consumer demand justifies UI weight. - TargetsPage / IssuersPage / OwnersPage / etc. opt-in to the pagination prop incrementally — DataTable now supports it; per- page adoption is a follow-up commit each. - CertificatesPage Vitest coverage of the new filter+pagination paths deferred to the per-page test campaign (cat-s2-c24a548076c6). --- web/src/components/DataTable.tsx | 84 +++++++++++++++++++++++++++++- web/src/pages/CertificatesPage.tsx | 73 ++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index 02f1957..bee81c5 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -5,6 +5,24 @@ interface Column { className?: string; } +// F-1 closure (cat-k-e85d1099b2d7): DataTable was a render-only +// component pre-F-1 — every consumer page handed it the first 50 +// rows from a paginated endpoint and there was no way for the +// operator to advance. The backend has always returned `{data, +// total, page, per_page}` but the frontend never surfaced page +// 2+. The pagination prop below opt-ins reusable controls in the +// table footer; CertificatesPage is the first consumer (and the +// audit's flagged page), but TargetsPage / IssuersPage / others +// can adopt by passing the same prop. +interface PaginationProps { + page: number; + perPage: number; + total: number; + onPageChange: (page: number) => void; + onPerPageChange?: (perPage: number) => void; + perPageOptions?: number[]; +} + interface DataTableProps { columns: Column[]; data: T[]; @@ -15,9 +33,10 @@ interface DataTableProps { selectable?: boolean; selectedKeys?: Set; onSelectionChange?: (keys: Set) => void; + pagination?: PaginationProps; } -export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { if (isLoading) { return (
@@ -111,8 +130,69 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, })} + {pagination && pagination.total > 0 && ( + + )}
); } -export type { Column }; +// F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable +// consumers that want prev/next + page counter + per-page selector +// against a paginated backend response. Disabling logic guards the +// boundaries (prev disabled on page 1; next disabled when page * +// per_page >= total). +function PaginationControls({ page, perPage, total, onPageChange, onPerPageChange, perPageOptions }: PaginationProps) { + const start = total === 0 ? 0 : (page - 1) * perPage + 1; + const end = Math.min(page * perPage, total); + const lastPage = Math.max(1, Math.ceil(total / perPage)); + const isFirst = page <= 1; + const isLast = page >= lastPage; + const options = perPageOptions ?? [25, 50, 100, 200]; + return ( +
+ + Showing {start}{end} of {total.toLocaleString()} + +
+ {onPerPageChange && ( + + )} + + Page {page} of {lastPage} + +
+ + +
+
+
+ ); +} + +export type { Column, PaginationProps }; diff --git a/web/src/pages/CertificatesPage.tsx b/web/src/pages/CertificatesPage.tsx index 0560760..6530a39 100644 --- a/web/src/pages/CertificatesPage.tsx +++ b/web/src/pages/CertificatesPage.tsx @@ -398,6 +398,24 @@ export default function CertificatesPage() { const [issuerFilter, setIssuerFilter] = useState(''); const [ownerFilter, setOwnerFilter] = useState(''); const [profileFilter, setProfileFilter] = useState(''); + // F-1 closure (cat-e-610251c8f72d): pre-F-1 the page exposed only 5 of + // the backend handler's 17 supported query filters. Three new operator- + // facing filters added: team_id (already first-class elsewhere), + // expires_before (drives the "expiring in N days" workflow), and a + // sort selector (defaults to backend ordering). Audit-recommended + // minimum-add per the closure rationale; remaining filters + // (agent_id, expires_after, created_after, updated_after, cursor, + // fields, sort_desc) are deferred until a consumer use case + // demands them — over-stuffing the toolbar is its own UX cost. + const [teamFilter, setTeamFilter] = useState(''); + const [expiresBefore, setExpiresBefore] = useState(''); + const [sortBy, setSortBy] = useState(''); + // F-1 closure (cat-k-e85d1099b2d7): pre-F-1 the page rendered the + // first 50 certs returned by the backend with no way to advance. + // The reusable DataTable pagination prop (added in this same + // commit) takes the page + per_page state declared here. + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(50); const [showCreate, setShowCreate] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [showBulkRevoke, setShowBulkRevoke] = useState(false); @@ -407,13 +425,21 @@ export default function CertificatesPage() { const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) }); const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) }); const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) }); + // F-1 closure: hydrate the team filter dropdown. + const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) }); const params: Record = {}; - if (statusFilter) params.status = statusFilter; - if (envFilter) params.environment = envFilter; - if (issuerFilter) params.issuer_id = issuerFilter; - if (ownerFilter) params.owner_id = ownerFilter; - if (profileFilter) params.profile_id = profileFilter; + if (statusFilter) params.status = statusFilter; + if (envFilter) params.environment = envFilter; + if (issuerFilter) params.issuer_id = issuerFilter; + if (ownerFilter) params.owner_id = ownerFilter; + if (profileFilter) params.profile_id = profileFilter; + if (teamFilter) params.team_id = teamFilter; + if (expiresBefore) params.expires_before = expiresBefore; + if (sortBy) params.sort = sortBy; + // Pagination (F-1) — re-fetch on page / per_page change. + params.page = String(page); + params.per_page = String(perPage); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['certificates', params], @@ -587,6 +613,36 @@ export default function CertificatesPage() { ))} + {/* F-1 closure (cat-e-610251c8f72d): team / expires_before / sort */} + + { setExpiresBefore(e.target.value); setPage(1); }} + title="Expires before (drives the 'expiring in N days' workflow)" + className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink" + /> +
{error ? ( @@ -601,6 +657,13 @@ export default function CertificatesPage() { selectable selectedKeys={selectedIds} onSelectionChange={setSelectedIds} + pagination={{ + page, + perPage, + total: data?.total ?? 0, + onPageChange: setPage, + onPerPageChange: (n) => { setPerPage(n); setPage(1); }, + }} /> )}