diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor index 395c7d9..922da9c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -5,6 +5,7 @@
OtOpcUa Admin
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor new file mode 100644 index 0000000..118606d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor @@ -0,0 +1,172 @@ +@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) + { + + + + + + + + + + } + +
NodeClusterGenerationStatusLast appliedLast seenError
@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); +}