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:
+146
-4
@@ -36,12 +36,131 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Shared filtered join behind <see cref="QueryDeploymentListPageAsync"/> and
|
||||
/// <see cref="GetDeploymentStatusCountsAsync"/>, so the two reads that back one
|
||||
/// screen can never disagree about which rows they describe.
|
||||
///
|
||||
/// <para>
|
||||
/// The join to <c>Instance</c> is an INNER join, which is exact rather than
|
||||
/// merely convenient: <c>DeploymentRecord</c> has a <c>Restrict</c> FK to
|
||||
/// <c>Instance</c> and <see cref="DeleteInstanceAsync"/> removes the records
|
||||
/// before the instance, so an orphaned deployment record cannot exist. The join
|
||||
/// is what lets site scoping run in SQL — <c>DeploymentRecord</c> itself has no
|
||||
/// <c>SiteId</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter to apply.</param>
|
||||
/// <param name="applyStatus">
|
||||
/// When <see langword="false"/>, the status constraint is skipped — the
|
||||
/// status-count aggregation needs every status of the otherwise-identical set.
|
||||
/// </param>
|
||||
/// <returns>The composed, unordered, untracked query.</returns>
|
||||
private IQueryable<DeploymentInstanceJoin> BuildListQuery(DeploymentListFilter filter, bool applyStatus)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
var query =
|
||||
from d in _dbContext.DeploymentRecords.AsNoTracking()
|
||||
join i in _dbContext.Set<Instance>().AsNoTracking() on d.InstanceId equals i.Id
|
||||
select new DeploymentInstanceJoin
|
||||
{
|
||||
Id = d.Id,
|
||||
DeploymentId = d.DeploymentId,
|
||||
InstanceId = d.InstanceId,
|
||||
InstanceUniqueName = i.UniqueName,
|
||||
SiteId = i.SiteId,
|
||||
Status = d.Status,
|
||||
RevisionHash = d.RevisionHash,
|
||||
DeployedBy = d.DeployedBy,
|
||||
DeployedAt = d.DeployedAt,
|
||||
CompletedAt = d.CompletedAt,
|
||||
ErrorMessage = d.ErrorMessage
|
||||
};
|
||||
|
||||
if (filter.InstanceId.HasValue)
|
||||
query = query.Where(r => r.InstanceId == filter.InstanceId.Value);
|
||||
|
||||
if (applyStatus && filter.Status.HasValue)
|
||||
query = query.Where(r => r.Status == filter.Status.Value);
|
||||
|
||||
if (filter.SiteIdScope != null)
|
||||
{
|
||||
// An EMPTY scope is a real filter — a user permitted no sites sees
|
||||
// nothing — so it must not be short-circuited into "no filter".
|
||||
var scope = filter.SiteIdScope as int[] ?? filter.SiteIdScope.ToArray();
|
||||
query = query.Where(r => scope.Contains(r.SiteId));
|
||||
}
|
||||
|
||||
if (filter.NormalizedSearch is { } search)
|
||||
{
|
||||
// Substring match across the four identifiers a deployment is findable
|
||||
// by. Translated to LIKE '%term%' by both providers; case-insensitivity
|
||||
// is the database collation's (SQL Server default CI, SQLite ASCII LIKE).
|
||||
query = query.Where(r =>
|
||||
r.InstanceUniqueName.Contains(search)
|
||||
|| r.DeploymentId.Contains(search)
|
||||
|| r.DeployedBy.Contains(search)
|
||||
|| (r.RevisionHash != null && r.RevisionHash.Contains(search)));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentListPage> QueryDeploymentListPageAsync(
|
||||
DeploymentListFilter filter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize);
|
||||
|
||||
var query = BuildListQuery(filter, applyStatus: true);
|
||||
|
||||
// Count first: the pager needs a page count, and the caller must be able to
|
||||
// tell "page 7 is now past the end" (a concurrent purge or a narrowed filter)
|
||||
// from "page 7 is empty".
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// DeployedAt ties on rapid redeploys; without the Id tie-break the sort key
|
||||
// is unstable and offset paging repeats or drops rows between pages.
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.DeployedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.Select(r => new DeploymentListRow(
|
||||
r.Id,
|
||||
r.DeploymentId,
|
||||
r.InstanceId,
|
||||
r.InstanceUniqueName,
|
||||
r.SiteId,
|
||||
r.Status,
|
||||
r.RevisionHash,
|
||||
r.DeployedBy,
|
||||
r.DeployedAt,
|
||||
r.CompletedAt,
|
||||
r.ErrorMessage))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new DeploymentListPage(rows, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
|
||||
DeploymentListFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// applyStatus: false — the tiles are the status breakdown of the filtered
|
||||
// set, so the active status filter is the one dimension they must NOT honour.
|
||||
var grouped = await BuildListQuery(filter, applyStatus: false)
|
||||
.GroupBy(r => r.Status)
|
||||
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return DeploymentStatusCounts.FromGrouped(grouped.Select(g => (g.Status, g.Count)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -483,4 +602,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flat shape of the deployment-record ⋈ instance join used to compose the list
|
||||
/// query. A named type rather than an anonymous one so
|
||||
/// <see cref="BuildListQuery"/> can hand the composed-but-unexecuted query back
|
||||
/// to both the paging read and the count aggregation, keeping the two in
|
||||
/// lockstep. Not a public contract — <see cref="DeploymentListRow"/> is what
|
||||
/// leaves this class.
|
||||
/// </summary>
|
||||
private sealed class DeploymentInstanceJoin
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string DeploymentId { get; init; } = string.Empty;
|
||||
public int InstanceId { get; init; }
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public int SiteId { get; init; }
|
||||
public DeploymentStatus Status { get; init; }
|
||||
public string? RevisionHash { get; init; }
|
||||
public string DeployedBy { get; init; } = string.Empty;
|
||||
public DateTimeOffset DeployedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user