feat(adminui): /hosts cluster-grouped Driver Instances section (#8)

This commit is contained in:
Joseph Doherty
2026-06-18 11:45:37 -04:00
parent cb062fce90
commit 6457eba830
@@ -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<OtOpcUaConfigDbContext> DbFactory
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
@implements IDisposable
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -114,6 +124,90 @@ else
</section>
}
<section class="panel notice rise" style="animation-delay:.16s">
Driver health is keyed per driver instance <em>across the cluster</em>, not per Akka member —
the health feed carries no per-member identity. These rows are therefore <strong>cluster-scoped</strong>:
one entry per configured driver instance, grouped under its cluster.
</section>
@if (_driverGroups is null)
{
<p>Loading…</p>
}
else if (_driverGroups.Count == 0)
{
<section class="panel notice rise" style="animation-delay:.2s">
No driver instances reporting yet.
</section>
}
else
{
<section class="panel rise" style="animation-delay:.2s">
<div class="panel-head">Driver instances</div>
@foreach (var g in _driverGroups)
{
<div class="mb-3">
<div class="panel-head">Cluster <span class="mono">@g.ClusterId</span></div>
<div class="mb-2">
@if (g.Nodes.Count == 0)
{
<span class="text-muted">—</span>
}
else
{
@foreach (var n in g.Nodes)
{
<span class="chip chip-idle me-1">@n.NodeId (@n.Host:@n.OpcUaPort)</span>
}
}
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Driver</th>
<th>Type</th>
<th>Status</th>
<th>Last read</th>
<th>Errors/5 min</th>
<th>Last error</th>
</tr>
</thead>
<tbody>
@if (g.Drivers.Count == 0)
{
<tr>
<td colspan="6"><span class="text-muted">No drivers.</span></td>
</tr>
}
else
{
@foreach (var d in g.Drivers)
{
<tr>
<td>
@(d.Name ?? d.DriverInstanceId)
@if (d.Name is not null)
{
<span class="mono small">@d.DriverInstanceId</span>
}
</td>
<td>@(d.DriverType ?? "—")</td>
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
<td>@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")</td>
<td class="numeric">@d.ErrorCount5Min</td>
<td><span class="text-muted small">@(d.LastError ?? "—")</span></td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
}
</section>
}
@code {
private const int RefreshIntervalSeconds = 5;
@@ -122,10 +216,23 @@ else
private DateTime? _lastRefreshUtc;
private Timer? _timer;
protected override void OnInitialized()
private IReadOnlyList<HostsClusterGroup>? _driverGroups;
private List<HostsNodeInfo> _nodes = new();
private List<HostsDriverInstanceInfo> _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,