@page "/fleet" @* Per-node deployment status. v2 reads NodeDeploymentState (the per-(node, deployment) apply progress row owned by each DriverHostActor) and projects the most-recent row per node. The Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied anything yet alongside nodes that have. *@ @attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)] @rendermode RenderMode.InteractiveServer @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Commons.Interfaces @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @inject IClusterRoleInfo Cluster @inject IDbContextFactory DbFactory @implements IAsyncDisposable

Fleet status

Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
@if (_rows is null) {

Loading…

} else if (_rows.Count == 0) {
No driver-role nodes are currently Up in the Akka cluster, and no NodeDeploymentState rows have been recorded yet. Either no driver nodes have joined or the cluster is still forming.
} else {
Nodes
@_rows.Count
Applied
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applied)
Applying
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)
Failed
@_rows.Count(r => r.Status == NodeDeploymentStatus.Failed)
Nodes
@foreach (var r in _rows) { }
Node Roles Status Applied at Started at Failure reason
@r.NodeId @foreach (var role in r.Roles) { @role } @StatusLabel(r.Status) @(r.AppliedAtUtc?.ToString("u") ?? "—") @(r.StartedAtUtc?.ToString("u") ?? "—") @(string.IsNullOrEmpty(r.FailureReason) ? "" : DisplayText.Abbreviate(r.FailureReason, 80))
} @code { private const int RefreshIntervalSeconds = 10; private List? _rows; private bool _refreshing; private DateTime? _lastRefreshUtc; private Timer? _timer; // Overlap guard: a tick whose DB query outruns the refresh interval must not stack behind the // next one. A manual Refresh landing on top of an in-flight tick is a no-op by the same guard. private int _tickRunning; // Page-lifetime cancellation: cancelled in DisposeAsync() so a tick in flight when the circuit // goes away aborts its query instead of running it to completion against a component that's gone. private readonly CancellationTokenSource _cts = new(); protected override async Task OnInitializedAsync() { await LoadAsync(); _timer = new Timer(_ => _ = InvokeAsync(LoadAsync), null, TimeSpan.FromSeconds(RefreshIntervalSeconds), TimeSpan.FromSeconds(RefreshIntervalSeconds)); } private async Task RefreshAsync() => await LoadAsync(); private async Task LoadAsync() { if (Interlocked.Exchange(ref _tickRunning, 1) == 1) return; try { _refreshing = true; StateHasChanged(); await using var db = await DbFactory.CreateDbContextAsync(_cts.Token); // Project the most-recent NodeDeploymentState per node — that's the row the // DriverHostActor most recently touched, regardless of which deployment it was for. var states = await db.NodeDeploymentStates.AsNoTracking() .GroupBy(s => s.NodeId) .Select(g => g.OrderByDescending(s => s.StartedAtUtc).First()) .ToListAsync(_cts.Token); var byNode = states.ToDictionary(s => s.NodeId); // Union with current Akka driver members so a freshly-joined node that has no // NodeDeploymentState row yet still appears as "waiting". var akkaDrivers = Cluster.MembersWithRole("driver") .Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); _rows = allNodes.Select(nodeId => { byNode.TryGetValue(nodeId, out var state); return new NodeRow( NodeId: nodeId, Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty(), Status: state?.Status, StartedAtUtc: state?.StartedAtUtc, AppliedAtUtc: state?.AppliedAtUtc, FailureReason: state?.FailureReason); }).ToList(); _lastRefreshUtc = DateTime.UtcNow; } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { // Circuit disposed while a tick was in flight — a cancelled tick is a clean exit, not a // fault (the discarded timer task would otherwise swallow it silently). } finally { // Release first: a StateHasChanged on a torn-down circuit must never leave the guard set. Volatile.Write(ref _tickRunning, 0); _refreshing = false; StateHasChanged(); } } private static string StatusChipClass(NodeDeploymentStatus? status) => status switch { NodeDeploymentStatus.Applied => "chip-ok", NodeDeploymentStatus.Applying => "chip-caution", NodeDeploymentStatus.Failed => "chip-alert", _ => "chip-idle", }; private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting"; public async ValueTask DisposeAsync() { // Cancel before draining so a tick already in flight aborts its queries rather than running // them to completion against a component that's gone. _cts.Cancel(); // Drain the timer so an in-flight callback can't touch a component that's already gone // (System.Threading.Timer's async dispose awaits any in-flight callback — .NET 6+). if (_timer is not null) await _timer.DisposeAsync(); _cts.Dispose(); } private sealed record NodeRow( string NodeId, IReadOnlyCollection Roles, NodeDeploymentStatus? Status, DateTime? StartedAtUtc, DateTime? AppliedAtUtc, string? FailureReason); }