Merge fix/issue-546-panel: wire DriverStatusPanel into /hosts driver rows (#546)
Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
@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.Hosts
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
|
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers
|
||||||
@@ -173,20 +174,25 @@ else
|
|||||||
<th>Last read</th>
|
<th>Last read</th>
|
||||||
<th>Errors/5 min</th>
|
<th>Errors/5 min</th>
|
||||||
<th>Last error</th>
|
<th>Last error</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (g.Drivers.Count == 0)
|
@if (g.Drivers.Count == 0)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8"><span class="text-muted">No drivers.</span></td>
|
<td colspan="9"><span class="text-muted">No drivers.</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@foreach (var d in g.Drivers)
|
@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>
|
<td>
|
||||||
@(d.Name ?? d.DriverInstanceId)
|
@(d.Name ?? d.DriverInstanceId)
|
||||||
@if (d.Name is not null)
|
@if (d.Name is not null)
|
||||||
@@ -258,7 +264,42 @@ else
|
|||||||
<td>@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")</td>
|
<td>@(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")</td>
|
||||||
<td class="numeric">@d.ErrorCount5Min</td>
|
<td class="numeric">@d.ErrorCount5Min</td>
|
||||||
<td><span class="text-muted small">@(d.LastError ?? "—")</span></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>
|
</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>
|
</tbody>
|
||||||
@@ -281,6 +322,11 @@ else
|
|||||||
private List<HostsNodeInfo> _nodes = new();
|
private List<HostsNodeInfo> _nodes = new();
|
||||||
private List<HostsDriverInstanceInfo> _instances = 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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
Refresh();
|
Refresh();
|
||||||
@@ -336,7 +382,7 @@ else
|
|||||||
.Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort))
|
.Select(n => new HostsNodeInfo(n.ClusterId, n.NodeId, n.Host, n.OpcUaPort))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
_instances = await db.DriverInstances.AsNoTracking()
|
_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();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -351,6 +397,17 @@ else
|
|||||||
private void RebuildDriverGroups()
|
private void RebuildDriverGroups()
|
||||||
=> _driverGroups = HostsDriverView.Build(DriverStore.GetAll(), _nodes, _instances);
|
=> _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
|
// 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
|
// 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.
|
// brand-new driver shows by id until the next config reload (timer/manual Refresh) enriches it.
|
||||||
@@ -395,7 +452,9 @@ else
|
|||||||
_ => "chip-idle",
|
_ => "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
|
private static string DriverChipClass(string? state) => state switch
|
||||||
{
|
{
|
||||||
"Healthy" => "chip-ok",
|
"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="ClusterId">Cluster the instance is configured under.</param>
|
||||||
/// <param name="Name">Authored display name.</param>
|
/// <param name="Name">Authored display name.</param>
|
||||||
/// <param name="DriverType">Driver type string (Modbus, S7, Galaxy, ...).</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>
|
/// <summary>
|
||||||
/// One row in the <c>/hosts</c> "Driver Instances" table: a live health snapshot enriched with
|
/// 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
|
/// 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
|
/// "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>
|
/// 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(
|
public sealed record HostsDriverRow(
|
||||||
string DriverInstanceId, string? Name, string? DriverType, string State,
|
string DriverInstanceId, string? Name, string? DriverType, string State,
|
||||||
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
|
DateTime? LastSuccessfulReadUtc, string? LastError, int ErrorCount5Min, DateTime PublishedUtc,
|
||||||
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
|
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
|
||||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = 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
|
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
|
||||||
/// column exists to make visible.</summary>
|
/// column exists to make visible.</summary>
|
||||||
@@ -114,7 +123,7 @@ public static class HostsDriverView
|
|||||||
/// seen across <paramref name="snapshots"/> and <paramref name="nodes"/>, ordered
|
/// 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
|
/// 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;
|
/// (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>
|
/// </summary>
|
||||||
/// <param name="snapshots">Live health snapshots; null treated as empty.</param>
|
/// <param name="snapshots">Live health snapshots; null treated as empty.</param>
|
||||||
/// <param name="nodes">Configured cluster nodes; 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.RediscoveryNeededUtc,
|
||||||
s.RediscoveryReason,
|
s.RediscoveryReason,
|
||||||
s.HostStatuses,
|
s.HostStatuses,
|
||||||
s.SkippedTags);
|
s.SkippedTags,
|
||||||
|
instance?.Enabled ?? true);
|
||||||
})
|
})
|
||||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ public sealed class HostsDriverViewTests
|
|||||||
private static HostsNodeInfo Node(string cluster, string node, string host = "h", int port = 4840) =>
|
private static HostsNodeInfo Node(string cluster, string node, string host = "h", int port = 4840) =>
|
||||||
new(cluster, node, host, port);
|
new(cluster, node, host, port);
|
||||||
|
|
||||||
private static HostsDriverInstanceInfo Instance(string instance, string cluster, string name, string type) =>
|
private static HostsDriverInstanceInfo Instance(
|
||||||
new(instance, cluster, name, type);
|
string instance, string cluster, string name, string type, bool enabled = true) =>
|
||||||
|
new(instance, cluster, name, type, enabled);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_groups_two_clusters_with_their_nodes_and_snapshots()
|
public void Build_groups_two_clusters_with_their_nodes_and_snapshots()
|
||||||
@@ -214,6 +215,38 @@ public sealed class HostsDriverViewTests
|
|||||||
row.DegradedHosts.ShouldBeEmpty();
|
row.DegradedHosts.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- authored enabled-state (Gitea #546) ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authored enabled flag reaches the row, which forwards it to the row's
|
||||||
|
/// <c>DriverStatusPanel</c>. Defaulting it to true would have the panel open a live
|
||||||
|
/// subscription for an instance that was deliberately taken out of the deployment.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_carries_the_authored_enabled_flag_onto_the_row()
|
||||||
|
{
|
||||||
|
var groups = HostsDriverView.Build(
|
||||||
|
[Snap("MAIN", "drv-a")],
|
||||||
|
nodes: null,
|
||||||
|
[Instance("drv-a", "MAIN", "Pump House Modbus", "Modbus", enabled: false)]);
|
||||||
|
|
||||||
|
groups.Single().Drivers.Single().Enabled.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A snapshot with no matching configured instance is treated as enabled. The row only exists
|
||||||
|
/// because live health arrived for it, so rendering the panel's "Disabled — not deployed"
|
||||||
|
/// notice would contradict the evidence on the same row.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_unmatched_driver_row_is_treated_as_enabled()
|
||||||
|
{
|
||||||
|
var row = HostsDriverView.Build([Snap("MAIN", "orphan")], nodes: null, instances: null)
|
||||||
|
.Single().Drivers.Single();
|
||||||
|
|
||||||
|
row.Enabled.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- skipped tags (Gitea #541) ----
|
// ---- skipped tags (Gitea #541) ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins <c>DriverStatusPanel</c> to the <c>/hosts</c> page (Gitea #546). The panel is the only
|
||||||
|
/// component carrying the Restart/Reconnect driver controls, and it had ZERO references
|
||||||
|
/// repo-wide — the commands worked end to end over the mesh transport while no user could reach
|
||||||
|
/// them, and nothing failed to build, test or deploy. Deleting the last <c><DriverStatusPanel</c>
|
||||||
|
/// tag would silently restore exactly that state, so it is pinned here.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a source scan rather than a rendering assertion because the repo has no bUnit (same
|
||||||
|
/// technique as <c>MeshRegistrationRoleScopingTests</c>; panel markup is otherwise live-verified).
|
||||||
|
/// A scan proves reachability of the component, not that it renders correctly.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HostsPanelWiringTests
|
||||||
|
{
|
||||||
|
private static string ReadHostsPageSource()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||||
|
dir = dir.Parent;
|
||||||
|
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
|
||||||
|
|
||||||
|
return File.ReadAllText(Path.Combine(
|
||||||
|
dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
|
||||||
|
"Components", "Pages", "Hosts.razor"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>THE #546 regression pin: the panel must remain reachable from a routable page.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Hosts_page_renders_the_driver_status_panel()
|
||||||
|
{
|
||||||
|
ReadHostsPageSource().ShouldContain(
|
||||||
|
"<DriverStatusPanel",
|
||||||
|
Case.Sensitive,
|
||||||
|
customMessage:
|
||||||
|
"/hosts no longer renders DriverStatusPanel — the Restart/Reconnect driver controls " +
|
||||||
|
"are orphaned again with no reachable UI, which is Gitea #546");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The panel must be fed the row's real identity and enabled-state, not literals. Hard-coding
|
||||||
|
/// any of the three would make every expanded row drive the same driver, or claim a disabled
|
||||||
|
/// instance is live — both of which a source scan for the tag alone would pass.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_panel_is_bound_to_the_rows_own_driver_cluster_and_enabled_state()
|
||||||
|
{
|
||||||
|
var source = ReadHostsPageSource();
|
||||||
|
|
||||||
|
source.ShouldContain("DriverInstanceId=\"@d.DriverInstanceId\"");
|
||||||
|
source.ShouldContain("ClusterId=\"@g.ClusterId\"");
|
||||||
|
source.ShouldContain("Enabled=\"@d.Enabled\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user