From 6457eba83061359d6169dbc95cfb634d1b3ae790 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 11:45:37 -0400 Subject: [PATCH] feat(adminui): /hosts cluster-grouped Driver Instances section (#8) --- .../Components/Pages/Hosts.razor | 171 +++++++++++++++++- 1 file changed, 164 insertions(+), 7 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 7f6ff261..d0cec0e0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -1,13 +1,23 @@ @page "/hosts" -@* Akka cluster topology: each member's NodeId (host:port), roles, leader status. v2 reshapes - v1's "driver host" page — there are no per-driver host rows yet (driver-instance child actors - land with F7). For now this is the cluster-membership view; expand to per-driver rows when - DriverHostActor starts spawning DriverInstanceActor children. *@ +@* Two views, top to bottom. (1) Akka cluster topology: each member's NodeId (host:port), roles, + leader status — the cluster-membership view. (2) Driver instances: live driver-health grouped by + cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows + are cluster-scoped (keyed per driver instance across the cluster, not per member). The section + reads the in-process driver-health snapshot store directly + reloads its config from the ConfigDB. *@ @attribute [Microsoft.AspNetCore.Authorization.Authorize] @rendermode RenderMode.InteractiveServer @using Akka.Actor @using Akka.Cluster +@using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.Logging +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs +@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers @inject ActorSystem ActorSystem +@inject IDriverStatusSnapshotStore DriverStore +@inject IDbContextFactory DbFactory +@inject Microsoft.Extensions.Logging.ILogger Logger @implements IDisposable
@@ -114,6 +124,90 @@ else } +
+ Driver health is keyed per driver instance across the cluster, not per Akka member — + the health feed carries no per-member identity. These rows are therefore cluster-scoped: + one entry per configured driver instance, grouped under its cluster. +
+ +@if (_driverGroups is null) +{ +

Loading…

+} +else if (_driverGroups.Count == 0) +{ +
+ No driver instances reporting yet. +
+} +else +{ +
+
Driver instances
+ @foreach (var g in _driverGroups) + { +
+
Cluster @g.ClusterId
+
+ @if (g.Nodes.Count == 0) + { + + } + else + { + @foreach (var n in g.Nodes) + { + @n.NodeId (@n.Host:@n.OpcUaPort) + } + } +
+
+ + + + + + + + + + + + + @if (g.Drivers.Count == 0) + { + + + + } + else + { + @foreach (var d in g.Drivers) + { + + + + + + + + + } + } + +
DriverTypeStatusLast readErrors/5 minLast error
No drivers.
+ @(d.Name ?? d.DriverInstanceId) + @if (d.Name is not null) + { + @d.DriverInstanceId + } + @(d.DriverType ?? "—")@d.State@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")@d.ErrorCount5Min@(d.LastError ?? "—")
+
+
+ } +
+} + @code { private const int RefreshIntervalSeconds = 5; @@ -122,10 +216,23 @@ else private DateTime? _lastRefreshUtc; private Timer? _timer; - protected override void OnInitialized() + private IReadOnlyList? _driverGroups; + private List _nodes = new(); + private List _instances = new(); + + protected override async Task OnInitializedAsync() { Refresh(); - _timer = new Timer(_ => InvokeAsync(() => { Refresh(); StateHasChanged(); }), null, + await LoadConfigAsync(); + RebuildDriverGroups(); + DriverStore.SnapshotChanged += OnSnapshotChanged; + _timer = new Timer(_ => InvokeAsync(async () => + { + Refresh(); + await LoadConfigAsync(); + RebuildDriverGroups(); + StateHasChanged(); + }), null, TimeSpan.FromSeconds(RefreshIntervalSeconds), TimeSpan.FromSeconds(RefreshIntervalSeconds)); } @@ -138,6 +245,8 @@ else { await Task.Yield(); Refresh(); + await LoadConfigAsync(); + RebuildDriverGroups(); } finally { @@ -146,6 +255,39 @@ else } } + // Load the per-cluster node + driver-instance config from the ConfigDB. Kept cheap and + // swallow-on-failure so a transient DB hiccup dims the enrichment (rows fall back to id/—) + // without crashing the page. The live health feed (the snapshot store) is independent of this. + private async Task LoadConfigAsync() + { + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + _nodes = await db.ClusterNodes.AsNoTracking() + .Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort)) + .ToListAsync(); + _instances = await db.DriverInstances.AsNoTracking() + .Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType)) + .ToListAsync(); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "/hosts: failed to load driver-instance config; rows show ids only."); + _nodes = new(); + _instances = new(); + } + } + + // Re-project the cluster-grouped driver rows from the latest live snapshots + cached config. + private void RebuildDriverGroups() + => _driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances); + + // Raised by the snapshot store on the bridge actor's thread for every driver instance. Rebuild + // from the cached config (cheap — no DB hit) and marshal onto the render sync context. A + // brand-new driver shows by id until the next config reload (timer/manual Refresh) enriches it. + private void OnSnapshotChanged(DriverHealthChanged _) + => InvokeAsync(() => { RebuildDriverGroups(); StateHasChanged(); }); + private void Refresh() { var cluster = Akka.Cluster.Cluster.Get(ActorSystem); @@ -184,7 +326,22 @@ else _ => "chip-idle", }; - public void Dispose() => _timer?.Dispose(); + // Map DriverState string → chip CSS class (mirrors DriverStatusPanel.ChipClass). + private static string DriverChipClass(string? state) => state switch + { + "Healthy" => "chip-ok", + "Degraded" => "chip-warn", + "Connecting" => "chip-warn", + "Reconnecting" => "chip-warn", + "Faulted" => "chip-bad", + _ => "chip-idle", + }; + + public void Dispose() + { + _timer?.Dispose(); + DriverStore.SnapshotChanged -= OnSnapshotChanged; + } private sealed record MemberRow( string Address,