Merge fix/issue-546-panel: wire DriverStatusPanel into /hosts driver rows (#546)
v2-ci / build (push) Successful in 5m22s
v2-ci / unit-tests (push) Failing after 17m32s

Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
Joseph Doherty
2026-08-06 17:28:48 -04:00
4 changed files with 171 additions and 10 deletions
@@ -11,6 +11,7 @@
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Logging
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hosts
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
@@ -173,20 +174,25 @@ else
<th>Last read</th>
<th>Errors/5 min</th>
<th>Last error</th>
<th></th>
</tr>
</thead>
<tbody>
@if (g.Drivers.Count == 0)
{
<tr>
<td colspan="8"><span class="text-muted">No drivers.</span></td>
<td colspan="9"><span class="text-muted">No drivers.</span></td>
</tr>
}
else
{
@foreach (var d in g.Drivers)
{
<tr>
@* Keyed by instance id so the detail row's DriverStatusPanel is never
diffed onto a DIFFERENT driver: the panel binds DriverInstanceId
once in OnInitializedAsync (store subscription + resilience prime), so
a reused instance would keep reporting the driver it first bound. *@
<tr @key="@($"row-{d.DriverInstanceId}")">
<td>
@(d.Name ?? d.DriverInstanceId)
@if (d.Name is not null)
@@ -258,7 +264,42 @@ else
<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>
<td>
@* Per-row expander for the driver's status panel (Gitea #546). Shown to
every AuthenticatedRead viewer — the panel carries read-only detail
(resilience/breaker state, last error) as well as the operator actions,
and self-gates only the Reconnect/Restart buttons on DriverOperator. *@
<button type="button"
class="btn btn-sm btn-outline-secondary"
aria-expanded="@(IsExpanded(d.DriverInstanceId) ? "true" : "false")"
@onclick="() => ToggleExpanded(d.DriverInstanceId)"
title="Show live status, resilience and driver controls">
@(IsExpanded(d.DriverInstanceId) ? "▾" : "▸") Controls
</button>
</td>
</tr>
@if (IsExpanded(d.DriverInstanceId))
{
@* Full-width detail row beneath its driver. Rendered only while expanded so
the table stays scannable and only the opened panels hold store
subscriptions — collapsing disposes the panel and unsubscribes.
Enabled participates in the key on purpose. The panel decides whether to
subscribe ONCE, in OnInitializedAsync (`if (!Enabled) return;`), and has no
OnParametersSet hook — so a live config edit that flips this row's Enabled
(surfaced by the 5 s LoadConfigAsync refresh) would re-render the panel's
"Disabled — not deployed" markup while its two store subscriptions and its
staleness timer stayed wired underneath. Keying on the flag recycles the
component instead: the old instance is disposed (unsubscribing both stores,
draining both timers) and a fresh one mounts against the new value. *@
<tr @key="@($"detail-{d.DriverInstanceId}-{d.Enabled}")">
<td colspan="9">
<DriverStatusPanel DriverInstanceId="@d.DriverInstanceId"
ClusterId="@g.ClusterId"
Enabled="@d.Enabled" />
</td>
</tr>
}
}
}
</tbody>
@@ -281,6 +322,11 @@ else
private List<HostsNodeInfo> _nodes = new();
private List<HostsDriverInstanceInfo> _instances = new();
// Driver instances whose DriverStatusPanel is expanded (Gitea #546). Held on the component, NOT
// on the row records, so it survives the 5 s timer's RebuildDriverGroups — which replaces every
// HostsDriverRow instance and would otherwise collapse a panel an operator just opened.
private readonly HashSet<string> _expandedDrivers = new(StringComparer.OrdinalIgnoreCase);
protected override async Task OnInitializedAsync()
{
Refresh();
@@ -336,7 +382,7 @@ else
.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))
.Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled))
.ToListAsync();
}
catch (Exception ex)
@@ -351,6 +397,17 @@ else
private void RebuildDriverGroups()
=> _driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances);
// Whether this driver's DriverStatusPanel is currently expanded.
private bool IsExpanded(string driverInstanceId) => _expandedDrivers.Contains(driverInstanceId);
// Open/close one driver's panel. Ids are globally unique, so no cluster qualifier is needed; a
// removed driver simply leaves a stale id in the set, which matches nothing and costs nothing.
private void ToggleExpanded(string driverInstanceId)
{
if (!_expandedDrivers.Remove(driverInstanceId))
_expandedDrivers.Add(driverInstanceId);
}
// 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.
@@ -395,7 +452,9 @@ else
_ => "chip-idle",
};
// Map DriverState string → chip CSS class (mirrors DriverStatusPanel.ChipClass).
// Map DriverState string → chip CSS class (mirrors DriverStatusPanel.ChipClass). Since #546 that
// panel renders inside this page's expanded rows, so the two mappings are visible side by side —
// they must stay in step or one driver shows two different colours for one state.
private static string DriverChipClass(string? state) => state switch
{
"Healthy" => "chip-ok",
@@ -22,7 +22,11 @@ public sealed record HostsNodeInfo(string ClusterId, string NodeId, string Host,
/// <param name="ClusterId">Cluster the instance is configured under.</param>
/// <param name="Name">Authored display name.</param>
/// <param name="DriverType">Driver type string (Modbus, S7, Galaxy, ...).</param>
public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string ClusterId, string Name, string DriverType);
/// <param name="Enabled">Whether the instance is authored enabled — forwarded to the row's
/// <c>DriverStatusPanel</c>, which renders a "Disabled — not deployed" notice and opens no
/// subscription when false.</param>
public sealed record HostsDriverInstanceInfo(
string DriverInstanceId, string ClusterId, string Name, string DriverType, bool Enabled = true);
/// <summary>
/// One row in the <c>/hosts</c> "Driver Instances" table: a live health snapshot enriched with
@@ -48,12 +52,17 @@ public sealed record HostsDriverInstanceInfo(string DriverInstanceId, string Clu
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
/// count, last error — reads perfectly healthy (Gitea #541).</param>
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
/// so telling the operator it is "not deployed" would contradict the evidence in front of them.</param>
public sealed record HostsDriverRow(
string DriverInstanceId, string? Name, string? DriverType, string State,
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
SkippedTagReport? SkippedTags = null)
SkippedTagReport? SkippedTags = null,
bool Enabled = true)
{
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
/// column exists to make visible.</summary>
@@ -114,7 +123,7 @@ public static class HostsDriverView
/// seen across <paramref name="snapshots"/> and <paramref name="nodes"/>, ordered
/// case-insensitively. Each group carries its nodes (ordered by node id) and its driver rows
/// (snapshots enriched by joining <paramref name="instances"/> on driver instance id;
/// unmatched ids yield null name/type), ordered by name-then-instance-id.
/// unmatched ids yield null name/type and an enabled row), ordered by name-then-instance-id.
/// </summary>
/// <param name="snapshots">Live health snapshots; null treated as empty.</param>
/// <param name="nodes">Configured cluster nodes; null treated as empty.</param>
@@ -167,7 +176,8 @@ public static class HostsDriverView
s.RediscoveryNeededUtc,
s.RediscoveryReason,
s.HostStatuses,
s.SkippedTags);
s.SkippedTags,
instance?.Enabled ?? true);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)