fix(central-ui): resolve CentralUI-006 — push-based deployment status via IDeploymentStatusNotifier, remove 10s polling timer
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
src/ScadaLink.DeploymentManager/DeploymentStatusNotifier.cs
Normal file
51
src/ScadaLink.DeploymentManager/DeploymentStatusNotifier.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/ScadaLink.DeploymentManager/IDeploymentStatusNotifier.cs
Normal file
49
src/ScadaLink.DeploymentManager/IDeploymentStatusNotifier.cs
Normal 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);
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user