@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

Deployment Status

@* ── Status tiles ────────────────────────────────────────────────────────── Counts come from ONE server-side grouped aggregation over the same filtered set the table is drawn from — not from the rows on screen — so they stay whole-fleet totals while the table shows a single page. Each tile doubles as the status filter; the active one is pressed and clicking it again clears. *@
@* Each tile is a real
}
@* ── Filters ── *@
@_totalCount matching @(_totalCount == 1 ? "deployment" : "deployments")
@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else { @if (_rows.Count == 0) {

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

} else { @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)]; @if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId)) { } }
Deployment Instance Status Deployed By Started Completed Actions
@* Deployment id and revision hash are two distinct identifiers — kept on separate lines so neither reads as part of the other. *@ @idShort @if (!string.IsNullOrEmpty(revShort)) {
@revShort
}
@record.InstanceUniqueName @if (isFailed) { } @record.Status @if (record.Status == DeploymentStatus.InProgress) { } @record.DeployedBy @if (record.CompletedAt.HasValue) { } else { — } @if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage)) { }
Error:
@record.ErrorMessage
} @if (_totalPages > 1) { } } @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 _rows = Array.Empty(); private DeploymentStatusCounts _counts = DeploymentStatusCounts.Empty; private int _totalCount; private bool _loading = true; private string? _errorMessage; private bool _autoRefresh = true; private readonly HashSet _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; /// Raw contents of the search box, updated on every keystroke. private string _searchInput = string.Empty; /// /// The search term the last query actually ran with. Distinct from /// so the debounce window can absorb keystrokes /// without the rendered result count implying a query that has not run. /// private string _appliedSearch = string.Empty; /// /// Whether the CURRENTLY RENDERED result set is constrained. Keyed on the /// applied search, not the box contents, so the empty-state message describes /// the query that actually ran. /// private bool HasActiveFilter => _statusFilter is not null || !string.IsNullOrWhiteSpace(_appliedSearch); /// /// Whether "Clear filters" has anything to clear. Keyed on the box CONTENTS as /// well, so the escape hatch is live the moment the operator types — it must not /// be dead for the 500 ms the debounce window is still open. /// private bool CanClearFilters => HasActiveFilter || !string.IsNullOrWhiteSpace(_searchInput); /// Tile definitions, in display order. The "All" tile is rendered separately. private static readonly (DeploymentStatus Status, string Label, string Variant, string TestId)[] StatusTiles = { (DeploymentStatus.Pending, "Pending", "warning", "deploy-tile-pending"), (DeploymentStatus.InProgress, "In Progress", "info", "deploy-tile-inprogress"), (DeploymentStatus.Success, "Successful", "success", "deploy-tile-success"), (DeploymentStatus.Failed, "Failed", "danger", "deploy-tile-failed"), }; private int CountFor(DeploymentStatus status) => _counts.For(status); // CentralUI-022: IDeploymentStatusNotifier is a process singleton that // raises StatusChanged on the DeploymentManager service thread. Dispose() // unsubscribes, but the notifier can read its subscriber list and begin // 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; /// Coalescing timer for pushes arriving inside the debounce window. Disposed with the component. 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(); } /// Trailing edge of the debounce: disarm, stamp, and run the one coalesced reload. 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; /// Trailing-edge timer for search-box input. Disposed with the component. private Timer? _filterDebounceTimer; private readonly object _filterLock = new(); private void OnSearchInput(ChangeEventArgs e) { _searchInput = e.Value?.ToString() ?? string.Empty; lock (_filterLock) { if (_disposed) return; // Re-arm rather than stack: one pending query per idle gap, not one per // keystroke. _filterDebounceTimer?.Dispose(); _filterDebounceTimer = new Timer( _ => OnFilterDebounceElapsed(), null, TimeSpan.FromMilliseconds(FilterDebounceMs), Timeout.InfiniteTimeSpan); } } private void OnFilterDebounceElapsed() { lock (_filterLock) { _filterDebounceTimer?.Dispose(); _filterDebounceTimer = null; if (_disposed) return; } // 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(); } /// /// Marshals the filter-debounce trailing edge onto the renderer's dispatcher. /// The page-1 reset MUST happen inside InvokeAsync, and behind the SAME /// request-generation guard as the rest of the applied state (P6-11) — /// runs on a raw /// 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 /// resetToFirstPage: true defers the actual _currentPage = 1 /// write into 's apply step, so a stale flight here /// is discarded exactly like a stale flight on any other initiator. /// 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. } } /// /// Reloads the deployment table on the renderer's dispatcher, guarded /// against the component being disposed mid-flight (CentralUI-022): /// InvokeAsync throws once the /// circuit is gone, and this handler runs fire-and-forget so that exception /// would otherwise go unobserved on the DeploymentManager thread. /// 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(); } /// /// 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 pageToQuery rather /// than writing _currentPage 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. /// 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? 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; } } }