feat(adminui): /hosts cluster-grouped Driver Instances section (#8)
This commit is contained in:
@@ -1,13 +1,23 @@
|
|||||||
@page "/hosts"
|
@page "/hosts"
|
||||||
@* Akka cluster topology: each member's NodeId (host:port), roles, leader status. v2 reshapes
|
@* Two views, top to bottom. (1) Akka cluster topology: each member's NodeId (host:port), roles,
|
||||||
v1's "driver host" page — there are no per-driver host rows yet (driver-instance child actors
|
leader status — the cluster-membership view. (2) Driver instances: live driver-health grouped by
|
||||||
land with F7). For now this is the cluster-membership view; expand to per-driver rows when
|
cluster. The health feed (DriverHealthChanged) carries no per-Akka-member identity, so the rows
|
||||||
DriverHostActor starts spawning DriverInstanceActor children. *@
|
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]
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||||
@rendermode RenderMode.InteractiveServer
|
@rendermode RenderMode.InteractiveServer
|
||||||
@using Akka.Actor
|
@using Akka.Actor
|
||||||
@using Akka.Cluster
|
@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 ActorSystem ActorSystem
|
||||||
|
@inject IDriverStatusSnapshotStore DriverStore
|
||||||
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
|
@inject Microsoft.Extensions.Logging.ILogger<Hosts> Logger
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
@@ -114,6 +124,90 @@ else
|
|||||||
</section>
|
</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 {
|
@code {
|
||||||
private const int RefreshIntervalSeconds = 5;
|
private const int RefreshIntervalSeconds = 5;
|
||||||
|
|
||||||
@@ -122,10 +216,23 @@ else
|
|||||||
private DateTime? _lastRefreshUtc;
|
private DateTime? _lastRefreshUtc;
|
||||||
private Timer? _timer;
|
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();
|
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),
|
||||||
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||||
}
|
}
|
||||||
@@ -138,6 +245,8 @@ else
|
|||||||
{
|
{
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
Refresh();
|
Refresh();
|
||||||
|
await LoadConfigAsync();
|
||||||
|
RebuildDriverGroups();
|
||||||
}
|
}
|
||||||
finally
|
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()
|
private void Refresh()
|
||||||
{
|
{
|
||||||
var cluster = Akka.Cluster.Cluster.Get(ActorSystem);
|
var cluster = Akka.Cluster.Cluster.Get(ActorSystem);
|
||||||
@@ -184,7 +326,22 @@ else
|
|||||||
_ => "chip-idle",
|
_ => "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(
|
private sealed record MemberRow(
|
||||||
string Address,
|
string Address,
|
||||||
|
|||||||
Reference in New Issue
Block a user