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:
@@ -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.
|
||||
|
||||
+127
-19
@@ -20,9 +20,13 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>TreeView</c>. 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.
|
||||
/// </summary>
|
||||
public class DebugViewAlarmTableTests : BunitContext
|
||||
{
|
||||
@@ -69,35 +73,139 @@ public class DebugViewAlarmTableTests : BunitContext
|
||||
(Dictionary<string, AlarmStateChanged>)typeof(DebugViewPage)
|
||||
.GetField("_alarmStates", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||
|
||||
private static Dictionary<string, AttributeValueChanged> AttributeValues(DebugViewPage c) =>
|
||||
(Dictionary<string, AttributeValueChanged>)typeof(DebugViewPage)
|
||||
.GetField("_attributeValues", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
|
||||
|
||||
/// <summary>
|
||||
/// Mark the page connected with the given snapshot and seed the latest-per-name
|
||||
/// dictionaries, then re-render. Mirrors what <c>Connect()</c> does from a real
|
||||
/// snapshot without driving the gRPC stream.
|
||||
/// </summary>
|
||||
private static void Connect(
|
||||
IRenderedComponent<DebugViewPage> cut,
|
||||
IReadOnlyList<AttributeValueChanged> attributes,
|
||||
IReadOnlyList<AlarmStateChanged> 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<AttributeValueChanged>(), 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<AttributeValueChanged>(),
|
||||
new List<AlarmStateChanged> { native }, DateTimeOffset.UtcNow));
|
||||
AlarmStates(cut.Instance)["T01.Hi"] = native;
|
||||
});
|
||||
cut.Render();
|
||||
Connect(cut, Array.Empty<AttributeValueChanged>(), 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user