feat(adminui): /hosts cluster-grouped Driver Instances section (#8)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user