From 50ce26f2e6e840a5186ea8526964844314bacdf7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 17 Jun 2026 15:23:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(centralui):=20DV-5=20=E2=80=94=20Debug=20V?= =?UTF-8?q?iew=20tabbed=20composition=20trees=20(Attributes/Alarms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two flat capped tables with a Bootstrap nav-tabs layout, each tab hosting a TreeView 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. --- .../Pages/Deployment/DebugView.razor | 400 +++++++++--------- .../Deployment/DebugViewAlarmTableTests.cs | 146 ++++++- 2 files changed, 318 insertions(+), 228 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor index 0d616cf6..71e7625c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -109,159 +109,163 @@ @if (_connected && _snapshot != null) { -
- @* Attribute Values *@ -
-
-
-
- Attribute Values - @FilteredAttributeValues.Count latest (cap @MaxRows) -
-
- - - -
-
-
- - - - - - - - - - - @foreach (var av in FilteredAttributeValues) - { - - - - - - - } - -
AttributeValueQualityTimestamp
@av.AttributeName@ValueFormatter.FormatDisplayValue(av.Value) - @av.Quality - - @av.Timestamp.LocalDateTime.ToString("HH:mm:ss") -
-
-
+
+ @* Tabbed layout β€” Attributes / Alarms, each a composition tree. *@ +
+
- - @* Alarm States *@ -
-
-
-
- Alarm States - @FilteredAlarmStates.Count latest (cap @MaxRows) -
-
- - - -
+
+ @* ── Attributes tab ── *@ +
+
+
-
- - - - - - - - - - - - - @foreach (var alarm in FilteredAlarmStates) + + + @if (node.Attribute is not null) + { + @* Leaf β€” one attribute value. *@ + @node.Segment + @ValueFormatter.FormatDisplayValue(node.Attribute.Value) + @node.Attribute.Quality + + @node.Attribute.Timestamp.LocalDateTime.ToString("HH:mm:ss") + + } + else + { + @* Branch β€” composition member. *@ + @node.Segment + @if (node.HasBadQuality) { - - - - - - - - + ⚠ bad quality } - -
AlarmKindStateSevLevelTimestamp
- @alarm.AlarmName - @if (!string.IsNullOrEmpty(alarm.Message)) - { - πŸ’¬ - } - @if (!string.IsNullOrEmpty(alarm.SourceReference)) - { -
@alarm.SourceReference
- } -
- @FormatKind(alarm.Kind) - - @alarm.State - @if (alarm.Kind != AlarmKind.Computed) - { - @if (alarm.Condition.Active && !alarm.Condition.Acknowledged) - { - Unacked - } - @if (alarm.Condition.Shelve != AlarmShelveState.Unshelved) - { - Shelved - } - @if (alarm.Condition.Suppressed) - { - Suppressed - } - } - @alarm.Condition.Severity - @if (alarm.Level != AlarmLevel.None) - { - @FormatLevel(alarm.Level) - } - else - { - β€” - } - - @alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss") -
+ } + + +
No attributes.
+
+ +
+ + @* ── Alarms tab ── *@ +
+
+
+ + + @if (node.Alarm is not null) + { + @* Leaf β€” computed alarm or native condition. *@ + @node.Segment + @if (!string.IsNullOrEmpty(node.Alarm.Message)) + { + πŸ’¬ + } + @node.Alarm.State + @FormatKind(node.Alarm.Kind) + @if (node.Alarm.Kind != AlarmKind.Computed) + { + @if (node.Alarm.Condition.Active && !node.Alarm.Condition.Acknowledged) + { + Unacked + } + @if (node.Alarm.Condition.Shelve != AlarmShelveState.Unshelved) + { + Shelved + } + @if (node.Alarm.Condition.Suppressed) + { + Suppressed + } + } + sev @node.Alarm.Condition.Severity + @if (node.Alarm.Level != AlarmLevel.None) + { + @FormatLevel(node.Alarm.Level) + } + } + else if (node.IsNativeBinding) + { + @* Native source binding branch. *@ + @node.Segment + @if (node.ActiveCount > 0) + { + @node.ActiveCount active + } + else if (!node.HasChildren) + { + no active conditions + } + } + else + { + @* Generic composition branch. *@ + @node.Segment + @if (node.WorstState == AlarmState.Active) + { + @node.ActiveCount active + } + } + + +
No alarms.
+
+
@@ -274,8 +278,6 @@
@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 _attributeValues = new(); private Dictionary _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? _attrTree; + private TreeView? _alarmTree; + + /// + /// Current-status enumeration of the live attribute dictionary. Read only on + /// the render thread (CentralUI-021) β€” see . + /// The trees render from ; this ordered view is + /// the single render-thread reader of the raw dictionary. + /// private IReadOnlyList FilteredAttributeValues => string.IsNullOrWhiteSpace(_attrFilter) ? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList() @@ -308,14 +320,13 @@ .OrderBy(a => a.AttributeName) .ToList(); - private IReadOnlyList 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(); + /// Attribute composition forest, rebuilt from the live latest-per-name dictionary. + private IReadOnlyList AttributeForest => + DebugTreeBuilder.BuildAttributeTree(_attributeValues.Values, _attrFilter); + + /// Alarm composition forest, rebuilt from the live latest-per-name dictionary. + private IReadOnlyList 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 } + /// + /// When the attribute filter changes, expand the tree so any branches holding + /// freshly-matched leaves are visible (the builder already prunes non-matches). + /// + private void OnAttrFilterChanged() => _attrTree?.ExpandAll(); + + /// As , for the alarm tree. + 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(); - } - /// /// Handles one debug-stream event. The callback is invoked on an Akka/gRPC /// thread, but / are /// instances also enumerated by the - /// render thread via / - /// . Dictionary 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 - /// so every access to the dictionaries β€” read and write β€” happens on the - /// render thread. + /// render thread (the tree forests + are + /// built from them). Dictionary 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 + /// so every access to the dictionaries β€” read + /// and write β€” happens on the render thread. /// 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 @@ }); } - /// - /// 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. - /// - /// Must be called on the render thread only (CentralUI-021) β€” see - /// . The cap-trim loop is in the same - /// critical section as the upsert so the dictionary is never observed - /// over-capacity. - /// - /// - private static void UpsertWithCap(Dictionary 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", - _ => "" - }; - /// /// Severity-tinted badge class for HiLo alarm levels. The critical bands /// (HighHigh / LowLow) get the danger class; warning bands get amber. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs index 09be8afe..a09b8181 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewAlarmTableTests.cs @@ -20,9 +20,13 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; /// -/// Task 23: the DebugView alarm table surfaces enriched native alarm state β€” -/// kind, severity, source reference, and a composite condition badge set -/// (Unacked / Shelved / Suppressed). +/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab +/// hosting a composition TreeView. Computed alarms and attributes nest as +/// leaves under their composition-member branches; native conditions nest under +/// their source-binding node. Branch nodes surface a roll-up (active-count / +/// bad-quality) badge. These tests assert the tree structure + roll-up, not just +/// that markup exists, and that the enriched native-alarm badge set +/// (kind / severity / Unacked / Shelved) still renders inside the tree leaf. /// public class DebugViewAlarmTableTests : BunitContext { @@ -69,35 +73,139 @@ public class DebugViewAlarmTableTests : BunitContext (Dictionary)typeof(DebugViewPage) .GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!; + private static Dictionary AttributeValues(DebugViewPage c) => + (Dictionary)typeof(DebugViewPage) + .GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!; + + /// + /// Mark the page connected with the given snapshot and seed the latest-per-name + /// dictionaries, then re-render. Mirrors what Connect() does from a real + /// snapshot without driving the gRPC stream. + /// + private static void Connect( + IRenderedComponent cut, + IReadOnlyList attributes, + IReadOnlyList alarms) + { + cut.InvokeAsync(() => + { + SetField(cut.Instance, "_connected", true); + SetField(cut.Instance, "_snapshot", + new DebugViewSnapshot("inst", attributes.ToList(), alarms.ToList(), DateTimeOffset.UtcNow)); + foreach (var a in attributes) AttributeValues(cut.Instance)[a.AttributeName] = a; + foreach (var a in alarms) AlarmStates(cut.Instance)[a.AlarmName] = a; + }); + cut.Render(); + } + [Fact] - public void AlarmTable_RendersNativeAlarm_WithSeverityKindAndShelvedBadge() + public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree() { var cut = RenderPage(); + var speed = new AttributeValueChanged( + "inst", "Motor1.Speed", "Motor1.Speed", 1450, "Good", DateTimeOffset.UtcNow); + var alarm = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow); + + Connect(cut, new[] { speed }, new[] { alarm }); + + // Both tab hooks render. + var attrTab = cut.Find("[data-test='debug-tab-attributes']"); + var alarmTab = cut.Find("[data-test='debug-tab-alarms']"); + Assert.Contains("active", attrTab.GetAttribute("class")!); // Attributes is the default active tab + Assert.DoesNotContain("active", alarmTab.GetAttribute("class")!); + + // The Attributes pane is visible (not d-none); the Alarms pane is hidden. + var attrPane = cut.Find("[data-test='debug-pane-attributes']"); + var alarmPane = cut.Find("[data-test='debug-pane-alarms']"); + Assert.DoesNotContain("d-none", attrPane.GetAttribute("class")!); + Assert.Contains("d-none", alarmPane.GetAttribute("class")!); + + // The attribute tree shows the Motor1 branch and the Speed leaf nested under it. + var attrItems = attrPane.QuerySelectorAll("li[role='treeitem']"); + Assert.Contains(attrItems, li => li.TextContent.Contains("Motor1")); + Assert.Contains(attrItems, li => li.TextContent.Contains("Speed") && li.TextContent.Contains("1450")); + + // The leaf for Speed must be nested inside the Motor1 branch's group, not a root. + var motor1 = attrItems.Single(li => + li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1")); + Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']")); + } + + [Fact] + public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge() + { + var cut = RenderPage(); + + // Two computed alarms under the same Motor1 module β€” one active, one normal. + var high = new AlarmStateChanged("inst", "Motor1.HighTemp", AlarmState.Active, 700, DateTimeOffset.UtcNow); + var ok = new AlarmStateChanged("inst", "Motor1.Overload", AlarmState.Normal, 200, DateTimeOffset.UtcNow); + + Connect(cut, Array.Empty(), new[] { high, ok }); + + // Switch to the Alarms tab. + cut.Find("[data-test='debug-tab-alarms']").Click(); + + var alarmPane = cut.Find("[data-test='debug-pane-alarms']"); + Assert.DoesNotContain("d-none", alarmPane.GetAttribute("class")!); + + var items = alarmPane.QuerySelectorAll("li[role='treeitem']"); + + // Motor1 branch carries the roll-up "1 active" danger badge. + var motor1 = items.Single(li => + li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("Motor1")); + var rollup = motor1.QuerySelector(".tv-content .badge.bg-danger"); + Assert.NotNull(rollup); + Assert.Contains("1 active", rollup!.TextContent); + + // The active alarm leaf nests under Motor1 and shows the Active state badge. + var group = motor1.QuerySelector("ul[role='group']"); + Assert.NotNull(group); + var leaves = group!.QuerySelectorAll("li[role='treeitem']"); + Assert.Contains(leaves, li => li.TextContent.Contains("HighTemp")); + Assert.Contains(leaves, li => li.TextContent.Contains("Overload")); + var highTemp = leaves.Single(li => li.TextContent.Contains("HighTemp")); + Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge + } + + [Fact] + public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode() + { + var cut = RenderPage(); + + // A native OPC UA condition belonging to the Motor1.MotorAlarms binding. var native = new AlarmStateChanged("inst", "T01.Hi", AlarmState.Active, 800, DateTimeOffset.UtcNow) { Kind = AlarmKind.NativeOpcUa, + NativeSourceCanonicalName = "Motor1.MotorAlarms", SourceReference = "ns=2;s=Tank01.Level.HiHi", Condition = new AlarmConditionState( Active: true, Acknowledged: false, Confirmed: null, Shelve: AlarmShelveState.OneShotShelved, Suppressed: false, Severity: 800) }; - cut.InvokeAsync(() => - { - SetField(cut.Instance, "_connected", true); - SetField(cut.Instance, "_snapshot", - new DebugViewSnapshot("inst", new List(), - new List { native }, DateTimeOffset.UtcNow)); - AlarmStates(cut.Instance)["T01.Hi"] = native; - }); - cut.Render(); + Connect(cut, Array.Empty(), new[] { native }); - var markup = cut.Markup; - Assert.Contains("800", markup); // severity surfaced - Assert.Contains("Shelved", markup); // composite condition badge - Assert.Contains("OPC UA", markup); // native kind badge - Assert.Contains("ns=2;s=Tank01.Level.HiHi", markup); // source reference - Assert.Contains("Unacked", markup); // active + unacknowledged + cut.Find("[data-test='debug-tab-alarms']").Click(); + + var alarmPane = cut.Find("[data-test='debug-pane-alarms']"); + var items = alarmPane.QuerySelectorAll("li[role='treeitem']"); + + // The source-binding node renders with its roll-up active badge. + var binding = items.Single(li => + li.QuerySelector(".tv-content")!.TextContent.Trim().StartsWith("MotorAlarms")); + var bindingBadge = binding.QuerySelector(".tv-content .badge"); + Assert.NotNull(bindingBadge); + Assert.Contains("1 active", bindingBadge!.TextContent); + + // The native condition leaf nests under the binding and surfaces the enriched badge set. + var leaf = binding.QuerySelector("ul[role='group'] li[role='treeitem']"); + Assert.NotNull(leaf); + var leafMarkup = leaf!.OuterHtml; + Assert.Contains("800", leafMarkup); // severity surfaced + Assert.Contains("Shelved", leafMarkup); // composite condition badge + Assert.Contains("OPC UA", leafMarkup); // native kind badge + Assert.Contains("Unacked", leafMarkup); // active + unacknowledged + Assert.Contains("ns=2;s=Tank01.Level.HiHi", leaf.TextContent); // source reference as the leaf label } }