@page "/deployment/deployments" @using ScadaLink.Security @using ScadaLink.Commons.Entities.Deployment @using ScadaLink.Commons.Entities.Instances @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.Commons.Types.Enums @attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] @inject IDeploymentManagerRepository DeploymentManagerRepository @inject ITemplateEngineRepository TemplateEngineRepository @inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope @inject ScadaLink.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
@idShort@(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; } private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change) { // CentralUI-022: a callback racing disposal must not touch the component. if (_disposed || !_autoRefresh) return; _ = DispatchReloadAsync(); } /// /// 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; } }