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
@@ -0,0 +1,50 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Filter contract for the Central UI deployment-status list. Every dimension is
/// applied IN THE DATABASE — the page that consumes it previously loaded the whole
/// <c>DeploymentRecords</c> table plus every <c>Instance</c> and filtered, sorted,
/// counted and paged the result in the Blazor circuit's memory.
///
/// <para>
/// Message-contract evolution rule: additive-only. New dimensions go on the end
/// with a default of <see langword="null"/> meaning "unconstrained".
/// </para>
/// </summary>
/// <param name="Status">
/// Restrict to one deployment status, or <see langword="null"/> for all.
/// Deliberately IGNORED by the status-count aggregation
/// (<c>IDeploymentManagerRepository.GetDeploymentStatusCountsAsync</c>): the tiles
/// are the status breakdown of the otherwise-identically-filtered set, so a tile
/// must keep showing its own total while that same status is the active filter.
/// </param>
/// <param name="Search">
/// Free-text term matched (case-insensitively, as a substring) against the
/// instance unique name, the deployment id, the revision hash and the initiating
/// user. Blank/whitespace is treated as unconstrained.
/// </param>
/// <param name="SiteIdScope">
/// When non-null, restrict to deployments whose instance belongs to one of these
/// sites — the site-scoped user's permitted-site grant, pushed into the query
/// rather than intersected in memory. An EMPTY collection is a real, meaningful
/// filter (a user permitted no sites sees nothing) and must not be treated as
/// "no filter"; <see langword="null"/> is the system-wide case.
/// </param>
/// <param name="InstanceId">Restrict to one instance's deployment history, or <see langword="null"/> for all.</param>
public sealed record DeploymentListFilter(
DeploymentStatus? Status = null,
string? Search = null,
IReadOnlyCollection<int>? SiteIdScope = null,
int? InstanceId = null)
{
/// <summary>
/// The trimmed search term, or <see langword="null"/> when the term is absent
/// or whitespace-only. Repository implementations use this rather than
/// <see cref="Search"/> so a stray space never becomes a LIKE pattern that
/// matches nothing.
/// </summary>
public string? NormalizedSearch =>
string.IsNullOrWhiteSpace(Search) ? null : Search.Trim();
}
@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// One page of the Central UI deployment-status list plus the total row count of
/// the filtered set.
///
/// <para>
/// The count travels WITH the page because the page's pager is a numbered,
/// jump-to-any-page control: it needs a page count, which only a total can give
/// it. That is also the reason this read path is OFFSET-paged rather than keyset
/// -paged like the Audit Log — see
/// <c>IDeploymentManagerRepository.QueryDeploymentListPageAsync</c> for the full
/// rationale.
/// </para>
/// </summary>
/// <param name="Rows">The requested page of rows, newest deployment first.</param>
/// <param name="TotalCount">Total rows matching the filter across all pages.</param>
public sealed record DeploymentListPage(
IReadOnlyList<DeploymentListRow> Rows,
int TotalCount)
{
/// <summary>An empty page — no rows, no matches.</summary>
public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0);
/// <summary>
/// Number of pages the filtered set spans at the given page size; at least 1
/// so a pager always has a page to sit on. Returns 1 for a non-positive
/// <paramref name="pageSize"/> rather than dividing by zero.
/// </summary>
/// <param name="pageSize">Rows per page.</param>
/// <returns>The total page count, never less than 1.</returns>
public int PageCount(int pageSize) =>
pageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)pageSize));
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// One rendered row of the Central UI deployment-status list: a
/// <c>DeploymentRecord</c> projection already joined to its <c>Instance</c>.
///
/// <para>
/// The join is what removes the page's SECOND full-table read. Rendering a row
/// needs the instance's display name and site scoping needs its site, and the page
/// used to obtain both by materializing every instance into two dictionaries. Both
/// values now come back with the page of rows that actually needs them.
/// </para>
///
/// <para>
/// Like <see cref="DeploymentRecordSummary"/>, the <c>RowVersion</c>
/// optimistic-concurrency token is deliberately absent — this is a READ projection
/// and must never be mistaken for something that can be written back. Mutating
/// paths keep loading the tracked entity.
/// </para>
///
/// <para>
/// Message-contract evolution rule: additive-only. New fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Id">Deployment record row id.</param>
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
/// <param name="InstanceId">Instance the deployment targeted.</param>
/// <param name="InstanceUniqueName">The target instance's unique name, for display.</param>
/// <param name="SiteId">The target instance's site, the value site scoping is evaluated against.</param>
/// <param name="Status">Terminal or in-flight deployment status.</param>
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
/// <param name="DeployedBy">User who initiated the deployment.</param>
/// <param name="DeployedAt">When the deployment was initiated.</param>
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
public sealed record DeploymentListRow(
int Id,
string DeploymentId,
int InstanceId,
string InstanceUniqueName,
int SiteId,
DeploymentStatus Status,
string? RevisionHash,
string DeployedBy,
DateTimeOffset DeployedAt,
DateTimeOffset? CompletedAt,
string? ErrorMessage);
@@ -0,0 +1,78 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Server-computed deployment counts per <see cref="DeploymentStatus"/>, backing the
/// Central UI deployment-status tiles.
///
/// <para>
/// The tiles used to be <c>_records.Count(r =&gt; r.Status == X)</c> over the
/// client-materialized full table — which meant the tiles were only correct because
/// the whole table was in memory, and paging the list would silently have reduced
/// them to "counts of the visible page". Computing them DB-side with one grouped
/// query decouples the tiles from the page.
/// </para>
/// </summary>
/// <param name="Pending">Rows in <see cref="DeploymentStatus.Pending"/>.</param>
/// <param name="InProgress">Rows in <see cref="DeploymentStatus.InProgress"/>.</param>
/// <param name="Success">Rows in <see cref="DeploymentStatus.Success"/>.</param>
/// <param name="Failed">Rows in <see cref="DeploymentStatus.Failed"/>.</param>
public sealed record DeploymentStatusCounts(
int Pending,
int InProgress,
int Success,
int Failed)
{
/// <summary>All-zero counts — the shape returned for an empty or fully-excluded set.</summary>
public static DeploymentStatusCounts Empty { get; } = new(0, 0, 0, 0);
/// <summary>
/// Total across every status. Summing is exact rather than approximate because
/// <see cref="DeploymentStatus"/> is a closed set and every row carries one of
/// its members.
/// </summary>
public int Total => Pending + InProgress + Success + Failed;
/// <summary>Count for a single status.</summary>
/// <param name="status">The status to read.</param>
/// <returns>The count for <paramref name="status"/>; 0 for an unrecognised value.</returns>
public int For(DeploymentStatus status) => status switch
{
DeploymentStatus.Pending => Pending,
DeploymentStatus.InProgress => InProgress,
DeploymentStatus.Success => Success,
DeploymentStatus.Failed => Failed,
_ => 0
};
/// <summary>
/// Folds the <c>(status, count)</c> pairs a grouped database query returns into
/// the fixed-shape record. Statuses absent from the grouping — no rows in that
/// state — land as 0 rather than being missing.
/// </summary>
/// <param name="grouped">Per-status counts as returned by the aggregation query.</param>
/// <returns>The folded counts.</returns>
public static DeploymentStatusCounts FromGrouped(IEnumerable<(DeploymentStatus Status, int Count)> grouped)
{
ArgumentNullException.ThrowIfNull(grouped);
var pending = 0;
var inProgress = 0;
var success = 0;
var failed = 0;
foreach (var (status, count) in grouped)
{
switch (status)
{
case DeploymentStatus.Pending: pending += count; break;
case DeploymentStatus.InProgress: inProgress += count; break;
case DeploymentStatus.Success: success += count; break;
case DeploymentStatus.Failed: failed += count; break;
}
}
return new DeploymentStatusCounts(pending, inProgress, success, failed);
}
}