21d50d4406
Three fetch initiators (search debounce, paging, push/coalesce reload) could each have a LoadDataAsync flight in flight at once; completion order was not call order, so a slow, stale flight could resolve after a newer one and clobber the grid the operator was already looking at. Add a monotonic _requestGeneration counter, bumped at fetch start and checked at apply — last-write-wins by generation, not by whichever await resolves first. Separately, the filter-debounce timer wrote `_currentPage = 1` directly on its raw System.Threading.Timer callback thread — a Blazor threading violation, since component state may only be mutated on the renderer's sync context — and that write was not covered by the generation guard at all. LoadDataAsync now takes a resetToFirstPage flag, resolves the target page into a local, and applies it only behind the same generation check as every other piece of state, via a new DispatchFilterReloadAsync that marshals through InvokeAsync like the existing push-reload path. Tests (DeploymentsRequestGenerationTests, bUnit): a delayed stale flight arriving after a newer one does not overwrite the grid; a stale filter-debounce flight does not reset the page after a newer navigation. Both driven deterministically via TaskCompletionSource-backed repository stubs. Verified to fail against the unfixed code (swapped via `git show HEAD:<path>`, not `git stash` — repo-wide and unsafe across the parallel worktree wave).
694 lines
32 KiB
Plaintext
694 lines
32 KiB
Plaintext
@page "/deployment/deployments"
|
|
@using ZB.MOM.WW.ScadaBridge.Security
|
|
@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 ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
|
|
@inject ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier
|
|
@implements IDisposable
|
|
|
|
<div class="container-fluid mt-3">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Deployment Status</h4>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Deployment list actions">
|
|
<button class="btn btn-outline-secondary" @onclick="ToggleAutoRefresh"
|
|
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
|
|
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
|
|
</button>
|
|
<button class="btn btn-outline-secondary" @onclick="() => LoadDataAsync()" aria-label="Refresh deployments">Refresh</button>
|
|
</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" />
|
|
}
|
|
else if (_errorMessage != null)
|
|
{
|
|
<div class="alert alert-danger">@_errorMessage</div>
|
|
}
|
|
else
|
|
{
|
|
@if (_rows.Count == 0)
|
|
{
|
|
<div class="text-center py-5 text-muted">
|
|
<p class="mb-0">@(HasActiveFilter ? "No deployments match the current filters." : "No deployments recorded.")</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm table-striped table-hover align-middle">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>Deployment</th>
|
|
<th>Instance</th>
|
|
<th>Status</th>
|
|
<th>Deployed By</th>
|
|
<th>Started</th>
|
|
<th>Completed</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var record in _rows)
|
|
{
|
|
var rowId = $"deploy-row-{record.DeploymentId}";
|
|
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
|
|
var isFailed = record.Status == DeploymentStatus.Failed;
|
|
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
|
|
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
|
|
<tr id="@rowId" class="@GetRowClass(record.Status)">
|
|
<td>
|
|
@* Deployment id and revision hash are two distinct
|
|
identifiers — kept on separate lines so neither
|
|
reads as part of the other. *@
|
|
<code class="small" title="@record.DeploymentId">@idShort</code>
|
|
@if (!string.IsNullOrEmpty(revShort))
|
|
{
|
|
<div class="font-monospace small text-muted" title="@record.RevisionHash">@revShort</div>
|
|
}
|
|
</td>
|
|
<td>@record.InstanceUniqueName</td>
|
|
<td>
|
|
@if (isFailed)
|
|
{
|
|
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
|
|
}
|
|
<span class="badge @GetStatusBadge(record.Status)"
|
|
aria-label="@($"Deployment status: {record.Status}")">
|
|
@record.Status
|
|
@if (record.Status == DeploymentStatus.InProgress)
|
|
{
|
|
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
|
|
role="status" aria-label="Deployment in progress"></span>
|
|
}
|
|
</span>
|
|
</td>
|
|
<td class="small">@record.DeployedBy</td>
|
|
<td class="small">
|
|
<TimestampDisplay Value="@record.DeployedAt" />
|
|
</td>
|
|
<td class="small">
|
|
@if (record.CompletedAt.HasValue)
|
|
{
|
|
<TimestampDisplay Value="@record.CompletedAt.Value" />
|
|
}
|
|
else
|
|
{
|
|
<span class="text-muted">—</span>
|
|
}
|
|
</td>
|
|
<td class="small text-end">
|
|
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
|
|
{
|
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
|
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
|
|
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
|
|
aria-controls="@errorCollapseId">
|
|
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
|
|
</button>
|
|
}
|
|
</td>
|
|
</tr>
|
|
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
|
|
{
|
|
<tr id="@errorCollapseId" class="table-danger">
|
|
<td colspan="7">
|
|
<div class="small">
|
|
<strong>Error:</strong>
|
|
<pre class="detail-pre mb-0 mt-1 small">@record.ErrorMessage</pre>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
@if (_totalPages > 1)
|
|
{
|
|
<nav>
|
|
<ul class="pagination pagination-sm justify-content-end">
|
|
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
|
|
<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))
|
|
{
|
|
if (page == 0)
|
|
{
|
|
<li class="page-item disabled">
|
|
<span class="page-link">…</span>
|
|
</li>
|
|
}
|
|
else
|
|
{
|
|
var p = page;
|
|
<li class="page-item @(p == _currentPage ? "active" : "")">
|
|
<button class="page-link" @onclick="() => GoToPageAsync(p)">@(p)</button>
|
|
</li>
|
|
}
|
|
}
|
|
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
|
<button class="page-link" data-test="deploy-page-next" @onclick="() => GoToPageAsync(_currentPage + 1)">Next</button>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
// ── 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;
|
|
private readonly HashSet<string> _expandedErrors = new();
|
|
|
|
private int _currentPage = 1;
|
|
private int _totalPages;
|
|
private const int PageSize = 25;
|
|
|
|
// ── Request-generation guard (P6-11) ────────────────────────────────────────
|
|
// Three independent initiators can each start a LoadDataAsync flight: a search
|
|
// debounce, a page click, and the push/coalesce reload. Every flight awaits its
|
|
// own round-trip to central MS SQL, so COMPLETION order is not CALL order — a
|
|
// slow, stale flight that started first can resolve after a faster, newer one
|
|
// and clobber the grid the operator is already looking at. _requestGeneration
|
|
// is bumped synchronously at the top of every LoadDataAsync call (call order is
|
|
// still reliable — increments never interleave on the renderer's dispatcher),
|
|
// and a flight discards its own results at apply time if a newer flight has
|
|
// since started. Last-write-wins by GENERATION, not by whichever await
|
|
// resolves first.
|
|
private long _requestGeneration;
|
|
|
|
// ── 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
|
|
// invoking OnDeploymentStatusChanged just before this component is disposed.
|
|
// The handler then runs against a disposed component and InvokeAsync throws
|
|
// ObjectDisposedException as an unobserved fire-and-forget task exception.
|
|
// This flag (set first in Dispose()) makes a racing callback no-op, and the
|
|
// dispatch swallows the residual ObjectDisposedException — mirroring the
|
|
// DebugView (CentralUI-009) and ToastNotification (CentralUI-010) guards.
|
|
private volatile bool _disposed;
|
|
|
|
// CentralUI-006: deployment status updates are push-based, not polled.
|
|
// DeploymentManager raises IDeploymentStatusNotifier.StatusChanged on every
|
|
// deployment-record status write; this page subscribes to it and reloads,
|
|
// and Blazor Server pushes the re-render to the browser over its SignalR
|
|
// circuit — satisfying the design's "no polling required" requirement.
|
|
// The notifier event is raised on the DeploymentManager service thread, so
|
|
// the handler marshals onto the renderer via InvokeAsync.
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadDataAsync();
|
|
DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged;
|
|
}
|
|
|
|
// ── Push coalescing (arch-review WP2.4) ───────────────────────────────────
|
|
// The notifier fires per status WRITE, so a site deploy of N instances produced
|
|
// 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;
|
|
|
|
/// <summary>Coalescing timer for pushes arriving inside the debounce window. Disposed with the component.</summary>
|
|
private Timer? _coalesceTimer;
|
|
|
|
private void OnDeploymentStatusChanged(ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusChange change)
|
|
{
|
|
// CentralUI-022: a callback racing disposal must not touch the component.
|
|
if (_disposed || !_autoRefresh) return;
|
|
|
|
lock (_coalesceLock)
|
|
{
|
|
if (_disposed) return;
|
|
|
|
var sinceLast = DateTimeOffset.UtcNow - _lastReloadAt;
|
|
if (sinceLast >= TimeSpan.FromMilliseconds(ReloadDebounceMs))
|
|
{
|
|
// Idle — reload straight away (leading edge).
|
|
_lastReloadAt = DateTimeOffset.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
// Inside the window: arm one trailing reload for the remainder, and
|
|
// let any further push in this window ride it.
|
|
if (_coalesceTimer is not null) return;
|
|
|
|
var delay = TimeSpan.FromMilliseconds(ReloadDebounceMs) - sinceLast;
|
|
_coalesceTimer = new Timer(_ => OnCoalesceElapsed(), null, delay, Timeout.InfiniteTimeSpan);
|
|
return;
|
|
}
|
|
}
|
|
|
|
_ = DispatchReloadAsync();
|
|
}
|
|
|
|
/// <summary>Trailing edge of the debounce: disarm, stamp, and run the one coalesced reload.</summary>
|
|
private void OnCoalesceElapsed()
|
|
{
|
|
lock (_coalesceLock)
|
|
{
|
|
_coalesceTimer?.Dispose();
|
|
_coalesceTimer = null;
|
|
if (_disposed) return;
|
|
_lastReloadAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
_ = DispatchReloadAsync();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// This callback runs on the raw System.Threading.Timer thread — it is NOT
|
|
// the renderer's synchronization context. _currentPage is component state,
|
|
// so its reset to page 1 must be marshaled through InvokeAsync the same as
|
|
// the reload itself (P6-11); writing it here directly was a Blazor
|
|
// threading violation that could also race a same-tick page click on the
|
|
// renderer thread.
|
|
_ = DispatchFilterReloadAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marshals the filter-debounce trailing edge onto the renderer's dispatcher.
|
|
/// The page-1 reset MUST happen inside <c>InvokeAsync</c>, and behind the SAME
|
|
/// request-generation guard as the rest of the applied state (P6-11) —
|
|
/// <see cref="OnFilterDebounceElapsed"/> runs on a raw
|
|
/// <see cref="System.Threading.Timer"/> callback thread, not the renderer's
|
|
/// synchronization context, and a slow search flight can resolve after the
|
|
/// operator has already navigated to a different page by other means. Passing
|
|
/// <c>resetToFirstPage: true</c> defers the actual <c>_currentPage = 1</c>
|
|
/// write into <see cref="LoadDataAsync"/>'s apply step, so a stale flight here
|
|
/// is discarded exactly like a stale flight on any other initiator.
|
|
/// </summary>
|
|
private async Task DispatchFilterReloadAsync()
|
|
{
|
|
if (_disposed) return;
|
|
try
|
|
{
|
|
await InvokeAsync(async () =>
|
|
{
|
|
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 (applied only if this flight is still current;
|
|
// see the resetToFirstPage remarks on LoadDataAsync).
|
|
await LoadDataAsync(resetToFirstPage: true);
|
|
StateHasChanged();
|
|
});
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Component disposed between the guard and the dispatch — ignore.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloads the deployment table on the renderer's dispatcher, guarded
|
|
/// against the component being disposed mid-flight (CentralUI-022):
|
|
/// <c>InvokeAsync</c> throws <see cref="ObjectDisposedException"/> once the
|
|
/// circuit is gone, and this handler runs fire-and-forget so that exception
|
|
/// would otherwise go unobserved on the DeploymentManager thread.
|
|
/// </summary>
|
|
private async Task DispatchReloadAsync()
|
|
{
|
|
if (_disposed) return;
|
|
try
|
|
{
|
|
await InvokeAsync(async () =>
|
|
{
|
|
if (_disposed) return;
|
|
await LoadDataAsync();
|
|
StateHasChanged();
|
|
});
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// Component disposed between the guard and the dispatch — ignore.
|
|
}
|
|
}
|
|
|
|
private void ToggleAutoRefresh()
|
|
{
|
|
// When paused, incoming push notifications are ignored; "Refresh" still
|
|
// forces a manual reload. No timer is involved either way.
|
|
_autoRefresh = !_autoRefresh;
|
|
}
|
|
|
|
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
|
|
|
|
private void ToggleErrorExpansion(string deploymentId)
|
|
{
|
|
if (!_expandedErrors.Remove(deploymentId))
|
|
{
|
|
_expandedErrors.Add(deploymentId);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <param name="resetToFirstPage">
|
|
/// True when this flight represents a redefinition of the result set (a new
|
|
/// search term) rather than a plain reload of whatever page is currently on
|
|
/// screen. The target page is resolved into a LOCAL <c>pageToQuery</c> rather
|
|
/// than writing <c>_currentPage</c> up front, so the page number — like every
|
|
/// other piece of applied state — only lands once this flight has been
|
|
/// confirmed to still be the newest one (see the generation guard below). A
|
|
/// stale, slow search flight that resolves after a newer page click must not
|
|
/// yank the operator back to page 1 out from under a navigation they already
|
|
/// completed.
|
|
/// </param>
|
|
private async Task LoadDataAsync(bool resetToFirstPage = false)
|
|
{
|
|
// Captured before any await — see the request-generation guard note on
|
|
// _requestGeneration above. Bumping it here (rather than at apply time)
|
|
// means it also marks THIS flight as the newest one in flight, so an
|
|
// earlier flight still awaiting its own round-trip will find itself
|
|
// stale when it reaches the check below.
|
|
var generation = ++_requestGeneration;
|
|
var pageToQuery = resetToFirstPage ? 1 : _currentPage;
|
|
|
|
_loading = _rows.Count == 0; // Only show loading on first load
|
|
_errorMessage = null;
|
|
try
|
|
{
|
|
// 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();
|
|
IReadOnlyCollection<int>? siteScope = systemWide
|
|
? null
|
|
: (await SiteScope.PermittedSiteIdsAsync()).ToArray();
|
|
|
|
_appliedSearch = _searchInput.Trim();
|
|
var filter = new DeploymentListFilter(
|
|
Status: _statusFilter,
|
|
Search: _appliedSearch,
|
|
SiteIdScope: siteScope);
|
|
|
|
var page = await DeploymentManagerRepository
|
|
.QueryDeploymentListPageAsync(filter, pageToQuery, 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.
|
|
var counts = await DeploymentManagerRepository.GetDeploymentStatusCountsAsync(filter);
|
|
|
|
// A newer flight has started since this one began (its generation is
|
|
// now the current one) — this flight's results are stale. Discard
|
|
// them rather than overwrite whatever the newer flight already
|
|
// applied (or is about to apply). This guards _currentPage exactly
|
|
// like every other applied field — a stale reset-to-page-1 flight
|
|
// must not clobber a page the operator has since navigated to.
|
|
if (generation != _requestGeneration)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_currentPage = pageToQuery;
|
|
_rows = page.Rows;
|
|
_totalCount = page.TotalCount;
|
|
_totalPages = page.PageCount(PageSize);
|
|
_counts = counts;
|
|
|
|
// 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);
|
|
|
|
if (generation != _requestGeneration)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_rows = reread.Rows;
|
|
_totalCount = reread.TotalCount;
|
|
_totalPages = reread.PageCount(PageSize);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (generation != _requestGeneration)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_errorMessage = $"Failed to load deployments: {ex.Message}";
|
|
}
|
|
|
|
if (generation == _requestGeneration)
|
|
{
|
|
_loading = false;
|
|
}
|
|
}
|
|
|
|
private async Task GoToPageAsync(int page)
|
|
{
|
|
if (page < 1 || page > _totalPages || page == _currentPage) return;
|
|
_currentPage = page;
|
|
await LoadDataAsync();
|
|
}
|
|
|
|
private static string GetStatusBadge(DeploymentStatus status) => status switch
|
|
{
|
|
DeploymentStatus.Pending => "bg-warning text-dark",
|
|
DeploymentStatus.InProgress => "bg-info text-dark",
|
|
DeploymentStatus.Success => "bg-success",
|
|
DeploymentStatus.Failed => "bg-danger",
|
|
_ => "bg-secondary"
|
|
};
|
|
|
|
private static string GetRowClass(DeploymentStatus status) => status switch
|
|
{
|
|
DeploymentStatus.Failed => "table-danger",
|
|
DeploymentStatus.InProgress => "table-info",
|
|
_ => ""
|
|
};
|
|
|
|
public void Dispose()
|
|
{
|
|
// CentralUI-022: set the guard first so a callback already in flight on
|
|
// the DeploymentManager thread no-ops, then unsubscribe so no further
|
|
// status change reaches this disposed component.
|
|
_disposed = true;
|
|
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
|
|
lock (_coalesceLock)
|
|
{
|
|
_coalesceTimer?.Dispose();
|
|
_coalesceTimer = null;
|
|
}
|
|
lock (_filterLock)
|
|
{
|
|
_filterDebounceTimer?.Dispose();
|
|
_filterDebounceTimer = null;
|
|
}
|
|
}
|
|
}
|