@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.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

Deployment Status

@if (_loading) { } else if (_errorMessage != null) {
@_errorMessage
} else { @* Summary cards *@

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

Pending

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

In Progress

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

Successful

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

Failed
@if (_records.Count == 0) {

No deployments recorded.

} else { @foreach (var record in _pagedRecords) { 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
}
@GetInstanceName(record.InstanceId) @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 { private List _records = new(); private List _pagedRecords = new(); private Dictionary _instanceNames = new(); 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; // 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) ─────────────────────────────────── // 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. 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(); /// /// 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 LoadDataAsync() { _loading = _records.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); var systemWide = await SiteScope.IsSystemWideAsync(); var permittedSiteIds = systemWide ? null : await SiteScope.PermittedSiteIdsAsync(); _records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync()) .Where(r => permittedSiteIds == null || (instanceSiteIds.TryGetValue(r.InstanceId, out var sid) && permittedSiteIds.Contains(sid))) .OrderByDescending(r => r.DeployedAt) .ToList(); _totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize)); if (_currentPage > _totalPages) _currentPage = 1; UpdatePage(); } catch (Exception ex) { _errorMessage = $"Failed to load deployments: {ex.Message}"; } _loading = false; } private void GoToPage(int page) { if (page < 1 || page > _totalPages) return; _currentPage = page; UpdatePage(); } 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", 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; } } }