diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index 4a6e4017..fdafd323 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -139,8 +139,14 @@ Central cluster only. Sites have no user interface. - View diff between deployed and current template-derived configuration. - Deploy updated configuration to individual instances. **Pre-deployment validation** runs automatically before any deployment is sent — validation errors are displayed and block deployment. - Track deployment status (pending, in-progress, success, failed). -- **Push-reload coalescing (arch-review WP2.4).** The page reloads its whole table (every deployment record + every instance) on each `DeploymentStatusChange` push, but the notifier fires per status *write* — a site-wide bulk deploy of N instances previously drove 2N+ back-to-back full reloads on the same circuit. Pushes are now leading-edge debounced (500ms): the first push after an idle gap reloads immediately (a single deployment stays as responsive as before), and every push inside the window collapses into one trailing reload. -- **Known residual — no server-side paging.** The table still loads and filters every deployment record client-side; server-side paging plus precomputed status counts (deferred-work register item) is the follow-on for large fleets, not shipped in this remediation. +- **Server-side paging + server-computed status counts (residual R3).** The page is a **database-paged** list, not a client-materialized one. It used to read *every* `DeploymentRecord` (an insert-only table — one row per deploy attempt for the whole retention window) plus *every* `Instance`, then site-scope, sort, count the four status tiles and slice a 25-row page in the Blazor circuit's memory, on first render **and on every deployment-status push**. All four jobs are now SQL: + - `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, pageNumber, pageSize)` returns one page of `DeploymentListRow` — `DeploymentRecord` **inner-joined to `Instance`**, so the instance's display name and site travel with the rows that need them (the join is exact: the FK is `Restrict` and instance deletion removes the records first) — plus the **total count** of the filtered set. + - `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from **one grouped aggregation**. It deliberately **ignores the filter's `Status`** — the tiles are the status *breakdown* of the otherwise-identically-filtered set, so each keeps its own total while it is the selected one. + - **Site scoping runs in the query.** The permitted-site grant is pushed in as `SiteIdScope` and resolved through the record's instance; an **empty** grant is a real filter matching nothing, never "unconstrained". `DeploymentRecord` has no `SiteId` of its own — the join is what makes this expressible. + - **Offset paging, not the Audit Log's keyset cursor — deliberately.** 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. 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 central `AuditLog`. This mirrors the Notification Outbox, which is 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 between pages. + - The whole-table `GetAllDeploymentRecordsAsync` read was **deleted** with its last caller. +- **Filtering.** Status filter (the tiles double as the control — clicking one applies it, clicking it again clears) plus a free-text search matched DB-side against instance unique name, deployment id, revision hash and initiating user. A filter change resets to page 1. Search input is **trailing-edge debounced at 500 ms**, so a typed term is one query rather than one per keystroke. +- **Push-reload coalescing (arch-review WP2.4).** The notifier fires per status *write*, so a site-wide bulk deploy of N instances drove 2N+ back-to-back reloads on the same circuit. Pushes are leading-edge debounced (500 ms): the first push after an idle gap reloads immediately (a single deployment stays as responsive as before), and every push inside the window collapses into one trailing reload. Server-side paging shrank what a reload *costs* but not how many *arrive*, so the coalescing still holds — it now bounds round-trips to the database rather than table scans. ### System-Wide Artifact Deployment (Deployment Role) - Explicitly deploy shared scripts, external system definitions, database connection definitions, and data connection definitions to all sites or to an individual site. (Notification lists and SMTP configuration are central-only and are not deployed.) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor index 69bedd7a..129cd0fa 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Deployments.razor @@ -1,12 +1,10 @@ @page "/deployment/deployments" @using ZB.MOM.WW.ScadaBridge.Security -@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 @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject IDeploymentManagerRepository DeploymentManagerRepository -@inject ITemplateEngineRepository TemplateEngineRepository @inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope @inject ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier @implements IDisposable @@ -23,6 +21,76 @@ + @* ── Status tiles ────────────────────────────────────────────────────────── + Counts come from ONE server-side grouped aggregation over the same filtered + set the table is drawn from — not from the rows on screen — so they stay + whole-fleet totals while the table shows a single page. Each tile doubles as + the status filter; the active one is pressed and clicking it again clears. *@ +
+ @* Each tile is a real +
+ } +
+ +
+ + + @* ── 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); + } +}