fix(central-ui): resolve CentralUI-006 — push-based deployment status via IDeploymentStatusNotifier, remove 10s polling timer

This commit is contained in:
Joseph Doherty
2026-05-17 00:02:45 -04:00
parent a55502254e
commit 34588ae10c
11 changed files with 459 additions and 36 deletions

View File

@@ -8,6 +8,7 @@
@inject IDeploymentManagerRepository DeploymentManagerRepository
@inject ITemplateEngineRepository TemplateEngineRepository
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
@inject ScadaLink.DeploymentManager.IDeploymentStatusNotifier DeploymentStatusNotifier
@implements IDisposable
<div class="container-fluid mt-3">
@@ -196,47 +197,42 @@
private Dictionary<int, string> _instanceNames = new();
private bool _loading = true;
private string? _errorMessage;
private Timer? _refreshTimer;
private bool _autoRefresh = true;
private readonly HashSet<string> _expandedErrors = new();
private int _currentPage = 1;
private int _totalPages;
private const int PageSize = 25;
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10);
// 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();
StartTimer();
DeploymentStatusNotifier.StatusChanged += OnDeploymentStatusChanged;
}
private void StartTimer()
private void OnDeploymentStatusChanged(ScadaLink.DeploymentManager.DeploymentStatusChange change)
{
_refreshTimer?.Dispose();
_refreshTimer = new Timer(_ =>
if (!_autoRefresh) return;
_ = InvokeAsync(async () =>
{
InvokeAsync(async () =>
{
if (!_autoRefresh) return;
await LoadDataAsync();
StateHasChanged();
});
}, null, RefreshInterval, RefreshInterval);
await LoadDataAsync();
StateHasChanged();
});
}
private void ToggleAutoRefresh()
{
// When paused, incoming push notifications are ignored; "Refresh" still
// forces a manual reload. No timer is involved either way.
_autoRefresh = !_autoRefresh;
if (_autoRefresh)
{
StartTimer();
}
else
{
_refreshTimer?.Dispose();
_refreshTimer = null;
}
}
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
@@ -320,6 +316,8 @@
public void Dispose()
{
_refreshTimer?.Dispose();
// Unsubscribe so a status change after the circuit is gone does not
// touch a disposed component (the notifier is a process singleton).
DeploymentStatusNotifier.StatusChanged -= OnDeploymentStatusChanged;
}
}

View File

