From 34d60c412514bce6bc2273ac43cdde39868e825d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 17:34:37 -0400 Subject: [PATCH] =?UTF-8?q?ui(hosts):=20declutter=20=E2=80=94=20per-cluste?= =?UTF-8?q?r=20panels,=20stacked=20driver=20identity,=20abbreviated=20Last?= =?UTF-8?q?=20error,=20Controls=20in=20a=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Components/Pages/Hosts.razor | 162 ++++++++++-------- .../HostsPanelWiringTests.cs | 17 +- 2 files changed, 103 insertions(+), 76 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 3596d416..09f8e3ba 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -130,26 +130,21 @@ else if (_driverGroups.Count == 0) } else { -
-
Driver instances
- @foreach (var g in _driverGroups) - { -
-
Cluster @g.ClusterId
-
- @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) + { +
+
+ Driver instances · Cluster @g.ClusterId + + @foreach (var n in g.Nodes) { - — + @n.NodeId (@n.Host:@n.OpcUaPort) } - else - { - @foreach (var n in g.Nodes) - { - @n.NodeId (@n.Host:@n.OpcUaPort) - } - } -
-
+ +
+
@@ -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. *@ @@ -250,51 +242,74 @@ else - + - @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. *@ - - - - } } }
@(d.Name ?? d.DriverInstanceId) - @if (d.Name is not null) - { - @d.DriverInstanceId - } @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 } + @if (d.Name is not null) + { + @* Own line — inline after the name the two identifiers ran together. *@ +
@d.DriverInstanceId
+ }
@(d.DriverType ?? "—") @d.State @(d.LastSuccessfulReadUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") @d.ErrorCount5Min@(d.LastError ?? "—") - @* 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. *@ + @(DisplayText.Abbreviate(d.LastError, 60) is { Length: > 0 } err ? err : "—") + + @* 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. *@
- -
+
+
+ } + + @* 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) + { + +
+ + } } @code { @@ -309,10 +324,10 @@ else private List _nodes = new(); private List _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 _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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HostsPanelWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HostsPanelWiringTests.cs index ad533fe8..1e9b7ede 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HostsPanelWiringTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/HostsPanelWiringTests.cs @@ -43,17 +43,20 @@ public sealed class HostsPanelWiringTests } /// - /// 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 FindControlsDriver() re-resolves from the current groups (cd/cg) + /// — never to a cached row record, which the 5 s rebuild replaces. /// [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\""); } }