From b5f8661e98d64c9e1f310ae5e52e6ef3516991e1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 13:12:31 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2027=20=E2=80=94=20Fleet=20statu?= =?UTF-8?q?s=20dashboard=20page.=20New=20/fleet=20route=20shows=20per-node?= =?UTF-8?q?=20apply=20state=20(ClusterNodeGenerationState=20joined=20with?= =?UTF-8?q?=20ClusterNode=20for=20the=20ClusterId)=20in=20a=20sortable=20t?= =?UTF-8?q?able=20with=20summary=20cards=20for=20Total=20/=20Applied=20/?= =?UTF-8?q?=20Stale=20/=20Failed=20node=20counts.=20Stale=20detection:=20L?= =?UTF-8?q?astSeenAt=20older=20than=2030s=20triggers=20a=20table-warning?= =?UTF-8?q?=20row=20class=20+=20yellow=20count=20card.=20Failed=20rows=20g?= =?UTF-8?q?et=20table-danger=20+=20red=20card.=20Badge=20classes=20per=20L?= =?UTF-8?q?astAppliedStatus:=20Applied=3Dbg-success,=20Failed=3Dbg-danger,?= =?UTF-8?q?=20Applying=3Dbg-info,=20unknown=3Dbg-secondary.=20Timestamps?= =?UTF-8?q?=20rendered=20as=20relative-age=20strings=20('42s=20ago',=20'15?= =?UTF-8?q?m=20ago',=20'3h=20ago',=20then=20absolute=20date=20for=20>24h).?= =?UTF-8?q?=20Error=20column=20is=20truncated=20to=20320px=20with=20the=20?= =?UTF-8?q?full=20message=20in=20a=20tooltip=20so=20the=20table=20stays=20?= =?UTF-8?q?readable=20on=20wide=20fleets.=20Initial=20data=20load=20on=20O?= =?UTF-8?q?nInitializedAsync;=20auto-refresh=20every=205s=20via=20a=20Time?= =?UTF-8?q?r=20that=20calls=20InvokeAsync(RefreshAsync)=20=E2=80=94=20matc?= =?UTF-8?q?hes=20the=20FleetStatusPoller's=205s=20cadence=20so=20the=20das?= =?UTF-8?q?hboard=20sees=20the=20most=20recent=20state=20without=20polling?= =?UTF-8?q?=20ahead=20of=20the=20broadcaster.=20A=20Refresh=20button=20als?= =?UTF-8?q?o=20kicks=20a=20manual=20reload;=20=5Frefreshing=20gate=20preve?= =?UTF-8?q?nts=20double-runs=20when=20the=20timer=20fires=20during=20an=20?= =?UTF-8?q?in-flight=20query.=20IServiceScopeFactory=20(matches=20FleetSta?= =?UTF-8?q?tusPoller's=20pattern)=20creates=20a=20fresh=20DI=20scope=20per?= =?UTF-8?q?=20refresh=20so=20the=20per-page=20DbContext=20can't=20race=20t?= =?UTF-8?q?he=20timer=20with=20the=20render=20thread;=20no=20new=20DI=20re?= =?UTF-8?q?gistrations=20needed.=20Live=20SignalR=20hub=20push=20is=20deli?= =?UTF-8?q?berately=20deferred=20to=20a=20follow-up=20PR=20=E2=80=94=20the?= =?UTF-8?q?=20existing=20FleetStatusHub=20+=20NodeStateChangedMessage=20al?= =?UTF-8?q?ready=20works=20for=20external=20JavaScript=20clients;=20wiring?= =?UTF-8?q?=20an=20in-process=20Blazor=20Server=20consumer=20adds=20HubCon?= =?UTF-8?q?nectionBuilder=20plumbing=20that's=20worth=20its=20own=20focuse?= =?UTF-8?q?d=20change.=20Sidebar=20link=20added=20to=20MainLayout=20betwee?= =?UTF-8?q?n=20Overview=20and=20Clusters.=20Full=20Admin.Tests=20Unit=20su?= =?UTF-8?q?ite=2014=20pass=20/=200=20fail=20=E2=80=94=20unchanged,=20no=20?= =?UTF-8?q?tests=20regressed.=20Full=20Admin=20build=20clean=20(0=20errors?= =?UTF-8?q?,=200=20warnings).=20Closes=20the=20'no=20per-driver=20dashboar?= =?UTF-8?q?d'=20gap=20from=20lmx-followups=20item=20#7=20at=20the=20fleet?= =?UTF-8?q?=20level;=20per-host=20(platform/engine/Modbus=20PLC)=20granula?= =?UTF-8?q?rity=20still=20needs=20a=20dedicated=20page=20that=20consumes?= =?UTF-8?q?=20IHostConnectivityProbe.GetHostStatuses=20from=20the=20Server?= =?UTF-8?q?=20process=20=E2=80=94=20that's=20the=20live-SignalR=20follow-u?= =?UTF-8?q?p.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Fleet.razor | 172 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor 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); +}