From 35ce14138ccdb5b4e94f4296189c141e1051e36d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 15 Aug 2026 03:31:28 -0400 Subject: [PATCH] feat(centralui): server-side paging + server-computed status counts on Deployments (R3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + + } +
+ +
+ + + @* ── Filters ── *@ +
+
+
+
+ + +
+
+ +
+
+ + @_totalCount matching @(_totalCount == 1 ? "deployment" : "deployments") + +
+
+
+
+ @if (_loading) { @@ -33,46 +101,10 @@ } else { - @* Summary cards *@ -
-
-
-
-

@_records.Count(r => r.Status == DeploymentStatus.Pending)

- Pending -
-
-
-
-
-
-

@_records.Count(r => r.Status == DeploymentStatus.InProgress)

- In Progress -
-
-
-
-
-
-

@_records.Count(r => r.Status == DeploymentStatus.Success)

- Successful -
-
-
-
-
-
-

@_records.Count(r => r.Status == DeploymentStatus.Failed)

- Failed -
-
-
-
- - @if (_records.Count == 0) + @if (_rows.Count == 0) {
-

No deployments recorded.

+

@(HasActiveFilter ? "No deployments match the current filters." : "No deployments recorded.")

} else @@ -90,7 +122,7 @@ - @foreach (var record in _pagedRecords) + @foreach (var record in _rows) { var rowId = $"deploy-row-{record.DeploymentId}"; var errorCollapseId = $"deploy-err-{record.DeploymentId}"; @@ -108,7 +140,7 @@
@revShort
} - @GetInstanceName(record.InstanceId) + @record.InstanceUniqueName @if (isFailed) { @@ -171,7 +203,7 @@ @@ -199,9 +231,25 @@ @code { - private List _records = new(); - private List _pagedRecords = new(); - private Dictionary _instanceNames = new(); + // ── Server-side paging (residual R3) ────────────────────────────────────── + // The page used to read EVERY deployment record and EVERY instance, then + // site-scope, sort, count the tiles and slice a 25-row page in the circuit's + // memory — on first render AND on every deployment-status push. The cost + // scaled with the age of the system (DeploymentRecords is insert-only, one row + // per deploy attempt) rather than the size of the page. All four of those jobs + // are now SQL: QueryDeploymentListPageAsync returns one page joined to its + // instances plus the filtered total, and GetDeploymentStatusCountsAsync returns + // the tile counts from one grouped aggregation. + // + // Paging is OFFSET, not the Audit Log's keyset cursor, because this page's + // pager is numbered and jump-to-any-page: it needs a page count, which only a + // total can give it, and a keyset cursor cannot express either. The total is + // required for the tiles regardless. See QueryDeploymentListPageAsync's + // remarks for the full rationale. + private IReadOnlyList _rows = Array.Empty(); + private DeploymentStatusCounts _counts = DeploymentStatusCounts.Empty; + private int _totalCount; + private bool _loading = true; private string? _errorMessage; private bool _autoRefresh = true; @@ -211,6 +259,44 @@ private int _totalPages; private const int PageSize = 25; + // ── Filters ─────────────────────────────────────────────────────────────── + private DeploymentStatus? _statusFilter; + + /// Raw contents of the search box, updated on every keystroke. + private string _searchInput = string.Empty; + + /// + /// The search term the last query actually ran with. Distinct from + /// so the debounce window can absorb keystrokes + /// without the rendered result count implying a query that has not run. + /// + private string _appliedSearch = string.Empty; + + /// + /// Whether the CURRENTLY RENDERED result set is constrained. Keyed on the + /// applied search, not the box contents, so the empty-state message describes + /// the query that actually ran. + /// + private bool HasActiveFilter => _statusFilter is not null || !string.IsNullOrWhiteSpace(_appliedSearch); + + /// + /// Whether "Clear filters" has anything to clear. Keyed on the box CONTENTS as + /// well, so the escape hatch is live the moment the operator types — it must not + /// be dead for the 500 ms the debounce window is still open. + /// + private bool CanClearFilters => HasActiveFilter || !string.IsNullOrWhiteSpace(_searchInput); + + /// Tile definitions, in display order. The "All" tile is rendered separately. + private static readonly (DeploymentStatus Status, string Label, string Variant, string TestId)[] StatusTiles = + { + (DeploymentStatus.Pending, "Pending", "warning", "deploy-tile-pending"), + (DeploymentStatus.InProgress, "In Progress", "info", "deploy-tile-inprogress"), + (DeploymentStatus.Success, "Successful", "success", "deploy-tile-success"), + (DeploymentStatus.Failed, "Failed", "danger", "deploy-tile-failed"), + }; + + private int CountFor(DeploymentStatus status) => _counts.For(status); + // CentralUI-022: IDeploymentStatusNotifier is a process singleton that // raises StatusChanged on the DeploymentManager service thread. Dispose() // unsubscribes, but the notifier can read its subscriber list and begin @@ -237,12 +323,13 @@ } // ── Push coalescing (arch-review WP2.4) ─────────────────────────────────── - // One reload = every deployment record + every instance, re-read and re-filtered. // The notifier fires per status WRITE, so a site deploy of N instances produced - // 2N+ of those full reloads back to back, per circuit. The reload is now - // leading-edge debounced: the first push after an idle gap still reloads - // immediately (a single deployment stays as responsive as before), and every - // push inside the window is absorbed into ONE trailing reload. + // 2N+ back-to-back reloads per circuit. The reload is leading-edge debounced: + // the first push after an idle gap still reloads immediately (a single + // deployment stays as responsive as before), and every push inside the window + // is absorbed into ONE trailing reload. Server-side paging shrank what a reload + // costs, but it did not change how MANY arrive — the coalescing still earns its + // keep, and now bounds round-trips to the database rather than table scans. private const int ReloadDebounceMs = 500; private DateTimeOffset _lastReloadAt = DateTimeOffset.MinValue; @@ -296,6 +383,52 @@ private readonly object _coalesceLock = new(); + // ── Filter debounce ─────────────────────────────────────────────────────── + // Same Timer + lock + _disposed idiom as the push coalescer above, but TRAILING + // ONLY: a search box must not query on the first keystroke, so there is no + // leading edge. Each keystroke re-arms the window, and the query runs once the + // operator stops typing for FilterDebounceMs. Without this, every character of + // a 20-character instance name would be one paged query plus one count + // aggregation against central MS SQL. + private const int FilterDebounceMs = 500; + + /// Trailing-edge timer for search-box input. Disposed with the component. + private Timer? _filterDebounceTimer; + + private readonly object _filterLock = new(); + + private void OnSearchInput(ChangeEventArgs e) + { + _searchInput = e.Value?.ToString() ?? string.Empty; + + lock (_filterLock) + { + if (_disposed) return; + + // Re-arm rather than stack: one pending query per idle gap, not one per + // keystroke. + _filterDebounceTimer?.Dispose(); + _filterDebounceTimer = new Timer( + _ => OnFilterDebounceElapsed(), null, + TimeSpan.FromMilliseconds(FilterDebounceMs), Timeout.InfiniteTimeSpan); + } + } + + private void OnFilterDebounceElapsed() + { + lock (_filterLock) + { + _filterDebounceTimer?.Dispose(); + _filterDebounceTimer = null; + if (_disposed) return; + } + + // A changed search term redefines the result set, so the operator's current + // page number no longer refers to anything — restart at page 1. + _currentPage = 1; + _ = DispatchReloadAsync(); + } + /// /// Reloads the deployment table on the renderer's dispatcher, guarded /// against the component being disposed mid-flight (CentralUI-022): @@ -338,33 +471,78 @@ } } + private async Task SetStatusFilterAsync(DeploymentStatus? status) + { + // Clicking the active tile clears it — a toggle, so the tiles are both the + // readout and the control without needing a separate "clear" affordance. + _statusFilter = _statusFilter == status ? null : status; + _currentPage = 1; + await LoadDataAsync(); + } + + private async Task ClearFiltersAsync() + { + _statusFilter = null; + _searchInput = string.Empty; + _appliedSearch = string.Empty; + _currentPage = 1; + + // A cleared box must not be re-queried by an armed keystroke timer. + lock (_filterLock) + { + _filterDebounceTimer?.Dispose(); + _filterDebounceTimer = null; + } + + await LoadDataAsync(); + } + private async Task LoadDataAsync() { - _loading = _records.Count == 0; // Only show loading on first load + _loading = _rows.Count == 0; // Only show loading on first load _errorMessage = null; try { - // Build instance lookups first — site scoping (CentralUI-002) filters - // deployment records by the site of their instance. - var instances = await TemplateEngineRepository.GetAllInstancesAsync(); - _instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName); - var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId); - + // Site scoping (CentralUI-002) is pushed into the query as the set of + // permitted site ids and resolved through the deployment record's + // instance, rather than by loading every instance to build an + // InstanceId → SiteId map. A system-wide user passes null (no filter); + // a scoped user's EMPTY grant is a real filter that matches nothing. var systemWide = await SiteScope.IsSystemWideAsync(); - var permittedSiteIds = systemWide + IReadOnlyCollection? siteScope = systemWide ? null - : await SiteScope.PermittedSiteIdsAsync(); + : (await SiteScope.PermittedSiteIdsAsync()).ToArray(); - _records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync()) - .Where(r => permittedSiteIds == null - || (instanceSiteIds.TryGetValue(r.InstanceId, out var sid) - && permittedSiteIds.Contains(sid))) - .OrderByDescending(r => r.DeployedAt) - .ToList(); + _appliedSearch = _searchInput.Trim(); + var filter = new DeploymentListFilter( + Status: _statusFilter, + Search: _appliedSearch, + SiteIdScope: siteScope); - _totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize)); - if (_currentPage > _totalPages) _currentPage = 1; - UpdatePage(); + var page = await DeploymentManagerRepository + .QueryDeploymentListPageAsync(filter, _currentPage, PageSize); + + // The tile counts deliberately ignore the status filter (see + // GetDeploymentStatusCountsAsync) so each tile keeps showing its own + // total while it is the one selected. + _counts = await DeploymentManagerRepository.GetDeploymentStatusCountsAsync(filter); + + _rows = page.Rows; + _totalCount = page.TotalCount; + _totalPages = page.PageCount(PageSize); + + // A concurrent retention purge, or a filter that narrowed the set, can + // leave the operator past the end. Fall back to the last real page and + // re-query rather than rendering a blank table with a live pager. + if (_currentPage > _totalPages && _totalPages >= 1) + { + _currentPage = _totalPages; + var reread = await DeploymentManagerRepository + .QueryDeploymentListPageAsync(filter, _currentPage, PageSize); + _rows = reread.Rows; + _totalCount = reread.TotalCount; + _totalPages = reread.PageCount(PageSize); + } } catch (Exception ex) { @@ -373,24 +551,13 @@ _loading = false; } - private void GoToPage(int page) + private async Task GoToPageAsync(int page) { - if (page < 1 || page > _totalPages) return; + if (page < 1 || page > _totalPages || page == _currentPage) return; _currentPage = page; - UpdatePage(); + await LoadDataAsync(); } - private void UpdatePage() - { - _pagedRecords = _records - .Skip((_currentPage - 1) * PageSize) - .Take(PageSize) - .ToList(); - } - - private string GetInstanceName(int instanceId) => - _instanceNames.GetValueOrDefault(instanceId, $"#{instanceId}"); - private static string GetStatusBadge(DeploymentStatus status) => status switch { DeploymentStatus.Pending => "bg-warning text-dark", @@ -419,5 +586,10 @@ _coalesceTimer?.Dispose(); _coalesceTimer = null; } + lock (_filterLock) + { + _filterDebounceTimer?.Dispose(); + _filterDebounceTimer = null; + } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs index b7c59ced..a2d528d0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IDeploymentManagerRepository.cs @@ -16,11 +16,69 @@ public interface IDeploymentManagerRepository /// The deployment record, or null if not found. Task GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default); /// - /// Gets all deployment records. + /// Database-side paged, filtered and instance-joined deployment listing — + /// the read path behind the Central UI deployment-status page. + /// + /// + /// Replaces a GetAllDeploymentRecords 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. + /// + /// + /// + /// OFFSET paging, not keyset — deliberately. 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 + /// , which caps the table at the + /// terminal-record retention window — unlike the 365-day central + /// AuditLog. + /// + /// + /// + /// Ordering is DeployedAt DESC, Id DESC. The Id tie-break is not + /// cosmetic: DeployedAt ties on rapid redeploys, and an unstable sort key + /// makes offset paging non-deterministic — rows repeat across pages or vanish + /// between them. + /// /// + /// Status / search / site-scope / instance constraints; all applied DB-side. + /// 1-based page number; values below 1 are floored at 1. + /// Rows per page; clamped to [1, DeploymentRecordSummary.MaxPageSize]. /// A cancellation token that can be used to cancel the operation. - /// A read-only list of all deployment records. - Task> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default); + /// The requested page of rows plus the total count of the filtered set. + Task QueryDeploymentListPageAsync( + Types.Deployment.DeploymentListFilter filter, + int pageNumber, + int pageSize, + CancellationToken cancellationToken = default); + /// + /// One grouped aggregation returning the per-status row counts of the filtered + /// set — the server-side replacement for the deployment page's four + /// records.Count(r => r.Status == X) tile computations over a + /// client-materialized table. + /// + /// + /// is ignored here. + /// 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. + /// + /// + /// The active list filter; its Status member is deliberately not applied. + /// A cancellation token that can be used to cancel the operation. + /// Per-status counts, with absent statuses reported as 0. + Task GetDeploymentStatusCountsAsync( + Types.Deployment.DeploymentListFilter filter, + CancellationToken cancellationToken = default); /// /// Database-side paged and filtered deployment query returning row summaries. /// Backs the QueryDeployments management command, which previously diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListFilter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListFilter.cs new file mode 100644 index 00000000..ea2cb8ae --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListFilter.cs @@ -0,0 +1,50 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +/// +/// 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 +/// DeploymentRecords table plus every Instance and filtered, sorted, +/// counted and paged the result in the Blazor circuit's memory. +/// +/// +/// Message-contract evolution rule: additive-only. New dimensions go on the end +/// with a default of meaning "unconstrained". +/// +/// +/// +/// Restrict to one deployment status, or for all. +/// Deliberately IGNORED by the status-count aggregation +/// (IDeploymentManagerRepository.GetDeploymentStatusCountsAsync): 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. +/// +/// +/// 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. +/// +/// +/// 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"; is the system-wide case. +/// +/// Restrict to one instance's deployment history, or for all. +public sealed record DeploymentListFilter( + DeploymentStatus? Status = null, + string? Search = null, + IReadOnlyCollection? SiteIdScope = null, + int? InstanceId = null) +{ + /// + /// The trimmed search term, or when the term is absent + /// or whitespace-only. Repository implementations use this rather than + /// so a stray space never becomes a LIKE pattern that + /// matches nothing. + /// + public string? NormalizedSearch => + string.IsNullOrWhiteSpace(Search) ? null : Search.Trim(); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListPage.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListPage.cs new file mode 100644 index 00000000..81e23f85 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListPage.cs @@ -0,0 +1,34 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +/// +/// One page of the Central UI deployment-status list plus the total row count of +/// the filtered set. +/// +/// +/// 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 +/// IDeploymentManagerRepository.QueryDeploymentListPageAsync for the full +/// rationale. +/// +/// +/// The requested page of rows, newest deployment first. +/// Total rows matching the filter across all pages. +public sealed record DeploymentListPage( + IReadOnlyList Rows, + int TotalCount) +{ + /// An empty page — no rows, no matches. + public static DeploymentListPage Empty { get; } = new(Array.Empty(), 0); + + /// + /// 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 + /// rather than dividing by zero. + /// + /// Rows per page. + /// The total page count, never less than 1. + public int PageCount(int pageSize) => + pageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)pageSize)); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListRow.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListRow.cs new file mode 100644 index 00000000..d78c1a65 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentListRow.cs @@ -0,0 +1,50 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +/// +/// One rendered row of the Central UI deployment-status list: a +/// DeploymentRecord projection already joined to its Instance. +/// +/// +/// 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. +/// +/// +/// +/// Like , the RowVersion +/// 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. +/// +/// +/// +/// Message-contract evolution rule: additive-only. New fields go on the end with a +/// default. +/// +/// +/// Deployment record row id. +/// The logical deployment id (GUID, "N" format). +/// Instance the deployment targeted. +/// The target instance's unique name, for display. +/// The target instance's site, the value site scoping is evaluated against. +/// Terminal or in-flight deployment status. +/// Revision hash of the deployed configuration. +/// User who initiated the deployment. +/// When the deployment was initiated. +/// When the deployment reached a terminal status, if it has. +/// Failure detail when the deployment did not succeed. +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); diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentStatusCounts.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentStatusCounts.cs new file mode 100644 index 00000000..79494b11 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Deployment/DeploymentStatusCounts.cs @@ -0,0 +1,78 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; + +/// +/// Server-computed deployment counts per , backing the +/// Central UI deployment-status tiles. +/// +/// +/// The tiles used to be _records.Count(r => r.Status == X) 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. +/// +/// +/// Rows in . +/// Rows in . +/// Rows in . +/// Rows in . +public sealed record DeploymentStatusCounts( + int Pending, + int InProgress, + int Success, + int Failed) +{ + /// All-zero counts — the shape returned for an empty or fully-excluded set. + public static DeploymentStatusCounts Empty { get; } = new(0, 0, 0, 0); + + /// + /// Total across every status. Summing is exact rather than approximate because + /// is a closed set and every row carries one of + /// its members. + /// + public int Total => Pending + InProgress + Success + Failed; + + /// Count for a single status. + /// The status to read. + /// The count for ; 0 for an unrecognised value. + public int For(DeploymentStatus status) => status switch + { + DeploymentStatus.Pending => Pending, + DeploymentStatus.InProgress => InProgress, + DeploymentStatus.Success => Success, + DeploymentStatus.Failed => Failed, + _ => 0 + }; + + /// + /// Folds the (status, count) 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. + /// + /// Per-status counts as returned by the aggregation query. + /// The folded counts. + 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); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs index 0383adcf..a938a1cd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs @@ -36,12 +36,131 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken); } - /// - public async Task> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default) + /// + /// Shared filtered join behind and + /// , so the two reads that back one + /// screen can never disagree about which rows they describe. + /// + /// + /// The join to Instance is an INNER join, which is exact rather than + /// merely convenient: DeploymentRecord has a Restrict FK to + /// Instance and removes the records + /// before the instance, so an orphaned deployment record cannot exist. The join + /// is what lets site scoping run in SQL — DeploymentRecord itself has no + /// SiteId. + /// + /// + /// Filter to apply. + /// + /// When , the status constraint is skipped — the + /// status-count aggregation needs every status of the otherwise-identical set. + /// + /// The composed, unordered, untracked query. + private IQueryable 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().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; + } + + /// + public async Task 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); + } + + /// + public async Task 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))); } /// @@ -483,4 +602,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository { return await _dbContext.SaveChangesAsync(cancellationToken); } + + /// + /// Flat shape of the deployment-record ⋈ instance join used to compose the list + /// query. A named type rather than an anonymous one so + /// 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 — is what + /// leaves this class. + /// + 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; } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs index 76a665d2..ea6dfac3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsPushUpdateTests.cs @@ -6,9 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; -using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; -using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; @@ -23,29 +22,32 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// the timer and subscribes to , which /// DeploymentService raises on every deployment-record status write; /// Blazor Server then pushes the re-render over its SignalR circuit. +/// +/// +/// The "did it reload?" assertions moved from GetAllDeploymentRecordsAsync +/// to QueryDeploymentListPageAsync when the page's read path went +/// server-paged (residual R3) — the push mechanism under test is unchanged, only +/// the query it drives. +/// /// public class DeploymentsPushUpdateTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; - private ITemplateEngineRepository _templateRepo = null!; private DeploymentStatusNotifier _notifier = null!; private void RegisterServices() { _deployRepo = Substitute.For(); - _templateRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); - _templateRepo.GetAllInstancesAsync(Arg.Any()) - .Returns(new List - { - new("Inst-1") { Id = 1, SiteId = 1 } - }); - _deployRepo.GetAllDeploymentRecordsAsync(Arg.Any()) - .Returns(new List()); + _deployRepo.QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(DeploymentListPage.Empty); + _deployRepo.GetDeploymentStatusCountsAsync( + Arg.Any(), Arg.Any()) + .Returns(DeploymentStatusCounts.Empty); Services.AddSingleton(_deployRepo); - Services.AddSingleton(_templateRepo); Services.AddSingleton(_notifier); var identity = new ClaimsIdentity( @@ -80,9 +82,8 @@ public class DeploymentsPushUpdateTests : BunitContext RegisterServices(); var cut = Render(); - // Initial load: instances + records each fetched once. + // Initial load: the paged query is issued once. _deployRepo.ClearReceivedCalls(); - _templateRepo.ClearReceivedCalls(); // A deployment status write in DeploymentManager raises the notifier; // the page must reload in response (no polling timer involved). @@ -90,7 +91,8 @@ public class DeploymentsPushUpdateTests : BunitContext new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)); cut.WaitForAssertion(() => - _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + _deployRepo.Received().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())); } [Fact] @@ -106,8 +108,8 @@ public class DeploymentsPushUpdateTests : BunitContext _notifier.NotifyStatusChanged( new DeploymentStatusChange("dep-2", 1, DeploymentStatus.Failed)); - _deployRepo.DidNotReceive() - .GetAllDeploymentRecordsAsync(Arg.Any()); + _deployRepo.DidNotReceive().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// @@ -150,7 +152,7 @@ public class DeploymentsPushUpdateTests : BunitContext Assert.Null(ex); // The guard short-circuits before any reload is attempted. - _deployRepo.DidNotReceive() - .GetAllDeploymentRecordsAsync(Arg.Any()); + _deployRepo.DidNotReceive().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs index 83e578b5..92ca8645 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsReloadDebounceTests.cs @@ -5,9 +5,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; -using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; -using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DeploymentManager; using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; @@ -15,31 +14,36 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// -/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). One -/// notifier callback reloads EVERY deployment record plus EVERY instance, and the notifier -/// fires per status write — so a multi-instance deploy produced a stampede of full reloads -/// per circuit. The reload is now leading-edge debounced: the first push after an idle gap -/// is still immediate, and a burst behind it collapses into one trailing reload. +/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). The +/// notifier fires per status write, so a multi-instance deploy produced a stampede of +/// reloads per circuit. The reload is leading-edge debounced: the first push after an +/// idle gap is still immediate, and a burst behind it collapses into one trailing reload. +/// +/// +/// Server-side paging (residual R3) shrank what one reload costs but not how many +/// arrive, so the coalescing still has to hold. The assertions now count +/// QueryDeploymentListPageAsync calls instead of the deleted +/// GetAllDeploymentRecordsAsync. +/// /// public class DeploymentsReloadDebounceTests : BunitContext { private IDeploymentManagerRepository _deployRepo = null!; - private ITemplateEngineRepository _templateRepo = null!; private DeploymentStatusNotifier _notifier = null!; private void RegisterServices() { _deployRepo = Substitute.For(); - _templateRepo = Substitute.For(); _notifier = new DeploymentStatusNotifier(NullLogger.Instance); - _templateRepo.GetAllInstancesAsync(Arg.Any()) - .Returns(new List { new("Inst-1") { Id = 1, SiteId = 1 } }); - _deployRepo.GetAllDeploymentRecordsAsync(Arg.Any()) - .Returns(new List()); + _deployRepo.QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(DeploymentListPage.Empty); + _deployRepo.GetDeploymentStatusCountsAsync( + Arg.Any(), Arg.Any()) + .Returns(DeploymentStatusCounts.Empty); Services.AddSingleton(_deployRepo); - Services.AddSingleton(_templateRepo); Services.AddSingleton(_notifier); var identity = new ClaimsIdentity( @@ -58,13 +62,17 @@ public class DeploymentsReloadDebounceTests : BunitContext => Task.FromResult(_state); } + private void AssertReloaded(IRenderedComponent cut) => + cut.WaitForAssertion(() => + _deployRepo.Received().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())); + [Fact] public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads() { RegisterServices(); var cut = Render(); - cut.WaitForAssertion(() => - _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + AssertReloaded(cut); _deployRepo.ClearReceivedCalls(); // A 40-instance site deploy: every status write raises the notifier. @@ -75,14 +83,13 @@ public class DeploymentsReloadDebounceTests : BunitContext } // Leading edge fires at once; the rest ride one trailing reload. - cut.WaitForAssertion(() => - _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + AssertReloaded(cut); // Let the trailing window close before counting. Thread.Sleep(900); var reloads = _deployRepo.ReceivedCalls() - .Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.GetAllDeploymentRecordsAsync)); + .Count(c => c.GetMethodInfo().Name == nameof(IDeploymentManagerRepository.QueryDeploymentListPageAsync)); Assert.InRange(reloads, 1, 4); } @@ -91,8 +98,7 @@ public class DeploymentsReloadDebounceTests : BunitContext { RegisterServices(); var cut = Render(); - cut.WaitForAssertion(() => - _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + AssertReloaded(cut); _deployRepo.ClearReceivedCalls(); // Idle since the initial load — the leading edge must not wait out the window. @@ -101,7 +107,8 @@ public class DeploymentsReloadDebounceTests : BunitContext new DeploymentStatusChange("dep-1", 1, DeploymentStatus.Success)); cut.WaitForAssertion( - () => _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any()), + () => _deployRepo.Received().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()), TimeSpan.FromMilliseconds(400)); } @@ -110,8 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext { RegisterServices(); var cut = Render(); - cut.WaitForAssertion(() => - _deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any())); + AssertReloaded(cut); // Two pushes: the first takes the leading edge, the second arms the trailing timer. _notifier.NotifyStatusChanged(new DeploymentStatusChange("dep-1", 1, DeploymentStatus.InProgress)); @@ -122,6 +128,7 @@ public class DeploymentsReloadDebounceTests : BunitContext // The armed timer must be disposed with the component, not fire against it. Thread.Sleep(900); - _deployRepo.DidNotReceive().GetAllDeploymentRecordsAsync(Arg.Any()); + _deployRepo.DidNotReceive().QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs new file mode 100644 index 00000000..3bcd1396 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DeploymentsServerPagingTests.cs @@ -0,0 +1,323 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.Security; +using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Deployments; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Coverage for the Deployment Status page's server-side paging, server-computed +/// status tiles and debounced filter input (residual R3). +/// +/// +/// Before this change the page read EVERY deployment record and EVERY instance on +/// each load, then site-scoped, sorted, counted the four tiles and sliced a 25-row +/// page in the Blazor circuit's memory. These tests pin the replacement contract: +/// the page asks the repository for ONE page plus the filtered total, asks for the +/// tile counts separately, pushes the site-scope grant into the query rather than +/// intersecting in memory, and never touches the instance repository. +/// +/// +public class DeploymentsServerPagingTests : BunitContext +{ + private IDeploymentManagerRepository _deployRepo = null!; + private ITemplateEngineRepository _templateRepo = null!; + private DeploymentStatusNotifier _notifier = null!; + + /// Records every filter/page/pageSize triple the page queried with. + private readonly List<(DeploymentListFilter Filter, int Page, int PageSize)> _pageCalls = new(); + + private void RegisterServices( + DeploymentListPage? page = null, + DeploymentStatusCounts? counts = null, + string[]? siteClaims = null) + { + _deployRepo = Substitute.For(); + _templateRepo = Substitute.For(); + _notifier = new DeploymentStatusNotifier(NullLogger.Instance); + + var resultPage = page ?? DeploymentListPage.Empty; + + _deployRepo.QueryDeploymentListPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + _pageCalls.Add((ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + return Task.FromResult(resultPage); + }); + + _deployRepo.GetDeploymentStatusCountsAsync( + Arg.Any(), Arg.Any()) + .Returns(counts ?? DeploymentStatusCounts.Empty); + + Services.AddSingleton(_deployRepo); + Services.AddSingleton(_templateRepo); + Services.AddSingleton(_notifier); + + var claims = new List { new(ClaimTypes.Name, "deployer") }; + foreach (var siteId in siteClaims ?? Array.Empty()) + { + claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId)); + } + + var identity = new ClaimsIdentity(claims, "TestCookie"); + var stubAuth = new StubAuthStateProvider( + new AuthenticationState(new ClaimsPrincipal(identity))); + Services.AddSingleton(stubAuth); + Services.AddScoped(_ => new SiteScopeService(stubAuth)); + } + + private sealed class StubAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState _state; + public StubAuthStateProvider(AuthenticationState state) => _state = state; + public override Task GetAuthenticationStateAsync() + => Task.FromResult(_state); + } + + private static DeploymentListRow Row(int id, string instance, DeploymentStatus status = DeploymentStatus.Success) => + new( + Id: id, + DeploymentId: $"dep-{id:D4}", + InstanceId: id, + InstanceUniqueName: instance, + SiteId: 1, + Status: status, + RevisionHash: $"rev{id:D4}hash", + DeployedBy: "deployer", + DeployedAt: DateTimeOffset.UtcNow.AddMinutes(-id), + CompletedAt: DateTimeOffset.UtcNow, + ErrorMessage: null); + + // ── Paging ──────────────────────────────────────────────────────────────── + + [Fact] + public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + + Render(); + + var call = Assert.Single(_pageCalls); + Assert.Equal(1, call.Page); + Assert.Equal(25, call.PageSize); + + // The full-instance-table read that used to build the name/site maps is gone. + _templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync(); + } + + [Fact] + public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen() + { + // One page of 25 rows out of 130 total => 6 pages. Under the old + // client-materialized model the pager could only ever see the rows it held. + var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); + RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); + + var cut = Render(); + + Assert.Contains("130 matching deployments", cut.Find("[data-test=\"deploy-result-count\"]").TextContent); + + // Windowed pager: first, last and a radius around the current page. + var pageButtons = cut.FindAll("ul.pagination li.page-item").Count; + Assert.True(pageButtons > 2, "Pager must render page buttons, not just Previous/Next."); + Assert.Contains("6", cut.Find("ul.pagination").TextContent); + } + + [Fact] + public void NextPage_RequeriesTheServerForPageTwo() + { + var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); + RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); + + var cut = Render(); + _pageCalls.Clear(); + + cut.Find("[data-test=\"deploy-page-next\"]").Click(); + + var call = Assert.Single(_pageCalls); + Assert.Equal(2, call.Page); + } + + [Fact] + public void SinglePageResult_RendersNoPager() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + + var cut = Render(); + + Assert.Empty(cut.FindAll("ul.pagination")); + } + + // ── Status tiles ────────────────────────────────────────────────────────── + + [Fact] + public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage() + { + // The page holds ONE row, but the server reports fleet-wide counts. Under + // the old model the tiles were Count() over the in-memory list, so paging + // would have silently reduced them to per-page counts. + RegisterServices( + new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 400), + new DeploymentStatusCounts(Pending: 7, InProgress: 3, Success: 380, Failed: 10)); + + var cut = Render(); + + Assert.Contains("7", cut.Find("[data-test=\"deploy-tile-pending\"]").TextContent); + Assert.Contains("3", cut.Find("[data-test=\"deploy-tile-inprogress\"]").TextContent); + Assert.Contains("380", cut.Find("[data-test=\"deploy-tile-success\"]").TextContent); + Assert.Contains("10", cut.Find("[data-test=\"deploy-tile-failed\"]").TextContent); + Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent); + } + + [Fact] + public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne() + { + var rows = Enumerable.Range(1, 25).Select(i => Row(i, $"Inst-{i}")).ToList(); + RegisterServices(new DeploymentListPage(rows, TotalCount: 130)); + + var cut = Render(); + cut.Find("[data-test=\"deploy-page-next\"]").Click(); + _pageCalls.Clear(); + + cut.Find("[data-test=\"deploy-tile-failed\"]").Click(); + + var call = Assert.Single(_pageCalls); + Assert.Equal(DeploymentStatus.Failed, call.Filter.Status); + Assert.Equal(1, call.Page); + Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed")); + } + + [Fact] + public void ClickingTheActiveTileAgain_ClearsTheStatusFilter() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + var cut = Render(); + + cut.Find("[data-test=\"deploy-tile-pending\"]").Click(); + _pageCalls.Clear(); + cut.Find("[data-test=\"deploy-tile-pending\"]").Click(); + + var call = Assert.Single(_pageCalls); + Assert.Null(call.Filter.Status); + Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-all\"]").GetAttribute("aria-pressed")); + } + + // ── Site scoping ────────────────────────────────────────────────────────── + + [Fact] + public void SystemWideUser_QueriesWithNoSiteScope() + { + RegisterServices(); + + Render(); + + Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope); + } + + [Fact] + public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery() + { + RegisterServices(siteClaims: new[] { "3", "7" }); + + Render(); + + var scope = Assert.Single(_pageCalls).Filter.SiteIdScope; + Assert.NotNull(scope); + Assert.Equal(new[] { 3, 7 }, scope!.OrderBy(x => x)); + } + + // ── Filter debounce ─────────────────────────────────────────────────────── + + [Fact] + public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + var cut = Render(); + _pageCalls.Clear(); + + // Type "Reactor" one character at a time, well inside the 500ms window. + const string term = "Reactor"; + for (var i = 1; i <= term.Length; i++) + { + cut.Find("[data-test=\"deploy-search\"]").Input(term[..i]); + } + + // No query yet — the trailing debounce has not elapsed. + Assert.Empty(_pageCalls); + + cut.WaitForAssertion(() => Assert.NotEmpty(_pageCalls), TimeSpan.FromSeconds(3)); + Thread.Sleep(300); + + var call = Assert.Single(_pageCalls); + Assert.Equal(term, call.Filter.Search); + Assert.Equal(1, call.Page); + } + + [Fact] + public void ClearFilters_ResetsSearchAndStatus_AndRequeries() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + var cut = Render(); + + cut.Find("[data-test=\"deploy-tile-failed\"]").Click(); + _pageCalls.Clear(); + + cut.Find("[data-test=\"deploy-clear-filters\"]").Click(); + + var call = Assert.Single(_pageCalls); + Assert.Null(call.Filter.Status); + Assert.Null(call.Filter.NormalizedSearch); + Assert.Equal(1, call.Page); + } + + [Fact] + public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + var cut = Render(); + + Assert.True(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled")); + + cut.Find("[data-test=\"deploy-search\"]").Input("Rea"); + + // Still inside the 500ms window — the escape hatch must not be dead. + Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled")); + } + + [Fact] + public void DisposeWithAnArmedFilterTimer_DoesNotQuery() + { + RegisterServices(new DeploymentListPage(new[] { Row(1, "Inst-1") }, TotalCount: 1)); + var cut = Render(); + + cut.Find("[data-test=\"deploy-search\"]").Input("abc"); + cut.Instance.Dispose(); + _pageCalls.Clear(); + + Thread.Sleep(900); + + Assert.Empty(_pageCalls); + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + [Fact] + public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable() + { + RegisterServices(new DeploymentListPage(new[] { Row(42, "Line3.Filler") }, TotalCount: 1)); + + var cut = Render(); + + Assert.Contains("Line3.Filler", cut.Markup); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs new file mode 100644 index 00000000..90554c0e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/DeploymentListQueryRepositoryTests.cs @@ -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; + +/// +/// Coverage for the Central UI deployment-status page's server-side read path — +/// and +/// +/// (residual R3). +/// +/// +/// These replace a whole-table GetAllDeploymentRecords 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. +/// +/// +/// +/// Uses the shared SQLite in-memory fixture. It enforces the +/// DeploymentRecord → Instance FK, which is exactly the relationship the +/// new query's inner join relies on, so the seeds are real Site/Template/Instance +/// rows. +/// +/// +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 SeedSiteAsync(string name) + { + var site = new Site(name, name); + _context.Sites.Add(site); + await _context.SaveChangesAsync(); + return site.Id; + } + + private async Task 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 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(); + 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()), 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); + } +}