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:
@@ -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\"");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user