@@ -41,6 +41,7 @@ public class DeploymentService
private readonly OperationLockManager _lockManager;
private readonly IAuditService _auditService;
private readonly DiffService _diffService;
private readonly IDeploymentStatusNotifier _statusNotifier;
private readonly DeploymentManagerOptions _options;
private readonly ILogger<DeploymentService> _logger;
@@ -60,6 +61,7 @@ public class DeploymentService
OperationLockManager lockManager,
IAuditService auditService,
DiffService diffService,
IDeploymentStatusNotifier statusNotifier,
IOptions<DeploymentManagerOptions> options,
ILogger<DeploymentService> logger)
{
@@ -70,10 +72,21 @@ public class DeploymentService
_lockManager = lockManager;
_auditService = auditService;
_diffService = diffService;
_statusNotifier = statusNotifier;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// CentralUI-006: raises a push notification that a deployment record's
/// status was just persisted, so the Central UI deployment-status page can
/// re-render over its SignalR circuit instead of polling. Called at every
/// point a <see cref="DeploymentRecord"/> status is written.
/// </summary>
private void NotifyStatusChange(DeploymentRecord record) =>
_statusNotifier.NotifyStatusChanged(
new DeploymentStatusChange(record.DeploymentId, record.InstanceId, record.Status));
/// <summary>
/// Resolves the site's string identifier from the numeric DB ID.
/// The communication layer routes by string identifier (e.g. "site-a"), not DB ID.
@@ -155,11 +168,13 @@ public class DeploymentService
await _repository.AddDeploymentRecordAsync(record, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
NotifyStatusChange(record);
// Update status to InProgress
record.Status = DeploymentStatus.InProgress;
await _repository.UpdateDeploymentRecordAsync(record, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
NotifyStatusChange(record);
try
{
@@ -187,6 +202,7 @@ public class DeploymentService
// non-Success record while the site is running the new config.
await _repository.UpdateDeploymentRecordAsync(record, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
NotifyStatusChange(record);
if (response.Status == DeploymentStatus.Success)
{
@@ -253,6 +269,7 @@ public class DeploymentService
{
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
await _repository.SaveChangesAsync(CancellationToken.None);
NotifyStatusChange(record);
await _auditService.LogAsync(user, "DeployFailed", "Instance", instanceId.ToString(),
instance.UniqueName, new { DeploymentId = deploymentId, Error = ex.Message },
@@ -624,6 +641,7 @@ public class DeploymentService
prior.CompletedAt = DateTimeOffset.UtcNow;
await _repository.UpdateDeploymentRecordAsync(prior, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
NotifyStatusChange(prior);
await _auditService.LogAsync(prior.DeployedBy, "DeployReconciled", "Instance",
instance.Id.ToString(), instance.UniqueName,

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.Logging;
namespace ScadaLink.DeploymentManager;
/// <summary>
/// Default <see cref="IDeploymentStatusNotifier"/> implementation. A simple
/// in-process event broadcaster: registered as a DI singleton so it is shared
/// between the central-process <see cref="DeploymentService"/> and the Central
/// UI's Blazor circuits (CentralUI-006).
///
/// A throwing subscriber must never break the deployment pipeline, so each
/// handler is invoked individually and its exceptions are caught and logged.
/// </summary>
public sealed class DeploymentStatusNotifier : IDeploymentStatusNotifier
{
private readonly ILogger<DeploymentStatusNotifier> _logger;
public DeploymentStatusNotifier(ILogger<DeploymentStatusNotifier> logger)
{
_logger = logger;
}
/// <inheritdoc />
public event Action<DeploymentStatusChange>? StatusChanged;
/// <inheritdoc />
public void NotifyStatusChanged(DeploymentStatusChange change)
{
var handlers = StatusChanged;
if (handlers == null)
return;
// Invoke each subscriber in isolation: one faulting handler (e.g. a
// disposed Blazor circuit) must not stop the others from being notified
// and must not propagate back into the deployment pipeline.
foreach (var handler in handlers.GetInvocationList())
{
try
{
((Action<DeploymentStatusChange>)handler)(change);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"A deployment-status-change subscriber threw for deployment {DeploymentId} " +
"(status {Status}); continuing with remaining subscribers",
change.DeploymentId, change.Status);
}
}
}
}

View File

@@ -0,0 +1,49 @@
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.DeploymentManager;
/// <summary>
/// Payload describing a single deployment-record status change. Kept small —
/// just the deployment identity, the owning instance, and the new status — so
/// it is cheap to raise on the hot path and cheap for subscribers to handle.
/// </summary>
/// <param name="DeploymentId">The unique deployment ID whose status changed.</param>
/// <param name="InstanceId">The instance the deployment record belongs to.</param>
/// <param name="Status">The status the deployment record was just written with.</param>
public readonly record struct DeploymentStatusChange(
string DeploymentId,
int InstanceId,
DeploymentStatus Status);
/// <summary>
/// CentralUI-006: push-based deployment-status change notification.
///
/// The design (Component-CentralUI "Real-Time Updates") requires deployment
/// status transitions to push to the UI immediately via SignalR, with no
/// polling. <see cref="DeploymentService"/> raises <see cref="StatusChanged"/>
/// whenever it writes a <see cref="Commons.Entities.Deployment.DeploymentRecord"/>
/// status; the Central UI's deployment-status page subscribes to it and
/// re-renders over its existing Blazor Server SignalR circuit.
///
/// Registered as a DI singleton (see <see cref="ServiceCollectionExtensions.AddDeploymentManager"/>)
/// so the scoped <see cref="DeploymentService"/> and the Blazor circuit's
/// scoped page component share the same instance — both run in the same
/// central Host process.
/// </summary>
public interface IDeploymentStatusNotifier
{
/// <summary>
/// Raised after a deployment record's status has been written. Handlers run
/// synchronously on the caller's thread; subscribers must not block and
/// should marshal any UI work onto their own dispatcher.
/// </summary>
event Action<DeploymentStatusChange>? StatusChanged;
/// <summary>
/// Raises <see cref="StatusChanged"/>. Called by <see cref="DeploymentService"/>
/// at every point a deployment record's status is persisted. A throwing
/// subscriber must not break the deployment pipeline, so handler exceptions
/// are swallowed by the implementation.
/// </summary>
void NotifyStatusChanged(DeploymentStatusChange change);
}

View File

@@ -27,6 +27,14 @@ public static class ServiceCollectionExtensions
// the declared option-class defaults apply.
services.AddOptions<DeploymentManagerOptions>();
services.AddSingleton<OperationLockManager>();
// CentralUI-006: push-based deployment-status notification. Registered
// as a singleton so the scoped DeploymentService and the Central UI's
// scoped Blazor page component share one instance — both run in the
// same central Host process. The deployment-status page subscribes to
// it instead of polling the database every 10 seconds.
services.AddSingleton<IDeploymentStatusNotifier, DeploymentStatusNotifier>();
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
services.AddScoped<DeploymentService>();
services.AddScoped<ArtifactDeploymentService>();