feat(centralui): server-side paging + server-computed status counts on Deployments (R3)
The Deployment Status page client-materialized the whole deployment list. It read EVERY DeploymentRecord — an insert-only table, one row per deploy attempt for the retention window — plus EVERY Instance, then site-scoped, sorted, counted the four status tiles and sliced a 25-row page in the Blazor circuit's memory. That ran on first render AND on every IDeploymentStatusNotifier push, so the cost scaled with the age of the system rather than the size of the page. All four jobs move into SQL: - `IDeploymentManagerRepository.QueryDeploymentListPageAsync(filter, page, size)` returns one page of `DeploymentListRow` — DeploymentRecord INNER JOINed to Instance, so the instance display name and site travel with the rows that need them — plus the total count of the filtered set. The join is exact: the FK is Restrict and DeleteInstanceAsync removes the records first, so no orphan exists. - `GetDeploymentStatusCountsAsync(filter)` returns the tile counts from ONE grouped aggregation, deliberately ignoring the filter's Status: the tiles are the status BREAKDOWN of the filtered set, so honouring it would zero three of four tiles the moment an operator clicked one. - Site scoping runs in the query as `SiteIdScope` resolved through the record's instance (DeploymentRecord has no SiteId of its own). An EMPTY grant stays a real filter matching nothing, never "unconstrained". - The now-callerless whole-table `GetAllDeploymentRecordsAsync` is deleted. OFFSET paging, not the Audit Log's keyset cursor, and deliberately so: this page's pager is numbered and jump-to-any-page, so it needs a page count, which only a total can give it — a keyset cursor can express neither, and the total is required for the tiles regardless. The deep-offset cost that pushes high-volume tables to keyset is bounded here by the terminal-record retention purge, unlike the 365-day AuditLog. This mirrors the Notification Outbox, offset-paged for the same reason. Ordering is DeployedAt DESC, Id DESC — the Id tie-break is load-bearing, because DeployedAt ties on rapid redeploys and an unstable sort key makes offset paging repeat or drop rows. UI: the four status tiles become the status filter (click to apply, click again to clear, aria-pressed, phrasing-only content so a <button> stays valid), plus a free-text search matched DB-side against instance name, deployment id, revision hash and initiating user. Search is TRAILING-edge debounced at 500ms — the same Timer + lock + _disposed idiom as the existing leading-edge push coalescer, minus the leading edge, because a search box must not query on the first keystroke. A filter change resets to page 1; a page past the end falls back to the last real page. Bootstrap only, existing PagerWindow pager retained. The WP2.4 push coalescing is unchanged and still earns its keep: server paging shrank what a reload costs, not how many arrive — it now bounds database round-trips rather than table scans. Tests: 19 new SQLite repository tests (paging slice + total, tie-break stability across pages, page/size clamping, past-the-end, the joined projection, every filter dimension incl. the empty-scope security case, and the grouped counts' status-blind contract); 15 new bUnit page tests (page-1 request, server total drives the pager, Next re-queries, tiles show server counts not page counts, tile filter + toggle, system-wide vs site-scoped scope push, debounce collapses a keystroke burst to one query, clear-filters, dispose with an armed timer). The two existing Deployments suites re-point their reload assertions at the new query. Doc: Component-CentralUI.md Deployment section — the "no server-side paging" known residual is replaced by the shipped design.
This commit is contained in:
@@ -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.)
|
||||
|
||||
+258
-86
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── 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. *@
|
||||
<div class="row g-2 mb-3">
|
||||
@* Each tile is a real <button> so it is keyboard-reachable and announces its
|
||||
pressed state. Its contents are PHRASING content only (span/small, not
|
||||
div/h4): a button's content model forbids flow content, and the .card /
|
||||
.card-body classes are carried by spans made block-level with d-block so
|
||||
the Bootstrap card look is unchanged. *@
|
||||
@foreach (var tile in StatusTiles)
|
||||
{
|
||||
var isActive = _statusFilter == tile.Status;
|
||||
<div class="col-lg col-md-4 col-6">
|
||||
<button type="button"
|
||||
class="card w-100 h-100 p-0 border-@tile.Variant @(isActive ? "border-2 shadow-sm" : "")"
|
||||
data-test="@tile.TestId"
|
||||
aria-pressed="@(isActive ? "true" : "false")"
|
||||
aria-label="@($"Filter by {tile.Label}: {CountFor(tile.Status)}")"
|
||||
@onclick="() => SetStatusFilterAsync(tile.Status)">
|
||||
<span class="card-body text-center py-2 d-block">
|
||||
<span class="h4 mb-0 d-block text-@tile.Variant">@CountFor(tile.Status)</span>
|
||||
<small class="text-muted">@tile.Label</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="col-lg col-md-4 col-6">
|
||||
<button type="button"
|
||||
class="card w-100 h-100 p-0 border-secondary @(_statusFilter is null ? "border-2 shadow-sm" : "")"
|
||||
data-test="deploy-tile-all"
|
||||
aria-pressed="@(_statusFilter is null ? "true" : "false")"
|
||||
aria-label="@($"Show all statuses: {_counts.Total}")"
|
||||
@onclick="() => SetStatusFilterAsync(null)">
|
||||
<span class="card-body text-center py-2 d-block">
|
||||
<span class="h4 mb-0 d-block">@_counts.Total</span>
|
||||
<small class="text-muted">All</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Filters ── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5 col-12">
|
||||
<label class="form-label small mb-1" for="dep-search">Search</label>
|
||||
<input id="dep-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Instance, deployment id, revision or user"
|
||||
data-test="deploy-search"
|
||||
value="@_searchInput"
|
||||
@oninput="OnSearchInput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="deploy-clear-filters"
|
||||
disabled="@(!CanClearFilters)" @onclick="ClearFiltersAsync">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
<div class="col text-end">
|
||||
<span class="small text-muted" data-test="deploy-result-count">
|
||||
@_totalCount matching @(_totalCount == 1 ? "deployment" : "deployments")
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
@@ -33,46 +101,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Summary cards *@
|
||||
<div class="row mb-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
|
||||
<small class="text-muted">Pending</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
|
||||
<small class="text-muted">In Progress</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
|
||||
<small class="text-muted">Successful</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
|
||||
<small class="text-muted">Failed</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_records.Count == 0)
|
||||
@if (_rows.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-0">No deployments recorded.</p>
|
||||
<p class="mb-0">@(HasActiveFilter ? "No deployments match the current filters." : "No deployments recorded.")</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -90,7 +122,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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 @@
|
||||
<div class="font-monospace small text-muted" title="@record.RevisionHash">@revShort</div>
|
||||
}
|
||||
</td>
|
||||
<td>@GetInstanceName(record.InstanceId)</td>
|
||||
<td>@record.InstanceUniqueName</td>
|
||||
<td>
|
||||
@if (isFailed)
|
||||
{
|
||||
@@ -171,7 +203,7 @@
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm justify-content-end">
|
||||
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
|
||||
<button class="page-link" data-test="deploy-page-prev" @onclick="() => GoToPageAsync(_currentPage - 1)">Previous</button>
|
||||
</li>
|
||||
@foreach (var page in ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.PagerWindow.Build(_currentPage, _totalPages))
|
||||
{
|
||||
@@ -185,12 +217,12 @@
|
||||
{
|
||||
var p = page;
|
||||
<li class="page-item @(p == _currentPage ? "active" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(p)">@(p)</button>
|
||||
<button class="page-link" @onclick="() => GoToPageAsync(p)">@(p)</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
|
||||
<button class="page-link" data-test="deploy-page-next" @onclick="() => GoToPageAsync(_currentPage + 1)">Next</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -199,9 +231,25 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<DeploymentRecord> _records = new();
|
||||
private List<DeploymentRecord> _pagedRecords = new();
|
||||
private Dictionary<int, string> _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<DeploymentListRow> _rows = Array.Empty<DeploymentListRow>();
|
||||
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;
|
||||
|
||||
/// <summary>Raw contents of the search box, updated on every keystroke.</summary>
|
||||
private string _searchInput = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The search term the last query actually ran with. Distinct from
|
||||
/// <see cref="_searchInput"/> so the debounce window can absorb keystrokes
|
||||
/// without the rendered result count implying a query that has not run.
|
||||
/// </summary>
|
||||
private string _appliedSearch = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private bool HasActiveFilter => _statusFilter is not null || !string.IsNullOrWhiteSpace(_appliedSearch);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private bool CanClearFilters => HasActiveFilter || !string.IsNullOrWhiteSpace(_searchInput);
|
||||
|
||||
/// <summary>Tile definitions, in display order. The "All" tile is rendered separately.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Trailing-edge timer for search-box input. Disposed with the component.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<int>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-3
@@ -16,11 +16,69 @@ public interface IDeploymentManagerRepository
|
||||
/// <returns>The deployment record, or null if not found.</returns>
|
||||
Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Gets all deployment records.
|
||||
/// Database-side paged, filtered and instance-joined deployment listing —
|
||||
/// the read path behind the Central UI deployment-status page.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces a <c>GetAllDeploymentRecords</c> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>OFFSET paging, not keyset — deliberately.</b> 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
|
||||
/// <see cref="PurgeTerminalDeploymentRecordsAsync"/>, which caps the table at the
|
||||
/// terminal-record retention window — unlike the 365-day central
|
||||
/// <c>AuditLog</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Ordering is <c>DeployedAt DESC, Id DESC</c>. The <c>Id</c> tie-break is not
|
||||
/// cosmetic: <c>DeployedAt</c> ties on rapid redeploys, and an unstable sort key
|
||||
/// makes offset paging non-deterministic — rows repeat across pages or vanish
|
||||
/// between them.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="filter">Status / search / site-scope / instance constraints; all applied DB-side.</param>
|
||||
/// <param name="pageNumber">1-based page number; values below 1 are floored at 1.</param>
|
||||
/// <param name="pageSize">Rows per page; clamped to <c>[1, DeploymentRecordSummary.MaxPageSize]</c>.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>A read-only list of all deployment records.</returns>
|
||||
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
|
||||
/// <returns>The requested page of rows plus the total count of the filtered set.</returns>
|
||||
Task<Types.Deployment.DeploymentListPage> QueryDeploymentListPageAsync(
|
||||
Types.Deployment.DeploymentListFilter filter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// One grouped aggregation returning the per-status row counts of the filtered
|
||||
/// set — the server-side replacement for the deployment page's four
|
||||
/// <c>records.Count(r => r.Status == X)</c> tile computations over a
|
||||
/// client-materialized table.
|
||||
///
|
||||
/// <para>
|
||||
/// <b><see cref="Types.Deployment.DeploymentListFilter.Status"/> is ignored here.</b>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="filter">The active list filter; its <c>Status</c> member is deliberately not applied.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>Per-status counts, with absent statuses reported as 0.</returns>
|
||||
Task<Types.Deployment.DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
|
||||
Types.Deployment.DeploymentListFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Database-side paged and filtered deployment query returning row summaries.
|
||||
/// Backs the <c>QueryDeployments</c> management command, which previously
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Filter contract for the Central UI deployment-status list. Every dimension is
|
||||
/// applied IN THE DATABASE — the page that consumes it previously loaded the whole
|
||||
/// <c>DeploymentRecords</c> table plus every <c>Instance</c> and filtered, sorted,
|
||||
/// counted and paged the result in the Blazor circuit's memory.
|
||||
///
|
||||
/// <para>
|
||||
/// Message-contract evolution rule: additive-only. New dimensions go on the end
|
||||
/// with a default of <see langword="null"/> meaning "unconstrained".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Status">
|
||||
/// Restrict to one deployment status, or <see langword="null"/> for all.
|
||||
/// Deliberately IGNORED by the status-count aggregation
|
||||
/// (<c>IDeploymentManagerRepository.GetDeploymentStatusCountsAsync</c>): the tiles
|
||||
/// are the status breakdown of the otherwise-identically-filtered set, so a tile
|
||||
/// must keep showing its own total while that same status is the active filter.
|
||||
/// </param>
|
||||
/// <param name="Search">
|
||||
/// Free-text term matched (case-insensitively, as a substring) against the
|
||||
/// instance unique name, the deployment id, the revision hash and the initiating
|
||||
/// user. Blank/whitespace is treated as unconstrained.
|
||||
/// </param>
|
||||
/// <param name="SiteIdScope">
|
||||
/// When non-null, restrict to deployments whose instance belongs to one of these
|
||||
/// sites — the site-scoped user's permitted-site grant, pushed into the query
|
||||
/// rather than intersected in memory. An EMPTY collection is a real, meaningful
|
||||
/// filter (a user permitted no sites sees nothing) and must not be treated as
|
||||
/// "no filter"; <see langword="null"/> is the system-wide case.
|
||||
/// </param>
|
||||
/// <param name="InstanceId">Restrict to one instance's deployment history, or <see langword="null"/> for all.</param>
|
||||
public sealed record DeploymentListFilter(
|
||||
DeploymentStatus? Status = null,
|
||||
string? Search = null,
|
||||
IReadOnlyCollection<int>? SiteIdScope = null,
|
||||
int? InstanceId = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// The trimmed search term, or <see langword="null"/> when the term is absent
|
||||
/// or whitespace-only. Repository implementations use this rather than
|
||||
/// <see cref="Search"/> so a stray space never becomes a LIKE pattern that
|
||||
/// matches nothing.
|
||||
/// </summary>
|
||||
public string? NormalizedSearch =>
|
||||
string.IsNullOrWhiteSpace(Search) ? null : Search.Trim();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// One page of the Central UI deployment-status list plus the total row count of
|
||||
/// the filtered set.
|
||||
///
|
||||
/// <para>
|
||||
/// The count travels WITH the page because the page's pager is a numbered,
|
||||
/// jump-to-any-page control: it needs a page count, which only a total can give
|
||||
/// it. That is also the reason this read path is OFFSET-paged rather than keyset
|
||||
/// -paged like the Audit Log — see
|
||||
/// <c>IDeploymentManagerRepository.QueryDeploymentListPageAsync</c> for the full
|
||||
/// rationale.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Rows">The requested page of rows, newest deployment first.</param>
|
||||
/// <param name="TotalCount">Total rows matching the filter across all pages.</param>
|
||||
public sealed record DeploymentListPage(
|
||||
IReadOnlyList<DeploymentListRow> Rows,
|
||||
int TotalCount)
|
||||
{
|
||||
/// <summary>An empty page — no rows, no matches.</summary>
|
||||
public static DeploymentListPage Empty { get; } = new(Array.Empty<DeploymentListRow>(), 0);
|
||||
|
||||
/// <summary>
|
||||
/// Number of pages the filtered set spans at the given page size; at least 1
|
||||
/// so a pager always has a page to sit on. Returns 1 for a non-positive
|
||||
/// <paramref name="pageSize"/> rather than dividing by zero.
|
||||
/// </summary>
|
||||
/// <param name="pageSize">Rows per page.</param>
|
||||
/// <returns>The total page count, never less than 1.</returns>
|
||||
public int PageCount(int pageSize) =>
|
||||
pageSize <= 0 ? 1 : Math.Max(1, (int)Math.Ceiling(TotalCount / (double)pageSize));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// One rendered row of the Central UI deployment-status list: a
|
||||
/// <c>DeploymentRecord</c> projection already joined to its <c>Instance</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The join is what removes the page's SECOND full-table read. Rendering a row
|
||||
/// needs the instance's display name and site scoping needs its site, and the page
|
||||
/// used to obtain both by materializing every instance into two dictionaries. Both
|
||||
/// values now come back with the page of rows that actually needs them.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Like <see cref="DeploymentRecordSummary"/>, the <c>RowVersion</c>
|
||||
/// optimistic-concurrency token is deliberately absent — this is a READ projection
|
||||
/// and must never be mistaken for something that can be written back. Mutating
|
||||
/// paths keep loading the tracked entity.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Message-contract evolution rule: additive-only. New fields go on the end with a
|
||||
/// default.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Id">Deployment record row id.</param>
|
||||
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
|
||||
/// <param name="InstanceId">Instance the deployment targeted.</param>
|
||||
/// <param name="InstanceUniqueName">The target instance's unique name, for display.</param>
|
||||
/// <param name="SiteId">The target instance's site, the value site scoping is evaluated against.</param>
|
||||
/// <param name="Status">Terminal or in-flight deployment status.</param>
|
||||
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
|
||||
/// <param name="DeployedBy">User who initiated the deployment.</param>
|
||||
/// <param name="DeployedAt">When the deployment was initiated.</param>
|
||||
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
|
||||
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
|
||||
public sealed record DeploymentListRow(
|
||||
int Id,
|
||||
string DeploymentId,
|
||||
int InstanceId,
|
||||
string InstanceUniqueName,
|
||||
int SiteId,
|
||||
DeploymentStatus Status,
|
||||
string? RevisionHash,
|
||||
string DeployedBy,
|
||||
DateTimeOffset DeployedAt,
|
||||
DateTimeOffset? CompletedAt,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,78 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Server-computed deployment counts per <see cref="DeploymentStatus"/>, backing the
|
||||
/// Central UI deployment-status tiles.
|
||||
///
|
||||
/// <para>
|
||||
/// The tiles used to be <c>_records.Count(r => r.Status == X)</c> over the
|
||||
/// client-materialized full table — which meant the tiles were only correct because
|
||||
/// the whole table was in memory, and paging the list would silently have reduced
|
||||
/// them to "counts of the visible page". Computing them DB-side with one grouped
|
||||
/// query decouples the tiles from the page.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Pending">Rows in <see cref="DeploymentStatus.Pending"/>.</param>
|
||||
/// <param name="InProgress">Rows in <see cref="DeploymentStatus.InProgress"/>.</param>
|
||||
/// <param name="Success">Rows in <see cref="DeploymentStatus.Success"/>.</param>
|
||||
/// <param name="Failed">Rows in <see cref="DeploymentStatus.Failed"/>.</param>
|
||||
public sealed record DeploymentStatusCounts(
|
||||
int Pending,
|
||||
int InProgress,
|
||||
int Success,
|
||||
int Failed)
|
||||
{
|
||||
/// <summary>All-zero counts — the shape returned for an empty or fully-excluded set.</summary>
|
||||
public static DeploymentStatusCounts Empty { get; } = new(0, 0, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Total across every status. Summing is exact rather than approximate because
|
||||
/// <see cref="DeploymentStatus"/> is a closed set and every row carries one of
|
||||
/// its members.
|
||||
/// </summary>
|
||||
public int Total => Pending + InProgress + Success + Failed;
|
||||
|
||||
/// <summary>Count for a single status.</summary>
|
||||
/// <param name="status">The status to read.</param>
|
||||
/// <returns>The count for <paramref name="status"/>; 0 for an unrecognised value.</returns>
|
||||
public int For(DeploymentStatus status) => status switch
|
||||
{
|
||||
DeploymentStatus.Pending => Pending,
|
||||
DeploymentStatus.InProgress => InProgress,
|
||||
DeploymentStatus.Success => Success,
|
||||
DeploymentStatus.Failed => Failed,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Folds the <c>(status, count)</c> pairs a grouped database query returns into
|
||||
/// the fixed-shape record. Statuses absent from the grouping — no rows in that
|
||||
/// state — land as 0 rather than being missing.
|
||||
/// </summary>
|
||||
/// <param name="grouped">Per-status counts as returned by the aggregation query.</param>
|
||||
/// <returns>The folded counts.</returns>
|
||||
public static DeploymentStatusCounts FromGrouped(IEnumerable<(DeploymentStatus Status, int Count)> grouped)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(grouped);
|
||||
|
||||
var pending = 0;
|
||||
var inProgress = 0;
|
||||
var success = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var (status, count) in grouped)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case DeploymentStatus.Pending: pending += count; break;
|
||||
case DeploymentStatus.InProgress: inProgress += count; break;
|
||||
case DeploymentStatus.Success: success += count; break;
|
||||
case DeploymentStatus.Failed: failed += count; break;
|
||||
}
|
||||
}
|
||||
|
||||
return new DeploymentStatusCounts(pending, inProgress, success, failed);
|
||||
}
|
||||
}
|
||||
+146
-4
@@ -36,12 +36,131 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
|
||||
/// <summary>
|
||||
/// Shared filtered join behind <see cref="QueryDeploymentListPageAsync"/> and
|
||||
/// <see cref="GetDeploymentStatusCountsAsync"/>, so the two reads that back one
|
||||
/// screen can never disagree about which rows they describe.
|
||||
///
|
||||
/// <para>
|
||||
/// The join to <c>Instance</c> is an INNER join, which is exact rather than
|
||||
/// merely convenient: <c>DeploymentRecord</c> has a <c>Restrict</c> FK to
|
||||
/// <c>Instance</c> and <see cref="DeleteInstanceAsync"/> removes the records
|
||||
/// before the instance, so an orphaned deployment record cannot exist. The join
|
||||
/// is what lets site scoping run in SQL — <c>DeploymentRecord</c> itself has no
|
||||
/// <c>SiteId</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="filter">Filter to apply.</param>
|
||||
/// <param name="applyStatus">
|
||||
/// When <see langword="false"/>, the status constraint is skipped — the
|
||||
/// status-count aggregation needs every status of the otherwise-identical set.
|
||||
/// </param>
|
||||
/// <returns>The composed, unordered, untracked query.</returns>
|
||||
private IQueryable<DeploymentInstanceJoin> BuildListQuery(DeploymentListFilter filter, bool applyStatus)
|
||||
{
|
||||
return await _dbContext.DeploymentRecords
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
var query =
|
||||
from d in _dbContext.DeploymentRecords.AsNoTracking()
|
||||
join i in _dbContext.Set<Instance>().AsNoTracking() on d.InstanceId equals i.Id
|
||||
select new DeploymentInstanceJoin
|
||||
{
|
||||
Id = d.Id,
|
||||
DeploymentId = d.DeploymentId,
|
||||
InstanceId = d.InstanceId,
|
||||
InstanceUniqueName = i.UniqueName,
|
||||
SiteId = i.SiteId,
|
||||
Status = d.Status,
|
||||
RevisionHash = d.RevisionHash,
|
||||
DeployedBy = d.DeployedBy,
|
||||
DeployedAt = d.DeployedAt,
|
||||
CompletedAt = d.CompletedAt,
|
||||
ErrorMessage = d.ErrorMessage
|
||||
};
|
||||
|
||||
if (filter.InstanceId.HasValue)
|
||||
query = query.Where(r => r.InstanceId == filter.InstanceId.Value);
|
||||
|
||||
if (applyStatus && filter.Status.HasValue)
|
||||
query = query.Where(r => r.Status == filter.Status.Value);
|
||||
|
||||
if (filter.SiteIdScope != null)
|
||||
{
|
||||
// An EMPTY scope is a real filter — a user permitted no sites sees
|
||||
// nothing — so it must not be short-circuited into "no filter".
|
||||
var scope = filter.SiteIdScope as int[] ?? filter.SiteIdScope.ToArray();
|
||||
query = query.Where(r => scope.Contains(r.SiteId));
|
||||
}
|
||||
|
||||
if (filter.NormalizedSearch is { } search)
|
||||
{
|
||||
// Substring match across the four identifiers a deployment is findable
|
||||
// by. Translated to LIKE '%term%' by both providers; case-insensitivity
|
||||
// is the database collation's (SQL Server default CI, SQLite ASCII LIKE).
|
||||
query = query.Where(r =>
|
||||
r.InstanceUniqueName.Contains(search)
|
||||
|| r.DeploymentId.Contains(search)
|
||||
|| r.DeployedBy.Contains(search)
|
||||
|| (r.RevisionHash != null && r.RevisionHash.Contains(search)));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentListPage> QueryDeploymentListPageAsync(
|
||||
DeploymentListFilter filter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize);
|
||||
|
||||
var query = BuildListQuery(filter, applyStatus: true);
|
||||
|
||||
// Count first: the pager needs a page count, and the caller must be able to
|
||||
// tell "page 7 is now past the end" (a concurrent purge or a narrowed filter)
|
||||
// from "page 7 is empty".
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
// DeployedAt ties on rapid redeploys; without the Id tie-break the sort key
|
||||
// is unstable and offset paging repeats or drops rows between pages.
|
||||
var rows = await query
|
||||
.OrderByDescending(r => r.DeployedAt)
|
||||
.ThenByDescending(r => r.Id)
|
||||
.Skip((page - 1) * size)
|
||||
.Take(size)
|
||||
.Select(r => new DeploymentListRow(
|
||||
r.Id,
|
||||
r.DeploymentId,
|
||||
r.InstanceId,
|
||||
r.InstanceUniqueName,
|
||||
r.SiteId,
|
||||
r.Status,
|
||||
r.RevisionHash,
|
||||
r.DeployedBy,
|
||||
r.DeployedAt,
|
||||
r.CompletedAt,
|
||||
r.ErrorMessage))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new DeploymentListPage(rows, totalCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
|
||||
DeploymentListFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// applyStatus: false — the tiles are the status breakdown of the filtered
|
||||
// set, so the active status filter is the one dimension they must NOT honour.
|
||||
var grouped = await BuildListQuery(filter, applyStatus: false)
|
||||
.GroupBy(r => r.Status)
|
||||
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return DeploymentStatusCounts.FromGrouped(grouped.Select(g => (g.Status, g.Count)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -483,4 +602,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
{
|
||||
return await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flat shape of the deployment-record ⋈ instance join used to compose the list
|
||||
/// query. A named type rather than an anonymous one so
|
||||
/// <see cref="BuildListQuery"/> can hand the composed-but-unexecuted query back
|
||||
/// to both the paging read and the count aggregation, keeping the two in
|
||||
/// lockstep. Not a public contract — <see cref="DeploymentListRow"/> is what
|
||||
/// leaves this class.
|
||||
/// </summary>
|
||||
private sealed class DeploymentInstanceJoin
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string DeploymentId { get; init; } = string.Empty;
|
||||
public int InstanceId { get; init; }
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
public int SiteId { get; init; }
|
||||
public DeploymentStatus Status { get; init; }
|
||||
public string? RevisionHash { get; init; }
|
||||
public string DeployedBy { get; init; } = string.Empty;
|
||||
public DateTimeOffset DeployedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -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 <see cref="IDeploymentStatusNotifier"/>, which
|
||||
/// <c>DeploymentService</c> raises on every deployment-record status write;
|
||||
/// Blazor Server then pushes the re-render over its SignalR circuit.
|
||||
///
|
||||
/// <para>
|
||||
/// The "did it reload?" assertions moved from <c>GetAllDeploymentRecordsAsync</c>
|
||||
/// to <c>QueryDeploymentListPageAsync</c> when the page's read path went
|
||||
/// server-paged (residual R3) — the push mechanism under test is unchanged, only
|
||||
/// the query it drives.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentsPushUpdateTests : BunitContext
|
||||
{
|
||||
private IDeploymentManagerRepository _deployRepo = null!;
|
||||
private ITemplateEngineRepository _templateRepo = null!;
|
||||
private DeploymentStatusNotifier _notifier = null!;
|
||||
|
||||
private void RegisterServices()
|
||||
{
|
||||
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
_templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
|
||||
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>
|
||||
{
|
||||
new("Inst-1") { Id = 1, SiteId = 1 }
|
||||
});
|
||||
_deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DeploymentRecord>());
|
||||
_deployRepo.QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(DeploymentListPage.Empty);
|
||||
_deployRepo.GetDeploymentStatusCountsAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(DeploymentStatusCounts.Empty);
|
||||
|
||||
Services.AddSingleton(_deployRepo);
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
@@ -80,9 +82,8 @@ public class DeploymentsPushUpdateTests : BunitContext
|
||||
RegisterServices();
|
||||
var cut = Render<DeploymentsPage>();
|
||||
|
||||
// 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<CancellationToken>()));
|
||||
_deployRepo.Received().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,8 +108,8 @@ public class DeploymentsPushUpdateTests : BunitContext
|
||||
_notifier.NotifyStatusChanged(
|
||||
new DeploymentStatusChange("dep-2", 1, DeploymentStatus.Failed));
|
||||
|
||||
_deployRepo.DidNotReceive()
|
||||
.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>());
|
||||
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<CancellationToken>());
|
||||
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
+32
-25
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <c>QueryDeploymentListPageAsync</c> calls instead of the deleted
|
||||
/// <c>GetAllDeploymentRecordsAsync</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentsReloadDebounceTests : BunitContext
|
||||
{
|
||||
private IDeploymentManagerRepository _deployRepo = null!;
|
||||
private ITemplateEngineRepository _templateRepo = null!;
|
||||
private DeploymentStatusNotifier _notifier = null!;
|
||||
|
||||
private void RegisterServices()
|
||||
{
|
||||
_deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||
_templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
|
||||
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance> { new("Inst-1") { Id = 1, SiteId = 1 } });
|
||||
_deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DeploymentRecord>());
|
||||
_deployRepo.QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(DeploymentListPage.Empty);
|
||||
_deployRepo.GetDeploymentStatusCountsAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(DeploymentStatusCounts.Empty);
|
||||
|
||||
Services.AddSingleton(_deployRepo);
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
@@ -58,13 +62,17 @@ public class DeploymentsReloadDebounceTests : BunitContext
|
||||
=> Task.FromResult(_state);
|
||||
}
|
||||
|
||||
private void AssertReloaded(IRenderedComponent<DeploymentsPage> cut) =>
|
||||
cut.WaitForAssertion(() =>
|
||||
_deployRepo.Received().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
|
||||
|
||||
[Fact]
|
||||
public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads()
|
||||
{
|
||||
RegisterServices();
|
||||
var cut = Render<DeploymentsPage>();
|
||||
cut.WaitForAssertion(() =>
|
||||
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
|
||||
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<CancellationToken>()));
|
||||
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<DeploymentsPage>();
|
||||
cut.WaitForAssertion(() =>
|
||||
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
|
||||
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<CancellationToken>()),
|
||||
() => _deployRepo.Received().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()),
|
||||
TimeSpan.FromMilliseconds(400));
|
||||
}
|
||||
|
||||
@@ -110,8 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
|
||||
{
|
||||
RegisterServices();
|
||||
var cut = Render<DeploymentsPage>();
|
||||
cut.WaitForAssertion(() =>
|
||||
_deployRepo.Received().GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>()));
|
||||
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<CancellationToken>());
|
||||
_deployRepo.DidNotReceive().QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
+323
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the Deployment Status page's server-side paging, server-computed
|
||||
/// status tiles and debounced filter input (residual R3).
|
||||
///
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentsServerPagingTests : BunitContext
|
||||
{
|
||||
private IDeploymentManagerRepository _deployRepo = null!;
|
||||
private ITemplateEngineRepository _templateRepo = null!;
|
||||
private DeploymentStatusNotifier _notifier = null!;
|
||||
|
||||
/// <summary>Records every filter/page/pageSize triple the page queried with.</summary>
|
||||
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<IDeploymentManagerRepository>();
|
||||
_templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
_notifier = new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance);
|
||||
|
||||
var resultPage = page ?? DeploymentListPage.Empty;
|
||||
|
||||
_deployRepo.QueryDeploymentListPageAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
_pageCalls.Add((ci.ArgAt<DeploymentListFilter>(0), ci.ArgAt<int>(1), ci.ArgAt<int>(2)));
|
||||
return Task.FromResult(resultPage);
|
||||
});
|
||||
|
||||
_deployRepo.GetDeploymentStatusCountsAsync(
|
||||
Arg.Any<DeploymentListFilter>(), Arg.Any<CancellationToken>())
|
||||
.Returns(counts ?? DeploymentStatusCounts.Empty);
|
||||
|
||||
Services.AddSingleton(_deployRepo);
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton<IDeploymentStatusNotifier>(_notifier);
|
||||
|
||||
var claims = new List<Claim> { new(ClaimTypes.Name, "deployer") };
|
||||
foreach (var siteId in siteClaims ?? Array.Empty<string>())
|
||||
{
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "TestCookie");
|
||||
var stubAuth = new StubAuthStateProvider(
|
||||
new AuthenticationState(new ClaimsPrincipal(identity)));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
|
||||
Services.AddScoped(_ => new SiteScopeService(stubAuth));
|
||||
}
|
||||
|
||||
private sealed class StubAuthStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly AuthenticationState _state;
|
||||
public StubAuthStateProvider(AuthenticationState state) => _state = state;
|
||||
public override Task<AuthenticationState> 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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
_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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery()
|
||||
{
|
||||
RegisterServices(siteClaims: new[] { "3", "7" });
|
||||
|
||||
Render<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
_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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
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<DeploymentsPage>();
|
||||
|
||||
Assert.Contains("Line3.Filler", cut.Markup);
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the Central UI deployment-status page's server-side read path —
|
||||
/// <see cref="DeploymentManagerRepository.QueryDeploymentListPageAsync"/> and
|
||||
/// <see cref="DeploymentManagerRepository.GetDeploymentStatusCountsAsync"/>
|
||||
/// (residual R3).
|
||||
///
|
||||
/// <para>
|
||||
/// These replace a whole-table <c>GetAllDeploymentRecords</c> read whose only
|
||||
/// caller then site-scoped, sorted, counted the status tiles and sliced a page in
|
||||
/// the Blazor circuit's memory. Every one of those jobs is asserted here to happen
|
||||
/// in SQL instead: the filter dimensions, the total count, the deterministic
|
||||
/// ordering that makes offset paging safe, and the grouped tile aggregation.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Uses the shared SQLite in-memory fixture. It enforces the
|
||||
/// <c>DeploymentRecord → Instance</c> FK, which is exactly the relationship the
|
||||
/// new query's inner join relies on, so the seeds are real Site/Template/Instance
|
||||
/// rows.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DeploymentListQueryRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly DeploymentManagerRepository _repository;
|
||||
private readonly DateTimeOffset _base = new(2026, 8, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public DeploymentListQueryRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new DeploymentManagerRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task<int> SeedSiteAsync(string name)
|
||||
{
|
||||
var site = new Site(name, name);
|
||||
_context.Sites.Add(site);
|
||||
await _context.SaveChangesAsync();
|
||||
return site.Id;
|
||||
}
|
||||
|
||||
private async Task<int> SeedInstanceAsync(string uniqueName, int siteId)
|
||||
{
|
||||
var template = new Template($"T-{uniqueName}");
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance.Id;
|
||||
}
|
||||
|
||||
private async Task<DeploymentRecord> SeedRecordAsync(
|
||||
string deploymentId,
|
||||
int instanceId,
|
||||
DeploymentStatus status,
|
||||
DateTimeOffset deployedAt,
|
||||
string deployedBy = "alice",
|
||||
string? revisionHash = null)
|
||||
{
|
||||
var record = new DeploymentRecord(deploymentId, deployedBy)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
Status = status,
|
||||
DeployedAt = deployedAt,
|
||||
RevisionHash = revisionHash
|
||||
};
|
||||
_context.DeploymentRecords.Add(record);
|
||||
await _context.SaveChangesAsync();
|
||||
return record;
|
||||
}
|
||||
|
||||
// ── Paging ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_ReturnsRequestedSlice_AndTheTotalCountOfTheWholeSet()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base.AddMinutes(i));
|
||||
}
|
||||
|
||||
var page2 = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 2, pageSize: 10);
|
||||
|
||||
Assert.Equal(25, page2.TotalCount);
|
||||
Assert.Equal(10, page2.Rows.Count);
|
||||
Assert.Equal(3, page2.PageCount(10));
|
||||
|
||||
// Newest first: page 2 of 10 starts at the 11th newest, dep-014.
|
||||
Assert.Equal("dep-014", page2.Rows[0].DeploymentId);
|
||||
Assert.Equal("dep-005", page2.Rows[^1].DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_PagesDoNotOverlapOrDropRows_EvenWhenDeployedAtTies()
|
||||
{
|
||||
// Rapid redeploys write several records on the same clock tick. Without the
|
||||
// Id tie-break the sort key is unstable and offset paging repeats or drops
|
||||
// rows between pages — the failure mode the ThenByDescending(Id) prevents.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
for (var i = 0; i < 9; i++)
|
||||
{
|
||||
await SeedRecordAsync($"dep-{i:D3}", instanceId, DeploymentStatus.Success, _base);
|
||||
}
|
||||
|
||||
var seen = new List<string>();
|
||||
for (var page = 1; page <= 3; page++)
|
||||
{
|
||||
var result = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: page, pageSize: 3);
|
||||
seen.AddRange(result.Rows.Select(r => r.DeploymentId));
|
||||
}
|
||||
|
||||
Assert.Equal(9, seen.Count);
|
||||
Assert.Equal(9, seen.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_NonPositivePageNumber_IsFlooredAtPageOne()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 0, pageSize: 10);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_OversizedPageSize_IsClampedToTheMaximum()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
// A caller asking for int.MaxValue rows must not be able to turn the paged
|
||||
// read back into the whole-table read it replaced.
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 1, pageSize: int.MaxValue);
|
||||
|
||||
Assert.Single(page.Rows);
|
||||
Assert.True(DeploymentRecordSummary.MaxPageSize > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_PastTheEnd_ReturnsNoRowsButStillReportsTheTotal()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(), pageNumber: 9, pageSize: 25);
|
||||
|
||||
Assert.Empty(page.Rows);
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
}
|
||||
|
||||
// ── Joined projection ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_JoinsTheInstance_SoNameAndSiteTravelWithTheRow()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Line3.Filler", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Failed, _base,
|
||||
deployedBy: "bob", revisionHash: "abc123");
|
||||
|
||||
var row = Assert.Single(
|
||||
(await _repository.QueryDeploymentListPageAsync(new DeploymentListFilter(), 1, 25)).Rows);
|
||||
|
||||
Assert.Equal("Line3.Filler", row.InstanceUniqueName);
|
||||
Assert.Equal(siteId, row.SiteId);
|
||||
Assert.Equal(instanceId, row.InstanceId);
|
||||
Assert.Equal("dep-000", row.DeploymentId);
|
||||
Assert.Equal(DeploymentStatus.Failed, row.Status);
|
||||
Assert.Equal("bob", row.DeployedBy);
|
||||
Assert.Equal("abc123", row.RevisionHash);
|
||||
}
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_StatusFilter_IsAppliedInTheDatabase()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-ok", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-bad", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Status: DeploymentStatus.Failed), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal("dep-bad", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_InstanceFilter_RestrictsToOneInstanceHistory()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var a = await SeedInstanceAsync("Inst-A", siteId);
|
||||
var b = await SeedInstanceAsync("Inst-B", siteId);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(InstanceId: b), 1, 25);
|
||||
|
||||
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Filler", "dep-a")] // instance unique name
|
||||
[InlineData("dep-b", "dep-b")] // deployment id
|
||||
[InlineData("carol", "dep-b")] // initiating user
|
||||
[InlineData("rev-a", "dep-a")] // revision hash
|
||||
public async Task QueryPage_Search_MatchesAcrossTheFindableIdentifiers(string term, string expected)
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var a = await SeedInstanceAsync("Line3.Filler", siteId);
|
||||
var b = await SeedInstanceAsync("Line4.Capper", siteId);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base, deployedBy: "alice", revisionHash: "rev-a1");
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1), deployedBy: "carol", revisionHash: "rev-b1");
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: term), 1, 25);
|
||||
|
||||
Assert.Equal(expected, Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_WhitespaceSearch_IsTreatedAsUnconstrained()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(Search: " "), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_SiteScope_RestrictsToTheGrantedSites()
|
||||
{
|
||||
var s1 = await SeedSiteAsync("S1");
|
||||
var s2 = await SeedSiteAsync("S2");
|
||||
var a = await SeedInstanceAsync("Inst-A", s1);
|
||||
var b = await SeedInstanceAsync("Inst-B", s2);
|
||||
await SeedRecordAsync("dep-a", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-b", b, DeploymentStatus.Success, _base.AddMinutes(1));
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(SiteIdScope: new[] { s2 }), 1, 25);
|
||||
|
||||
Assert.Equal(1, page.TotalCount);
|
||||
Assert.Equal("dep-b", Assert.Single(page.Rows).DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryPage_EmptySiteScope_IsARealFilterThatMatchesNothing()
|
||||
{
|
||||
// A scoped user granted no sites must see nothing — an empty scope must NOT
|
||||
// degrade into "unconstrained", which would leak every site's deployments.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-000", instanceId, DeploymentStatus.Success, _base);
|
||||
|
||||
var page = await _repository.QueryDeploymentListPageAsync(
|
||||
new DeploymentListFilter(SiteIdScope: Array.Empty<int>()), 1, 25);
|
||||
|
||||
Assert.Equal(0, page.TotalCount);
|
||||
Assert.Empty(page.Rows);
|
||||
}
|
||||
|
||||
// ── Status counts ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_GroupsEveryStatus_WithAbsentOnesReportedAsZero()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Success, _base.AddMinutes(1));
|
||||
await SeedRecordAsync("dep-3", instanceId, DeploymentStatus.Failed, _base.AddMinutes(2));
|
||||
await SeedRecordAsync("dep-4", instanceId, DeploymentStatus.InProgress, _base.AddMinutes(3));
|
||||
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
||||
|
||||
Assert.Equal(0, counts.Pending);
|
||||
Assert.Equal(1, counts.InProgress);
|
||||
Assert.Equal(2, counts.Success);
|
||||
Assert.Equal(1, counts.Failed);
|
||||
Assert.Equal(4, counts.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_IgnoreTheStatusFilter_SoEachTileKeepsItsOwnTotal()
|
||||
{
|
||||
// The tiles are the status BREAKDOWN of the filtered set. Honouring the
|
||||
// active status filter here would zero the other three tiles the moment an
|
||||
// operator clicked one.
|
||||
var siteId = await SeedSiteAsync("S1");
|
||||
var instanceId = await SeedInstanceAsync("Inst-1", siteId);
|
||||
await SeedRecordAsync("dep-1", instanceId, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-2", instanceId, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(Status: DeploymentStatus.Failed));
|
||||
|
||||
Assert.Equal(1, counts.Success);
|
||||
Assert.Equal(1, counts.Failed);
|
||||
Assert.Equal(2, counts.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_HonourEveryOtherFilterDimension()
|
||||
{
|
||||
var s1 = await SeedSiteAsync("S1");
|
||||
var s2 = await SeedSiteAsync("S2");
|
||||
var a = await SeedInstanceAsync("Inst-A", s1);
|
||||
var b = await SeedInstanceAsync("Inst-B", s2);
|
||||
await SeedRecordAsync("dep-a1", a, DeploymentStatus.Success, _base);
|
||||
await SeedRecordAsync("dep-a2", a, DeploymentStatus.Failed, _base.AddMinutes(1));
|
||||
await SeedRecordAsync("dep-b1", b, DeploymentStatus.Success, _base.AddMinutes(2));
|
||||
|
||||
var scoped = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(SiteIdScope: new[] { s1 }));
|
||||
Assert.Equal(2, scoped.Total);
|
||||
|
||||
var searched = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(Search: "Inst-B"));
|
||||
Assert.Equal(1, searched.Total);
|
||||
Assert.Equal(1, searched.Success);
|
||||
|
||||
var byInstance = await _repository.GetDeploymentStatusCountsAsync(
|
||||
new DeploymentListFilter(InstanceId: a));
|
||||
Assert.Equal(2, byInstance.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StatusCounts_EmptySet_IsAllZeros()
|
||||
{
|
||||
var counts = await _repository.GetDeploymentStatusCountsAsync(new DeploymentListFilter());
|
||||
|
||||
Assert.Equal(DeploymentStatusCounts.Empty, counts);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user