5a878b78d4
Add missing <summary>/<param>/<returns>/<typeparam> tags and switch interface implementations to <inheritdoc/> across 106 files; strip project bookkeeping identifiers (Task NN, #05-TNN, PLAN-04, StoreAndForward-0NN) from shipped code comments while preserving the descriptive rationale. Comment-only: zero code-logic lines changed; solution builds 0/0. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
285 lines
11 KiB
C#
285 lines
11 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment;
|
|
|
|
/// <summary>
|
|
/// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and
|
|
/// alarm) events into a collapsible composition forest of
|
|
/// <see cref="DebugTreeNode"/>. Path-qualified canonical names are split on
|
|
/// <c>'.'</c> to derive branch nodes; the terminal segment carries the payload.
|
|
/// </summary>
|
|
public static class DebugTreeBuilder
|
|
{
|
|
private const char Separator = '.';
|
|
|
|
/// <summary>
|
|
/// Build the attribute composition forest.
|
|
/// </summary>
|
|
/// <param name="attributes">Latest attribute value events (one per attribute).</param>
|
|
/// <param name="filter">
|
|
/// Optional name-contains filter (case-insensitive, matched against
|
|
/// <see cref="AttributeValueChanged.AttributeName"/>). Null/empty/whitespace
|
|
/// keeps everything; matching leaves carry along their ancestor branches.
|
|
/// </param>
|
|
/// <returns>The root nodes of the attribute composition forest.</returns>
|
|
/// <remarks>
|
|
/// The caller is expected to pass at most one <see cref="AttributeValueChanged"/>
|
|
/// per <see cref="AttributeValueChanged.AttributeName"/>. The DebugView page
|
|
/// enforces this by keying a dictionary on the attribute name before calling this
|
|
/// method. Passing duplicate names would produce sibling leaves with identical
|
|
/// keys under the same parent branch.
|
|
/// </remarks>
|
|
public static IReadOnlyList<DebugTreeNode> BuildAttributeTree(
|
|
IEnumerable<AttributeValueChanged> attributes, string? filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(attributes);
|
|
|
|
var hasFilter = !string.IsNullOrWhiteSpace(filter);
|
|
|
|
// Roots keyed by the first path segment; branch nodes deduped by full prefix.
|
|
var roots = new List<DebugTreeNode>();
|
|
var branchByKey = new Dictionary<string, DebugTreeNode>(StringComparer.Ordinal);
|
|
|
|
foreach (var attr in attributes)
|
|
{
|
|
if (hasFilter &&
|
|
attr.AttributeName.IndexOf(filter!, StringComparison.OrdinalIgnoreCase) < 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var segments = attr.AttributeName.Split(Separator);
|
|
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
|
|
parent.Add(new DebugTreeNode
|
|
{
|
|
Key = attr.AttributeName,
|
|
Segment = segments[^1],
|
|
Attribute = attr,
|
|
});
|
|
}
|
|
|
|
SortAndRollUp(roots);
|
|
return roots.AsReadOnly();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the alarm composition forest from a flat list of <see cref="AlarmStateChanged"/>.
|
|
/// </summary>
|
|
/// <param name="alarms">Latest alarm-state events (computed alarms, native conditions, placeholders).</param>
|
|
/// <param name="filter">
|
|
/// Optional name-contains filter (case-insensitive). An alarm is kept when the filter
|
|
/// is a substring of any of <see cref="AlarmStateChanged.AlarmName"/>,
|
|
/// <see cref="AlarmStateChanged.SourceReference"/>, or
|
|
/// <see cref="AlarmStateChanged.NativeSourceCanonicalName"/>. Null/empty/whitespace
|
|
/// keeps everything; kept items carry along their ancestor branches and, for native
|
|
/// conditions, their binding node.
|
|
/// </param>
|
|
/// <returns>The root nodes of the alarm composition forest.</returns>
|
|
/// <remarks>
|
|
/// Two flavours are placed differently:
|
|
/// <list type="bullet">
|
|
/// <item><b>Computed</b> (<see cref="AlarmKind.Computed"/>): <see cref="AlarmStateChanged.AlarmName"/>
|
|
/// is a path-qualified canonical name; the alarm becomes a LEAF at that path, exactly
|
|
/// like an attribute.</item>
|
|
/// <item><b>Native</b> (OPC UA / MxAccess): the condition belongs to a source BINDING
|
|
/// identified by <see cref="AlarmStateChanged.NativeSourceCanonicalName"/> — itself a
|
|
/// path-qualified canonical name placed as a branch with
|
|
/// <see cref="DebugTreeNode.IsNativeBinding"/> set. Non-placeholder events become
|
|
/// condition children (keyed <c>canonical::sourceReference</c>); a placeholder event
|
|
/// only materialises the (childless) binding node so the page can render
|
|
/// "no active conditions".</item>
|
|
/// </list>
|
|
/// </remarks>
|
|
public static IReadOnlyList<DebugTreeNode> BuildAlarmTree(
|
|
IEnumerable<AlarmStateChanged> alarms, string? filter)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(alarms);
|
|
|
|
var hasFilter = !string.IsNullOrWhiteSpace(filter);
|
|
|
|
// Roots keyed by the first path segment; branch nodes (and native binding
|
|
// nodes) deduped by their full accumulated prefix / canonical name.
|
|
var roots = new List<DebugTreeNode>();
|
|
var branchByKey = new Dictionary<string, DebugTreeNode>(StringComparer.Ordinal);
|
|
|
|
foreach (var alarm in alarms)
|
|
{
|
|
if (hasFilter && !MatchesFilter(alarm, filter!))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (alarm.Kind == AlarmKind.Computed)
|
|
{
|
|
AddComputedLeaf(alarm, roots, branchByKey);
|
|
}
|
|
else
|
|
{
|
|
AddNativeCondition(alarm, roots, branchByKey);
|
|
}
|
|
}
|
|
|
|
SortAndRollUpAlarms(roots);
|
|
return roots.AsReadOnly();
|
|
}
|
|
|
|
/// <summary>True when the filter is a case-insensitive substring of any of the alarm's name fields.</summary>
|
|
private static bool MatchesFilter(AlarmStateChanged alarm, string filter)
|
|
=> alarm.AlarmName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| alarm.SourceReference.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| alarm.NativeSourceCanonicalName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
|
|
|
|
/// <summary>Place a computed alarm as a leaf at its path-qualified canonical name.</summary>
|
|
private static void AddComputedLeaf(
|
|
AlarmStateChanged alarm,
|
|
List<DebugTreeNode> roots,
|
|
Dictionary<string, DebugTreeNode> branchByKey)
|
|
{
|
|
var segments = alarm.AlarmName.Split(Separator);
|
|
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
|
|
|
|
parent.Add(new DebugTreeNode
|
|
{
|
|
Key = alarm.AlarmName,
|
|
Segment = segments[^1],
|
|
Alarm = alarm,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Place a native condition under its source-binding branch node. A placeholder only
|
|
/// materialises the (childless) binding node; a real condition adds a child leaf keyed
|
|
/// <c>canonical::sourceReference</c>.
|
|
/// </summary>
|
|
private static void AddNativeCondition(
|
|
AlarmStateChanged alarm,
|
|
List<DebugTreeNode> roots,
|
|
Dictionary<string, DebugTreeNode> branchByKey)
|
|
{
|
|
if (string.IsNullOrEmpty(alarm.NativeSourceCanonicalName)) return;
|
|
|
|
var canonical = alarm.NativeSourceCanonicalName;
|
|
var segments = canonical.Split(Separator);
|
|
|
|
// Walk/create the ancestor branches above the binding node, then get-or-create
|
|
// the binding node itself (deduped by canonical name, flagged IsNativeBinding).
|
|
var parent = WalkBranches(segments, segments.Length - 1, roots, branchByKey);
|
|
|
|
if (!branchByKey.TryGetValue(canonical, out var binding))
|
|
{
|
|
binding = new DebugTreeNode
|
|
{
|
|
Key = canonical,
|
|
Segment = segments[^1],
|
|
IsNativeBinding = true,
|
|
};
|
|
branchByKey[canonical] = binding;
|
|
parent.Add(binding);
|
|
}
|
|
|
|
// Placeholder: leave the binding node childless ("no active conditions").
|
|
if (alarm.IsConfiguredPlaceholder)
|
|
{
|
|
return;
|
|
}
|
|
|
|
binding.Children.Add(new DebugTreeNode
|
|
{
|
|
Key = canonical + "::" + alarm.SourceReference,
|
|
Segment = alarm.SourceReference,
|
|
Alarm = alarm,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walk (creating as needed) the branch nodes for the first <paramref name="count"/>
|
|
/// segments, deduping by accumulated prefix, and return the child list at that depth.
|
|
/// </summary>
|
|
private static List<DebugTreeNode> WalkBranches(
|
|
string[] segments,
|
|
int count,
|
|
List<DebugTreeNode> roots,
|
|
Dictionary<string, DebugTreeNode> branchByKey)
|
|
{
|
|
var currentLevel = roots;
|
|
var prefix = string.Empty;
|
|
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
prefix = prefix.Length == 0 ? segments[i] : prefix + Separator + segments[i];
|
|
|
|
if (!branchByKey.TryGetValue(prefix, out var branch))
|
|
{
|
|
branch = new DebugTreeNode { Key = prefix, Segment = segments[i] };
|
|
branchByKey[prefix] = branch;
|
|
currentLevel.Add(branch);
|
|
}
|
|
|
|
currentLevel = branch.Children;
|
|
}
|
|
|
|
return currentLevel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
|
|
/// (ordinal) and roll up alarm state post-order: <see cref="DebugTreeNode.WorstState"/>
|
|
/// is <see cref="AlarmState.Active"/> when any descendant alarm leaf/condition is Active
|
|
/// (placeholders excluded), and <see cref="DebugTreeNode.ActiveCount"/> tallies those
|
|
/// active descendants. A native binding node rolls up over its condition children the
|
|
/// same way. (Bad-quality is attribute-only and left default for alarm nodes.)
|
|
/// </summary>
|
|
private static void SortAndRollUpAlarms(List<DebugTreeNode> nodes)
|
|
{
|
|
nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment));
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
// A leaf's own contribution: Active and not a configured placeholder.
|
|
var ownActive = IsActiveAlarm(node);
|
|
var activeCount = ownActive ? 1 : 0;
|
|
|
|
if (node.Children.Count > 0)
|
|
{
|
|
SortAndRollUpAlarms(node.Children);
|
|
activeCount += node.Children.Sum(static c => c.ActiveCount);
|
|
}
|
|
|
|
node.ActiveCount = activeCount;
|
|
node.WorstState = activeCount > 0 ? AlarmState.Active : AlarmState.Normal;
|
|
}
|
|
}
|
|
|
|
private static bool IsActiveAlarm(DebugTreeNode node)
|
|
=> node.Alarm is { State: AlarmState.Active, IsConfiguredPlaceholder: false };
|
|
|
|
/// <summary>
|
|
/// Recursively sort each node's children by <see cref="DebugTreeNode.Segment"/>
|
|
/// (ordinal) and roll up bad-quality: a node has bad quality when its own
|
|
/// attribute is off-Good or any descendant leaf is off-Good. Post-order so a
|
|
/// child's roll-up is settled before its parent reads it.
|
|
/// </summary>
|
|
private static void SortAndRollUp(List<DebugTreeNode> nodes)
|
|
{
|
|
nodes.Sort(static (a, b) => string.CompareOrdinal(a.Segment, b.Segment));
|
|
|
|
foreach (var node in nodes)
|
|
{
|
|
// Leaf's own quality (branches normally have no attribute).
|
|
var bad = node.Attribute is not null && !IsGood(node.Attribute.Quality);
|
|
|
|
if (node.Children.Count > 0)
|
|
{
|
|
SortAndRollUp(node.Children);
|
|
bad = bad || node.Children.Any(static c => c.HasBadQuality);
|
|
}
|
|
|
|
node.HasBadQuality = bad;
|
|
}
|
|
}
|
|
|
|
private static bool IsGood(string quality)
|
|
=> string.Equals(quality, "Good", StringComparison.Ordinal);
|
|
}
|