@page "/fleet" @using Microsoft.EntityFrameworkCore @using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject IServiceScopeFactory ScopeFactory @implements IDisposable

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 node state recorded yet. Nodes publish their state to the central DB on each poll; if this list is empty, either no nodes have been registered or the poller hasn't run yet.
} else {
Nodes
@_rows.Count
Applied
@_rows.Count(r => r.Status == "Applied")
Stale
@_rows.Count(r => IsStale(r))
Failed
@_rows.Count(r => r.Status == "Failed")
@foreach (var r in _rows) { }
Node Cluster Generation Status Last applied Last seen Error
@r.NodeId @r.ClusterId @(r.GenerationId?.ToString() ?? "—") @(r.Status ?? "—") @FormatAge(r.AppliedAt) @FormatAge(r.SeenAt) @r.Error
} @code { // Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees // the most recent published state without polling ahead of the broadcaster. private const int RefreshIntervalSeconds = 5; private List? _rows; private bool _refreshing; private DateTime? _lastRefreshUtc; private Timer? _timer; protected override async Task OnInitializedAsync() { await RefreshAsync(); _timer = new Timer(async _ => await InvokeAsync(RefreshAsync), state: null, dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds), period: TimeSpan.FromSeconds(RefreshIntervalSeconds)); } private async Task RefreshAsync() { if (_refreshing) return; _refreshing = true; try { using var scope = ScopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var rows = await db.ClusterNodeGenerationStates.AsNoTracking() .Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow( s.NodeId, n.ClusterId, s.CurrentGenerationId, s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null, s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt)) .OrderBy(r => r.ClusterId) .ThenBy(r => r.NodeId) .ToListAsync(); _rows = rows; _lastRefreshUtc = DateTime.UtcNow; } finally { _refreshing = false; StateHasChanged(); } } private static bool IsStale(FleetNodeRow r) { if (r.SeenAt is null) return true; return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30); } private static string RowClass(FleetNodeRow r) => r.Status switch { "Failed" => "table-danger", _ when IsStale(r) => "table-warning", _ => "", }; private static string StatusBadge(string? status) => status switch { "Applied" => "bg-success", "Failed" => "bg-danger", "Applying" => "bg-info", _ => "bg-secondary", }; private static string FormatAge(DateTime? t) { if (t is null) return "—"; var age = DateTime.UtcNow - t.Value; if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago"; if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago"; if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago"; return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'"); } public void Dispose() => _timer?.Dispose(); internal sealed record FleetNodeRow( string NodeId, string ClusterId, long? GenerationId, string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt); }