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; /// /// Pure (no Blazor/DI) builder that turns a flat list of streamed attribute (and /// alarm) events into a collapsible composition forest of /// . Path-qualified canonical names are split on /// '.' to derive branch nodes; the terminal segment carries the payload. /// public static class DebugTreeBuilder { private const char Separator = '.'; /// /// Build the attribute composition forest. /// /// Latest attribute value events (one per attribute). /// /// Optional name-contains filter (case-insensitive, matched against /// ). Null/empty/whitespace /// keeps everything; matching leaves carry along their ancestor branches. /// /// The root nodes of the attribute composition forest. /// /// The caller is expected to pass at most one /// per . 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. /// public static IReadOnlyList BuildAttributeTree( IEnumerable 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(); var branchByKey = new Dictionary(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(); } /// /// Build the alarm composition forest from a flat list of . /// /// Latest alarm-state events (computed alarms, native conditions, placeholders). /// /// Optional name-contains filter (case-insensitive). An alarm is kept when the filter /// is a substring of any of , /// , or /// . Null/empty/whitespace /// keeps everything; kept items carry along their ancestor branches and, for native /// conditions, their binding node. /// /// The root nodes of the alarm composition forest. /// /// Two flavours are placed differently: /// /// Computed (): /// is a path-qualified canonical name; the alarm becomes a LEAF at that path, exactly /// like an attribute. /// Native (OPC UA / MxAccess): the condition belongs to a source BINDING /// identified by — itself a /// path-qualified canonical name placed as a branch with /// set. Non-placeholder events become /// condition children (keyed canonical::sourceReference); a placeholder event /// only materialises the (childless) binding node so the page can render /// "no active conditions". /// /// public static IReadOnlyList BuildAlarmTree( IEnumerable 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(); var branchByKey = new Dictionary(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(); } /// True when the filter is a case-insensitive substring of any of the alarm's name fields. 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; /// Place a computed alarm as a leaf at its path-qualified canonical name. private static void AddComputedLeaf( AlarmStateChanged alarm, List roots, Dictionary 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, }); } /// /// 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 /// canonical::sourceReference. /// private static void AddNativeCondition( AlarmStateChanged alarm, List roots, Dictionary 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, }); } /// /// Walk (creating as needed) the branch nodes for the first /// segments, deduping by accumulated prefix, and return the child list at that depth. /// private static List WalkBranches( string[] segments, int count, List roots, Dictionary 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; } /// /// Recursively sort each node's children by /// (ordinal) and roll up alarm state post-order: /// is when any descendant alarm leaf/condition is Active /// (placeholders excluded), and 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.) /// private static void SortAndRollUpAlarms(List 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 }; /// /// Recursively sort each node's children by /// (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. /// private static void SortAndRollUp(List 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); }