feat(centralui): DV-5 — Debug View tabbed composition trees (Attributes/Alarms)

Replace the two flat capped tables with a Bootstrap nav-tabs layout, each
tab hosting a TreeView<DebugTreeNode> built from the live latest-per-name
dictionaries via DebugTreeBuilder. Drop the MaxRows cap, auto-scroll locks,
and Clear buttons (change-feed affordances that don't fit a current-status
tree); HandleStreamEvent now does a plain dictionary upsert. Per-tab filters
ExpandAll on change so matches stay visible. Branch nodes surface roll-up
badges (active-count for alarms, bad-quality for attributes); native binding
nodes show active-count or 'no active conditions'. All existing badge helpers
and ValueFormatter reused. Marshalling/dispose/reconnect contract preserved
(SafeInvokeAsync/_disposed/Dispose unchanged; FilteredAttributeValues kept as
the render-thread dict reader the CentralUI-021 race test exercises).

Rework DebugViewAlarmTableTests for the tabbed-tree DOM: tab presence+default,
computed alarm grouped under its Motor1 branch with the active roll-up badge,
and a native condition nested under its source-binding node with the enriched
kind/severity/Unacked/Shelved badge set.
This commit is contained in:
Joseph Doherty
2026-06-17 15:23:49 -04:00
parent 59f135a4cf
commit 50ce26f2e6
2 changed files with 318 additions and 228 deletions
@@ -109,159 +109,163 @@
@if (_connected && _snapshot != null)
{
<div class="row">
@* Attribute Values *@
<div class="col-md-7">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Attribute Values</strong>
<small class="text-muted">@FilteredAttributeValues.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" aria-label="Filter attributes" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _attrScrollLocked = !_attrScrollLocked"
aria-pressed="@(_attrScrollLocked ? "true" : "false")"
aria-label="@(_attrScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_attrScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAttributes" aria-label="Clear attribute table">Clear</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Attribute</th>
<th>Value</th>
<th>Quality</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var av in FilteredAttributeValues)
{
<tr>
<td class="small">@av.AttributeName</td>
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
<td>
<span class="badge @GetQualityBadge(av.Quality)"
aria-label="@($"Quality: {av.Quality}")">@av.Quality</span>
</td>
<td class="small text-muted"
title="@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
@* Tabbed layout — Attributes / Alarms, each a composition tree. *@
<div class="card-header py-0 px-0">
<ul class="nav nav-tabs card-header-tabs mx-2 mt-2" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" role="tab"
class="nav-link @(_activeTab == "attributes" ? "active" : "")"
data-test="debug-tab-attributes"
aria-selected="@(_activeTab == "attributes" ? "true" : "false")"
@onclick='() => _activeTab = "attributes"'>
Attributes
</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" role="tab"
class="nav-link @(_activeTab == "alarms" ? "active" : "")"
data-test="debug-tab-alarms"
aria-selected="@(_activeTab == "alarms" ? "true" : "false")"
@onclick='() => _activeTab = "alarms"'>
Alarms
</button>
</li>
</ul>
</div>
@* Alarm States *@
<div class="col-md-5">
<div class="card">
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<strong>Alarm States</strong>
<small class="text-muted">@FilteredAlarmStates.Count latest (cap @MaxRows)</small>
</div>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 240px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" aria-label="Filter alarms" />
<button class="btn btn-link btn-sm py-0" type="button"
@onclick="() => _alarmScrollLocked = !_alarmScrollLocked"
aria-pressed="@(_alarmScrollLocked ? "true" : "false")"
aria-label="@(_alarmScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
@(_alarmScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
</button>
<button class="btn btn-outline-secondary btn-sm" type="button"
@onclick="ClearAlarms" aria-label="Clear alarm table">Clear</button>
</div>
<div class="card-body">
@* ── Attributes tab ── *@
<div class="@(_activeTab == "attributes" ? "" : "d-none")"
data-test="debug-pane-attributes" role="tabpanel">
<div class="mb-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 280px;"
placeholder="Filter by attribute…"
@bind="_attrFilter" @bind:event="oninput" @bind:after="OnAttrFilterChanged"
aria-label="Filter attributes" />
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Alarm</th>
<th>Kind</th>
<th>State</th>
<th>Sev</th>
<th>Level</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody aria-live="polite" aria-atomic="false">
@foreach (var alarm in FilteredAlarmStates)
<TreeView TItem="DebugTreeNode" @ref="_attrTree"
Items="AttributeForest"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key"
InitiallyExpanded="n => true"
StorageKey="debugview.attrTree">
<NodeContent Context="node">
@if (node.Attribute is not null)
{
@* Leaf — one attribute value. *@
<span class="me-2">@node.Segment</span>
<span class="font-monospace me-2"><strong>@ValueFormatter.FormatDisplayValue(node.Attribute.Value)</strong></span>
<span class="badge @GetQualityBadge(node.Attribute.Quality) me-2"
aria-label="@($"Quality: {node.Attribute.Quality}")">@node.Attribute.Quality</span>
<span class="text-muted small"
title="@node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</span>
}
else
{
@* Branch — composition member. *@
<span class="fw-semibold">@node.Segment</span>
@if (node.HasBadQuality)
{
<tr class="@GetAlarmRowClass(alarm.State)"
title="@BuildAlarmTooltip(alarm)">
<td class="small">
@alarm.AlarmName
@if (!string.IsNullOrEmpty(alarm.Message))
{
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
}
@if (!string.IsNullOrEmpty(alarm.SourceReference))
{
<div class="text-muted font-monospace text-truncate" style="font-size: .7rem; max-width: 180px;"
title="@alarm.SourceReference">@alarm.SourceReference</div>
}
</td>
<td>
<span class="badge @GetKindBadge(alarm.Kind)"
aria-label="@($"Alarm kind: {alarm.Kind}")">@FormatKind(alarm.Kind)</span>
</td>
<td>
<span class="badge @GetAlarmStateBadge(alarm.State)"
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
@if (alarm.Kind != AlarmKind.Computed)
{
@if (alarm.Condition.Active && !alarm.Condition.Acknowledged)
{
<span class="badge bg-warning text-dark ms-1" aria-label="Unacknowledged">Unacked</span>
}
@if (alarm.Condition.Shelve != AlarmShelveState.Unshelved)
{
<span class="badge bg-info text-dark ms-1" title="@alarm.Condition.Shelve"
aria-label="@($"Shelved: {alarm.Condition.Shelve}")">Shelved</span>
}
@if (alarm.Condition.Suppressed)
{
<span class="badge bg-info text-dark ms-1" aria-label="Suppressed">Suppressed</span>
}
}
</td>
<td class="small font-monospace">@alarm.Condition.Severity</td>
<td>
@if (alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
}
else
{
<span class="text-muted small">—</span>
}
</td>
<td class="small text-muted"
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
</td>
</tr>
<span class="badge bg-warning text-dark ms-2"
aria-label="Contains bad-quality attributes"
title="One or more descendant attributes are off-Good quality">⚠ bad quality</span>
}
</tbody>
</table>
}
</NodeContent>
<EmptyContent>
<div class="text-muted small p-2">No attributes.</div>
</EmptyContent>
</TreeView>
</div>
@* ── Alarms tab ── *@
<div class="@(_activeTab == "alarms" ? "" : "d-none")"
data-test="debug-pane-alarms" role="tabpanel">
<div class="mb-2">
<input type="text" class="form-control form-control-sm"
style="max-width: 280px;"
placeholder="Filter by alarm…"
@bind="_alarmFilter" @bind:event="oninput" @bind:after="OnAlarmFilterChanged"
aria-label="Filter alarms" />
</div>
<TreeView TItem="DebugTreeNode" @ref="_alarmTree"
Items="AlarmForest"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.HasChildren"
KeySelector="n => n.Key"
InitiallyExpanded="n => true"
StorageKey="debugview.alarmTree">
<NodeContent Context="node">
@if (node.Alarm is not null)
{
@* Leaf — computed alarm or native condition. *@
<span class="me-2"
title="@BuildAlarmTooltip(node.Alarm)">@node.Segment</span>
@if (!string.IsNullOrEmpty(node.Alarm.Message))
{
<span class="text-info me-1" aria-label="Has operator message">💬</span>
}
<span class="badge @GetAlarmStateBadge(node.Alarm.State) me-1"
aria-label="@($"Alarm state: {node.Alarm.State}")">@node.Alarm.State</span>
<span class="badge @GetKindBadge(node.Alarm.Kind) me-1"
aria-label="@($"Alarm kind: {node.Alarm.Kind}")">@FormatKind(node.Alarm.Kind)</span>
@if (node.Alarm.Kind != AlarmKind.Computed)
{
@if (node.Alarm.Condition.Active && !node.Alarm.Condition.Acknowledged)
{
<span class="badge bg-warning text-dark me-1" aria-label="Unacknowledged">Unacked</span>
}
@if (node.Alarm.Condition.Shelve != AlarmShelveState.Unshelved)
{
<span class="badge bg-info text-dark me-1" title="@node.Alarm.Condition.Shelve"
aria-label="@($"Shelved: {node.Alarm.Condition.Shelve}")">Shelved</span>
}
@if (node.Alarm.Condition.Suppressed)
{
<span class="badge bg-info text-dark me-1" aria-label="Suppressed">Suppressed</span>
}
}
<span class="font-monospace small text-muted me-1"
aria-label="@($"Severity: {node.Alarm.Condition.Severity}")">sev @node.Alarm.Condition.Severity</span>
@if (node.Alarm.Level != AlarmLevel.None)
{
<span class="badge @GetAlarmLevelBadge(node.Alarm.Level) me-1"
aria-label="@($"Alarm level: {node.Alarm.Level}")">@FormatLevel(node.Alarm.Level)</span>
}
}
else if (node.IsNativeBinding)
{
@* Native source binding branch. *@
<span class="fw-semibold me-2">@node.Segment</span>
@if (node.ActiveCount > 0)
{
<span class="badge @GetAlarmStateBadge(node.WorstState)"
aria-label="@($"{node.ActiveCount} active conditions")">@node.ActiveCount active</span>
}
else if (!node.HasChildren)
{
<span class="text-muted small">no active conditions</span>
}
}
else
{
@* Generic composition branch. *@
<span class="fw-semibold me-2">@node.Segment</span>
@if (node.WorstState == AlarmState.Active)
{
<span class="badge bg-danger"
aria-label="@($"{node.ActiveCount} active alarms")">@node.ActiveCount active</span>
}
}
</NodeContent>
<EmptyContent>
<div class="text-muted small p-2">No alarms.</div>
</EmptyContent>
</TreeView>
</div>
</div>
</div>
@@ -274,8 +278,6 @@
</div>
@code {
private const int MaxRows = 200;
[SupplyParameterFromQuery] public int? SiteId { get; set; }
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
@@ -288,18 +290,28 @@
private bool _connecting;
private bool _connectedFromStorage;
// Which tab pane is visible — "attributes" (default) or "alarms".
private string _activeTab = "attributes";
private DebugViewSnapshot? _snapshot;
// Keyed dictionaries hold the latest value per attribute/alarm; insertion order
// is preserved so we can trim the oldest when the count exceeds MaxRows.
// Keyed dictionaries hold the latest value per attribute/alarm — the
// current-status source of truth the composition trees are built from.
private Dictionary<string, AttributeValueChanged> _attributeValues = new();
private Dictionary<string, AlarmStateChanged> _alarmStates = new();
// Filters and scroll-lock state per table.
// Per-tab name-contains filters.
private string _attrFilter = string.Empty;
private string _alarmFilter = string.Empty;
private bool _attrScrollLocked;
private bool _alarmScrollLocked;
private TreeView<DebugTreeNode>? _attrTree;
private TreeView<DebugTreeNode>? _alarmTree;
/// <summary>
/// Current-status enumeration of the live attribute dictionary. Read only on
/// the render thread (CentralUI-021) — see <see cref="HandleStreamEvent"/>.
/// The trees render from <see cref="AttributeForest"/>; this ordered view is
/// the single render-thread reader of the raw dictionary.
/// </summary>
private IReadOnlyList<AttributeValueChanged> FilteredAttributeValues =>
string.IsNullOrWhiteSpace(_attrFilter)
? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList()
@@ -308,14 +320,13 @@
.OrderBy(a => a.AttributeName)
.ToList();
private IReadOnlyList<AlarmStateChanged> FilteredAlarmStates =>
string.IsNullOrWhiteSpace(_alarmFilter)
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
: _alarmStates.Values
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase)
|| a.SourceReference.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
.OrderBy(a => a.AlarmName)
.ToList();
/// <summary>Attribute composition forest, rebuilt from the live latest-per-name dictionary.</summary>
private IReadOnlyList<DebugTreeNode> AttributeForest =>
DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter);
/// <summary>Alarm composition forest, rebuilt from the live latest-per-name dictionary.</summary>
private IReadOnlyList<DebugTreeNode> AlarmForest =>
DebugTreeBuilder.BuildAlarmTree(_alarmStates.Values, _alarmFilter);
private DebugStreamSession? _session;
private ToastNotification _toast = default!;
@@ -422,6 +433,15 @@
// No-op; selection is tracked via _selectedInstanceId binding
}
/// <summary>
/// When the attribute filter changes, expand the tree so any branches holding
/// freshly-matched leaves are visible (the builder already prunes non-matches).
/// </summary>
private void OnAttrFilterChanged() => _attrTree?.ExpandAll();
/// <summary>As <see cref="OnAttrFilterChanged"/>, for the alarm tree.</summary>
private void OnAlarmFilterChanged() => _alarmTree?.ExpandAll();
private async Task Connect()
{
if (_selectedInstanceId == 0 || _selectedSiteId == 0) return;
@@ -517,27 +537,16 @@
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
}
private void ClearAttributes()
{
_attributeValues.Clear();
}
private void ClearAlarms()
{
_alarmStates.Clear();
}
/// <summary>
/// Handles one debug-stream event. The callback is invoked on an Akka/gRPC
/// thread, but <see cref="_attributeValues"/>/<see cref="_alarmStates"/> are
/// <see cref="Dictionary{TKey,TValue}"/> instances also enumerated by the
/// render thread via <see cref="FilteredAttributeValues"/>/
/// <see cref="FilteredAlarmStates"/>. <c>Dictionary</c> is not thread-safe
/// (CentralUI-021): a write racing an enumeration can throw or corrupt the
/// buckets. The mutation (<see cref="UpsertWithCap"/>) is therefore
/// marshalled onto the renderer's dispatcher via <see cref="SafeInvokeAsync"/>
/// so every access to the dictionaries — read and write — happens on the
/// render thread.
/// render thread (the tree forests + <see cref="FilteredAttributeValues"/> are
/// built from them). <c>Dictionary</c> is not thread-safe (CentralUI-021): a
/// write racing an enumeration can throw or corrupt the buckets. The mutation
/// is therefore marshalled onto the renderer's dispatcher via
/// <see cref="SafeInvokeAsync"/> so every access to the dictionaries — read
/// and write — happens on the render thread.
/// </summary>
private void HandleStreamEvent(object evt)
{
@@ -550,10 +559,10 @@
switch (evt)
{
case AttributeValueChanged av:
UpsertWithCap(_attributeValues, av.AttributeName, av);
_attributeValues[av.AttributeName] = av;
break;
case AlarmStateChanged al:
UpsertWithCap(_alarmStates, al.AlarmName, al);
_alarmStates[al.AlarmName] = al;
break;
default:
return;
@@ -562,27 +571,6 @@
});
}
/// <summary>
/// Replace or insert a value keyed by name, then trim the oldest entries
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
/// preserves insertion order, so the first key is always the oldest.
/// <para>
/// Must be called on the render thread only (CentralUI-021) — see
/// <see cref="HandleStreamEvent"/>. The cap-trim loop is in the same
/// critical section as the upsert so the dictionary is never observed
/// over-capacity.
/// </para>
/// </summary>
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
{
map[key] = value;
while (map.Count > MaxRows)
{
var oldest = map.Keys.First();
map.Remove(oldest);
}
}
private static string GetQualityBadge(string quality) => quality switch
{
"Good" => "bg-success",
@@ -598,12 +586,6 @@
_ => "bg-secondary"
};
private static string GetAlarmRowClass(AlarmState state) => state switch
{
AlarmState.Active => "table-danger",
_ => ""
};
/// <summary>
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
/// (HighHigh / LowLow) get the danger class; warning bands get amber.