ui(hosts): declutter — per-cluster panels, stacked driver identity, abbreviated Last error, Controls in a dialog

The page ran members, the driver-instances heading, and every cluster group
together inside one panel; driver name and id slammed inline; one long driver
exception blew the table out sideways (Controls scrolled out of reach); and the
inline #546 expander row dumped a full-width panel into the table.

- One panel per cluster group, node chips in the panel head.
- Driver id renders on its own muted mono line under the name.
- Last error abbreviated to 60 chars (DisplayText.Abbreviate — #504 rule),
  full text on the tooltip and in the Controls dialog.
- DriverStatusPanel moved from an inline expander row to a modal (one at a
  time); the dialog re-resolves its row from the current groups each render so
  the 5 s rebuild can't strand it, and the Enabled-in-key recycle discipline
  carries over unchanged. HostsPanelWiringTests re-pinned to the dialog markup.

Live-verified on docker-dev: separated cards, in-viewport table, dialog opens
with live breaker/status data and grouped Reconnect|Restart, closes cleanly.
This commit is contained in:
Joseph Doherty
2026-08-10 17:34:37 -04:00
parent ef55ff3977
commit 34d60c4125
2 changed files with 103 additions and 76 deletions
@@ -130,26 +130,21 @@ else if (_driverGroups.Count == 0)
}
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)
@* One panel per cluster group (was: one panel nesting every group) so the sections read as
separate cards instead of a continuous run of headings. *@
@foreach (var g in _driverGroups)
{
<section class="panel rise mt-3" style="animation-delay:.2s" @key="@($"group-{g.ClusterId}")">
<div class="panel-head">
Driver instances · Cluster <span class="mono">@g.ClusterId</span>
<span class="ms-2">
@foreach (var n in g.Nodes)
{
<span class="text-muted">—</span>
<span class="chip chip-idle me-1">@n.NodeId (@n.Host:@n.OpcUaPort)</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">
</span>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
@@ -175,17 +170,9 @@ else
{
@foreach (var d in g.Drivers)
{
@* 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)
{
<span class="mono small">@d.DriverInstanceId</span>
}
@if (d.RediscoveryNeededUtc is not null)
{
@* The driver reported its remote's tag set may have changed. Advisory
@@ -196,6 +183,11 @@ else
re-browse
</span>
}
@if (d.Name is not null)
{
@* Own line — inline after the name the two identifiers ran together. *@
<div class="mono small text-muted">@d.DriverInstanceId</div>
}
</td>
<td>@(d.DriverType ?? "—")</td>
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
@@ -250,51 +242,74 @@ else
</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>
<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. *@
@* Abbreviated so one long driver exception can't blow the table out
sideways (never slice a DB/driver string in Razor — #504); the full
text rides the tooltip and the Controls dialog. *@
<span class="text-muted small" title="@d.LastError">@(DisplayText.Abbreviate(d.LastError, 60) is { Length: > 0 } err ? err : "—")</span>
</td>
<td>
@* Opens the driver's status panel in a dialog (was an inline full-width
expander row — #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)"
@onclick="() => OpenControls(d.DriverInstanceId)"
title="Show live status, resilience and driver controls">
@(IsExpanded(d.DriverInstanceId) ? "▾" : "▸") Controls
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>
</table>
</div>
</section>
}
@* Controls dialog (#546, reworked from an inline expander row — the full-width panel inside the
table made an already-dense page unreadable). Only one dialog at a time; the id survives the
5 s RebuildDriverGroups because it is held on the component, and the row is re-resolved from
the CURRENT groups each render so the header/Enabled always track live state. A driver that
vanishes from the snapshot (undeployed) closes the dialog by rendering nothing.
Enabled participates in the panel's 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 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:
the old instance is disposed (unsubscribing both stores, draining both timers) and a fresh
one mounts against the new value. Closing the dialog disposes the panel the same way. *@
@if (FindControlsDriver() is var (cg, cd) && cd is not null && cg is not null)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
@(cd.Name ?? cd.DriverInstanceId)
<span class="mono small text-muted ms-2">@cd.DriverInstanceId</span>
<span class="chip chip-idle ms-2">@cg.ClusterId</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseControls"></button>
</div>
<div class="modal-body">
<DriverStatusPanel @key="@($"detail-{cd.DriverInstanceId}-{cd.Enabled}")"
DriverInstanceId="@cd.DriverInstanceId"
ClusterId="@cg.ClusterId"
Enabled="@cd.Enabled" />
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseControls">Close</button>
</div>
</div>
</div>
}
</section>
</div>
}
}
@code {
@@ -309,10 +324,10 @@ 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);
// The driver instance whose Controls dialog is open (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 close a dialog an operator just opened.
private string? _controlsDriverId;
protected override async Task OnInitializedAsync()
{
@@ -384,15 +399,24 @@ 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 the Controls dialog. Ids are globally unique, so no cluster qualifier is needed.
private void OpenControls(string driverInstanceId) => _controlsDriverId = 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)
private void CloseControls() => _controlsDriverId = null;
// Resolve the open dialog's driver from the CURRENT groups (the row records are replaced on
// every rebuild, so the dialog must never cache one). Null row ⇒ the driver vanished from the
// snapshot ⇒ the dialog renders nothing, i.e. self-closes.
private (HostsClusterGroup? Group, HostsDriverRow? Driver) FindControlsDriver()
{
if (!_expandedDrivers.Remove(driverInstanceId))
_expandedDrivers.Add(driverInstanceId);
if (_controlsDriverId is null || _driverGroups is null) return (null, null);
foreach (var g in _driverGroups)
{
var d = g.Drivers.FirstOrDefault(x =>
string.Equals(x.DriverInstanceId, _controlsDriverId, StringComparison.OrdinalIgnoreCase));
if (d is not null) return (g, d);
}
return (null, null);
}
// Raised by the snapshot store on the bridge actor's thread for every driver instance. Rebuild
@@ -43,17 +43,20 @@ public sealed class HostsPanelWiringTests
}
/// <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.
/// The panel must be fed the dialog driver's real identity and enabled-state, not literals.
/// Hard-coding any of the three would make every dialog drive the same driver, or claim a
/// disabled instance is live — both of which a source scan for the tag alone would pass.
/// Since the Controls dialog rework the panel renders once, inside the modal, bound to the
/// row that <c>FindControlsDriver()</c> re-resolves from the current groups (<c>cd</c>/<c>cg</c>)
/// — never to a cached row record, which the 5 s rebuild replaces.
/// </summary>
[Fact]
public void The_panel_is_bound_to_the_rows_own_driver_cluster_and_enabled_state()
public void The_panel_is_bound_to_the_dialogs_own_driver_cluster_and_enabled_state()
{
var source = ReadHostsPageSource();
source.ShouldContain("DriverInstanceId=\"@d.DriverInstanceId\"");
source.ShouldContain("ClusterId=\"@g.ClusterId\"");
source.ShouldContain("Enabled=\"@d.Enabled\"");
source.ShouldContain("DriverInstanceId=\"@cd.DriverInstanceId\"");
source.ShouldContain("ClusterId=\"@cg.ClusterId\"");
source.ShouldContain("Enabled=\"@cd.Enabled\"");
}
}