feat(centralui): server-side paging + server-computed status counts on Deployments (R3)

The Deployment Status page client-materialized the whole deployment list. It read
EVERY DeploymentRecord — an insert-only table, one row per deploy attempt for the
retention window — plus EVERY Instance, then site-scoped, sorted, counted the four
status tiles and sliced a 25-row page in the Blazor circuit's memory. That ran on
first render AND on every IDeploymentStatusNotifier push, so the cost scaled with
the age of the system rather than the size of the page.

All four jobs move into SQL:

- `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, page, size)`
  returns one page of `DeploymentListRow` — DeploymentRecord INNER JOINed to
  Instance, so the instance display name and site travel with the rows that need
  them — plus the total count of the filtered set. The join is exact: the FK is
  Restrict and DeleteInstanceAsync removes the records first, so no orphan exists.
- `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from ONE grouped
  aggregation, deliberately ignoring the filter's Status: the tiles are the status
  BREAKDOWN of the filtered set, so honouring it would zero three of four tiles the
  moment an operator clicked one.
- Site scoping runs in the query as `SiteIdScope` resolved through the record's
  instance (DeploymentRecord has no SiteId of its own). An EMPTY grant stays a real
  filter matching nothing, never "unconstrained".
- The now-callerless whole-table `GetAllDeploymentRecordsAsync` is deleted.

OFFSET paging, not the Audit Log's keyset cursor, and deliberately so: this page's
pager is numbered and jump-to-any-page, so it needs a page count, which only a total
can give it — a keyset cursor can express neither, and the total is required for the
tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is
bounded here by the terminal-record retention purge, unlike the 365-day AuditLog.
This mirrors the Notification Outbox, offset-paged for the same reason. Ordering is
DeployedAt DESC, Id DESC — the Id tie-break is load-bearing, because DeployedAt ties
on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows.

UI: the four status tiles become the status filter (click to apply, click again to
clear, aria-pressed, phrasing-only content so a <button> stays valid), plus a
free-text search matched DB-side against instance name, deployment id, revision hash
and initiating user. Search is TRAILING-edge debounced at 500ms — the same Timer +
lock + _disposed idiom as the existing leading-edge push coalescer, minus the leading
edge, because a search box must not query on the first keystroke. A filter change
resets to page 1; a page past the end falls back to the last real page. Bootstrap
only, existing PagerWindow pager retained.

The WP2.4 push coalescing is unchanged and still earns its keep: server paging shrank
what a reload costs, not how many arrive — it now bounds database round-trips rather
than table scans.

Tests: 19 new SQLite repository tests (paging slice + total, tie-break stability
across pages, page/size clamping, past-the-end, the joined projection, every filter
dimension incl. the empty-scope security case, and the grouped counts' status-blind
contract); 15 new bUnit page tests (page-1 request, server total drives the pager,
Next re-queries, tiles show server counts not page counts, tile filter + toggle,
system-wide vs site-scoped scope push, debounce collapses a keystroke burst to one
query, clear-filters, dispose with an armed timer). The two existing Deployments
suites re-point their reload assertions at the new query.

Doc: Component-CentralUI.md Deployment section — the "no server-side paging" known
residual is replaced by the shipped design.
This commit is contained in:
Joseph Doherty
2026-08-15 03:31:28 -04:00
parent 2b74851f96
commit 35ce14138c
12 changed files with 1436 additions and 139 deletions
@@ -16,11 +16,69 @@ public interface IDeploymentManagerRepository
/// <returns>The deployment record, or null if not found.</returns>
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all deployment records.
/// Database-side paged, filtered and instance-joined deployment listing —
/// the read path behind the Central UI deployment-status page.
///
/// <para>
/// Replaces a <c>GetAllDeploymentRecords</c> whole-table read. That method's only
/// caller loaded EVERY deployment record (the table is insert-only, one row per
/// deploy attempt for the retention window) plus EVERY instance, then filtered by
/// site scope, sorted, counted the status tiles and sliced a 25-row page — all in
/// the Blazor circuit's memory, on every render AND on every deployment-status
/// push. Cost scaled with the age of the system rather than the size of the page.
/// </para>
///
/// <para>
/// <b>OFFSET paging, not keyset — deliberately.</b> The sibling Audit Log and Site
/// Calls read paths are keyset-cursored, but this one is offset-paged like the
/// Notification Outbox, and for the same reason: its contract surfaces a PAGE
/// NUMBER and a TOTAL COUNT (the page renders a numbered, jump-to-any-page pager),
/// and a keyset cursor can express neither. The total is needed regardless, since
/// the status tiles are an aggregate over the same filtered set. The deep-offset
/// cost that pushes high-volume tables to keyset is bounded here by
/// <see cref="PurgeTerminalDeploymentRecordsAsync"/>, which caps the table at the
/// terminal-record retention window — unlike the 365-day central
/// <c>AuditLog</c>.
/// </para>
///
/// <para>
/// Ordering is <c>DeployedAt DESC, Id DESC</c>. The <c>Id</c> tie-break is not
/// cosmetic: <c>DeployedAt</c> ties on rapid redeploys, and an unstable sort key
/// makes offset paging non-deterministic — rows repeat across pages or vanish
/// between them.
/// </para>
/// </summary>
/// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param>
/// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param>
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentRecordSummary.MaxPageSize]</c>.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A read-only list of all deployment records.</returns>
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
/// <returns>The requested page of rows plus the total count of the filtered set.</returns>
Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync(
Types.Deployment.DeploymentListFilter filter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// One grouped aggregation returning the per-status row counts of the filtered
/// set — the server-side replacement for the deployment page's four
/// <c>records.Count(r =&gt; r.Status == X)</c> tile computations over a
/// client-materialized table.
///
/// <para>
/// <b><see cref="Types.Deployment.DeploymentListFilter.Status"/> is ignored here.</b>
/// The tiles are the status BREAKDOWN of the otherwise-identically-filtered set,
/// so constraining them by the active status filter would collapse three of the
/// four tiles to zero the moment an operator clicked one. Every other dimension
/// (search, site scope, instance) IS applied, so the tiles always describe the
/// same population the list is drawn from.
/// </para>
/// </summary>
/// <param name="filter">The active list filter; its <c>Status</c> member is deliberately not applied.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>Per-status counts, with absent statuses reported as 0.</returns>
Task<Types.Deployment.DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
Types.Deployment.DeploymentListFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Database-side paged and filtered deployment query returning row summaries.
/// Backs the <c>QueryDeployments</c> management command, which previously