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:
+375
@@ -0,0 +1,375 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the Central UI deployment-status page's server-side read path —
|
||||
/// <see cref="DeploymentManagerRepository.QueryDeploymentListPageAsync"/> and
|
||||
/// <see cref="DeploymentManagerRepository.GetDeploymentStatusCountsAsync"/>
|
||||
/// (residual R3).
|
||||
///
|
||||
/// <para>
|
||||
/// These replace a whole-table <c>GetAllDeploymentRecords</c> read whose only
|
||||
/// caller then site-scoped, sorted, counted the status tiles and sliced a page in
|
||||
/// the Blazor circuit's memory. Every one of those jobs is asserted here to happen
|
||||
/// in SQL instead: the filter dimensions, the total count, the deterministic
|
||||
/// ordering that makes offset paging safe, and the grouped tile aggregation.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Uses the shared SQLite in-memory fixture. It enforces the
|
||||
/// <c>DeploymentRecord → Instance</c> FK, which is exactly the relationship the
|
||||
/// new query's inner join relies on, so the seeds are real Site/Template/Instance
|
||||
/// rows.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentListQueryRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly DeploymentManagerRepository _repository;
|
||||
private readonly DateTimeOffset _base = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DeploymentListQueryRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new DeploymentManagerRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task<int> SeedSiteAsync(string name)
|
||||
{
|
||||
var site = new Site(name, name);
|
||||
_context.Sites.Add(site);
|
||||
await _context.SaveChangesAsync();
|
||||
return site.Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedInstanceAsync(string uniqueName, int siteId)
|
||||
{
|
||||
var template = new Template($"T-{uniqueName}");
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance.Id;
|
||||
}
|
||||
|
||||
private async Task<DeploymentRecord> SeedRecordAsync(
|
||||
string deploymentId,
|
||||
int instanceId,
|
||||
DeploymentStatus status,
|
||||
DateTimeOffset deployedAt,
|
||||
string deployedBy = "alice",
|
||||
string? revisionHash = null)
|
||||
{
|
||||
var record = new DeploymentRecord(deploymentId, deployedBy)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
Status = status,
|
||||
DeployedAt = deployedAt,
|
||||
RevisionHash = revisionHash
|
||||
};
|
||||
_context.DeploymentRecords.Add(record);
|
||||
await _context.SaveChangesAsync();
|
||||
return record;
|
||||
}
|
||||
|
||||
// ── Paging ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_ReturnsRequestedSlice_AndTheTotalCountOfTheWholeSet()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base.AddMinutes(i));
|
||||
}
|
||||
|
||||
var page2 = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 2, pageSize: 10);
|
||||
|
||||
Assert.Equal(25, page2.TotalCount);
|
||||
Assert.Equal(10, page2.Rows.Count);
|
||||
Assert.Equal(3, page2.PageCount(10));
|
||||
|
||||
// Newest first: page 2 of 10 starts at the 11th newest, dep-014.
|
||||
Assert.Equal("dep-014", page2.Rows[0].DeploymentId);
|
||||
Assert.Equal("dep-005", page2.Rows[^1].DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_PagesDoNotOverlapOrDropRows_EvenWhenDeployedAtTies()
|
||||
{
|
||||
// Rapid redeploys write several records on the same clock tick. Without the
|
||||
// Id tie-break the sort key is unstable and offset paging repeats or drops
|
||||
// rows between pages — the failure mode the ThenByDescending(Id) prevents.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
for (var i = 0; i < 9; i++)
|
||||
{
|
||||
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base);
|
||||
}
|
||||
|
||||
var seen = new List<string>();
|
||||
for (var page = 1; page <= 3; page++)
|
||||
{
|
||||
var result = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: page, pageSize: 3);
|
||||
seen.AddRange(result.Rows.Select(r => r.DeploymentId));
|
||||
}
|
||||
|
||||
Assert.Equal(9, seen.Count);
|
||||
Assert.Equal(9, seen.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_NonPositivePageNumber_IsFlooredAtPageOne()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 0, pageSize: 10);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_OversizedPageSize_IsClampedToTheMaximum()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
// A caller asking for int.MaxValue rows must not be able to turn the paged
|
||||
// read back into the whole-table read it replaced.
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
Assert.True(DeploymentRecordSummary.MaxPageSize > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_PastTheEnd_ReturnsNoRowsButStillReportsTheTotal()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 9, pageSize: 25);
|
||||
|
||||
Assert.Empty(page.Rows);
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
}
|
||||
|
||||
// ── Joined projection ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_JoinsTheInstance_SoNameAndSiteTravelWithTheRow()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Line3.Filler", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Failed, _base,
|
||||
deployedBy: "bob", revisionHash: "abc123");
|
||||
|
||||
var row = Assert.Single(
|
||||
(await _repository.QueryDeploymentListPageAsync(new DeploymentListFilter(), 1, 25)).Rows);
|
||||
|
||||
Assert.Equal("Line3.Filler", row.InstanceUniqueName);
|
||||
Assert.Equal(siteId, row.SiteId);
|
||||
Assert.Equal(instanceId, row.InstanceId);
|
||||
Assert.Equal("dep-000", row.DeploymentId);
|
||||
Assert.Equal(DeploymentStatus.Failed, row.Status);
|
||||
Assert.Equal("bob", row.DeployedBy);
|
||||
Assert.Equal("abc123", row.RevisionHash);
|
||||
}
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_StatusFilter_IsAppliedInTheDatabase()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-ok", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-bad", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Status: DeploymentStatus.Failed), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal("dep-bad", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_InstanceFilter_RestrictsToOneInstanceHistory()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var a = await SeedInstanceAsync("Inst-A", siteId);
|
||||
var b = await SeedInstanceAsync("Inst-B", siteId);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(InstanceId: b), 1, 25);
|
||||
|
||||
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Filler", "dep-a")] // instance unique name
|
||||
[InlineData("dep-b", "dep-b")] // deployment id
|
||||
[InlineData("carol", "dep-b")] // initiating user
|
||||
[InlineData("rev-a", "dep-a")] // revision hash
|
||||
public async Task QueryPage_Search_MatchesAcrossTheFindableIdentifiers(string term, string expected)
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var a = await SeedInstanceAsync("Line3.Filler", siteId);
|
||||
var b = await SeedInstanceAsync("Line4.Capper", siteId);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base, deployedBy: "alice", revisionHash: "rev-a1");
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1), deployedBy: "carol", revisionHash: "rev-b1");
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: term), 1, 25);
|
||||
|
||||
Assert.Equal(expected, Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_WhitespaceSearch_IsTreatedAsUnconstrained()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: " "), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_SiteScope_RestrictsToTheGrantedSites()
|
||||
{
|
||||
var s1 = await SeedSiteAsync("S1");
|
||||
var s2 = await SeedSiteAsync("S2");
|
||||
var a = await SeedInstanceAsync("Inst-A", s1);
|
||||
var b = await SeedInstanceAsync("Inst-B", s2);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1));
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(SiteIdScope: new[] { s2 }), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_EmptySiteScope_IsARealFilterThatMatchesNothing()
|
||||
{
|
||||
// A scoped user granted no sites must see nothing — an empty scope must NOT
|
||||
// degrade into "unconstrained", which would leak every site's deployments.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(SiteIdScope: Array.Empty<int>()), 1, 25);
|
||||
|
||||
Assert.Equal(0, page.TotalCount);
|
||||
Assert.Empty(page.Rows);
|
||||
}
|
||||
|
||||
// ── Status counts ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_GroupsEveryStatus_WithAbsentOnesReportedAsZero()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Success, _base.AddMinutes(1));
|
||||
await SeedRecordAsync("dep-3", instanceId, DeploymentStatus.Failed, _base.AddMinutes(2));
|
||||
await SeedRecordAsync("dep-4", instanceId, DeploymentStatus.InProgress, _base.AddMinutes(3));
|
||||
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
||||
|
||||
Assert.Equal(0, counts.Pending);
|
||||
Assert.Equal(1, counts.InProgress);
|
||||
Assert.Equal(2, counts.Success);
|
||||
Assert.Equal(1, counts.Failed);
|
||||
Assert.Equal(4, counts.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_IgnoreTheStatusFilter_SoEachTileKeepsItsOwnTotal()
|
||||
{
|
||||
// The tiles are the status BREAKDOWN of the filtered set. Honouring the
|
||||
// active status filter here would zero the other three tiles the moment an
|
||||
// operator clicked one.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(Status: DeploymentStatus.Failed));
|
||||
|
||||
Assert.Equal(1, counts.Success);
|
||||
Assert.Equal(1, counts.Failed);
|
||||
Assert.Equal(2, counts.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_HonourEveryOtherFilterDimension()
|
||||
{
|
||||
var s1 = await SeedSiteAsync("S1");
|
||||
var s2 = await SeedSiteAsync("S2");
|
||||
var a = await SeedInstanceAsync("Inst-A", s1);
|
||||
var b = await SeedInstanceAsync("Inst-B", s2);
|
||||
await SeedRecordAsync("dep-a1", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-a2", a, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
await SeedRecordAsync("dep-b1", b, DeploymentStatus.Success, _base.AddMinutes(2));
|
||||
|
||||
var scoped = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(SiteIdScope: new[] { s1 }));
|
||||
Assert.Equal(2, scoped.Total);
|
||||
|
||||
var searched = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(Search: "Inst-B"));
|
||||
Assert.Equal(1, searched.Total);
|
||||
Assert.Equal(1, searched.Success);
|
||||
|
||||
var byInstance = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(InstanceId: a));
|
||||
Assert.Equal(2, byInstance.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_EmptySet_IsAllZeros()
|
||||
{
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
||||
|
||||
Assert.Equal(DeploymentStatusCounts.Empty, counts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user