feat(adminui): #546 wire DriverStatusPanel into /hosts driver rows (collapsed expander)
DriverStatusPanel was orphaned — zero references repo-wide — so the Restart and Reconnect driver commands had NO reachable UI at all, despite working end to end over the new gRPC mesh transport. Nothing failed to build, test or deploy while that was true, which is why it went unnoticed. The panel is now reachable per driver row on /hosts, behind a per-row "Controls" expander that is collapsed by default: the table stays scannable, and only the opened panels hold snapshot-store subscriptions. Expansion state is a HashSet keyed by DriverInstanceId held on the page, so it survives the 5 s refresh that replaces every row record. Both rows are @key'd by instance id so the panel — which binds its DriverInstanceId once in OnInitializedAsync — can never be diffed onto a different driver. The panel's Enabled parameter is fed from real config rather than defaulted: HostsDriverInstanceInfo/HostsDriverRow now carry DriverInstance.Enabled, which LoadConfigAsync already had in hand. A snapshot with no matching configured instance is treated as enabled — the row exists only because live health arrived, so "Disabled — not deployed" would contradict the same row. The expander is visible to every AuthenticatedRead viewer; the panel self-gates only its Reconnect/Restart buttons on DriverOperator. A source-scan pin (HostsPanelWiringTests) asserts /hosts still renders the panel and still binds it to the row's own driver, cluster and enabled state — removing the tag would silently restore the orphaned state. Claude-Session: https://claude.ai/code/session_014WNM4vjoVksyyBraTXSZE1
This commit is contained in:
@@ -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 resolves 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,33 @@ 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. *@
|
||||
<tr @key="@($"detail-{d.DriverInstanceId}")">
|
||||
<td colspan="9">
|
||||
<DriverStatusPanel DriverInstanceId="@d.DriverInstanceId"
|
||||
ClusterId="@g.ClusterId"
|
||||
Enabled="@d.Enabled" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
@@ -281,6 +313,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 +373,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 +388,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 +443,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",
|
||||
|
||||
Reference in New Issue
Block a user