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 |
- Value |
- Quality |
- Timestamp |
-
-
-
- @foreach (var av in FilteredAttributeValues)
- {
-
- | @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 *@
-
-
-
@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
}
}