@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")
Nodes
@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(); // Order on the join's plain columns before projecting into FleetNodeRow — // EF Core cannot translate OrderBy over a property of a constructed record. var rows = await db.ClusterNodeGenerationStates.AsNoTracking() .Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n }) .OrderBy(x => x.n.ClusterId) .ThenBy(x => x.s.NodeId) .Select(x => new FleetNodeRow( x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId, x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null, x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt)) .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" => "chip-ok", "Failed" => "chip-bad", "Applying" => "chip-idle", _ => "chip-idle", }; 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); }