using System.Globalization; using Microsoft.AspNetCore.Components; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.CentralUI.Components.Audit; /// /// Recursive Blazor tree component for the execution-chain view (Audit Log /// ParentExecutionId feature, Task 10). /// /// /// Flat list → tree. The repository / query service returns the chain as /// a FLAT list (one per distinct execution). The /// root instance ( == 0) assembles it once in /// : it groups by , /// links each node to its parent via , /// and identifies the roots (nodes whose parent is null or not present in the /// list — a purged/ghost parent). Nested instances skip assembly: the parent /// hands each child subtree down pre-built via . /// /// /// /// Cycle safety. The ParentExecutionId graph is acyclic by /// construction, but the UI must not infinite-loop on corrupt data. Assembly /// tracks visited values while /// walking children, so a node is attached to the tree at most once — a cycle /// (A→B, B→A) is broken at the first revisit and every execution still renders /// exactly once. /// /// /// /// Presentation. Each node shows the short execution id (a link to /// /audit/log?executionId={id}), row count, channels/statuses, source /// site/instance, and time span. A stub node ( /// == 0) is marked "No audited actions". The node the user arrived from /// () is highlighted. Nodes with children /// are expandable; all nodes start expanded so the whole chain is visible. /// /// public partial class ExecutionTree { /// /// One assembled subtree: a node plus its already-linked child subtrees. /// Recursive — children are themselves values. /// /// The execution this subtree is rooted at. /// Child subtrees, ordered by first-occurrence time. public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children); /// /// The flat node list to assemble into a tree. Supplied on the ROOT /// instance only ( == 0); nested instances receive /// instead. /// [Parameter] public IReadOnlyList? Nodes { get; set; } /// /// Pre-assembled child subtrees, threaded down from a parent /// so nested instances render without /// re-running the flat-list assembly. Null / unused on the root instance. /// [Parameter] public IReadOnlyList? PreBuiltRoots { get; set; } /// /// The execution the user drilled in from — its node is visually /// highlighted so the user keeps their bearings within the chain. /// [Parameter] public Guid ArrivedFromExecutionId { get; set; } /// /// Nesting depth. 0 on the root instance (which owns flat-list assembly); /// each recursive child increments it. Used purely to pick the assembly /// path and to tag the root <ul> for styling. /// [Parameter] public int Depth { get; set; } // The subtrees this instance renders: assembled from Nodes on the root, // or taken straight from PreBuiltRoots on a nested instance. private IReadOnlyList _rootsToRender = Array.Empty(); // Per-execution expand/collapse state. Absent => expanded (the default): // the whole chain is shown on arrival so the user sees the full picture. private readonly HashSet _collapsed = new(); protected override void OnParametersSet() { // Nested instance: the parent already assembled our subtrees. if (Depth > 0) { _rootsToRender = PreBuiltRoots ?? Array.Empty(); return; } // Root instance: assemble the flat list into a tree. _rootsToRender = BuildForest(Nodes ?? Array.Empty()); } /// /// Assembles the flat list into a forest of /// values. There is normally exactly one root (the /// chain's topmost ancestor); the method returns a list to stay total if /// the input ever contains disjoint fragments. /// private static IReadOnlyList BuildForest(IReadOnlyList nodes) { if (nodes.Count == 0) { return Array.Empty(); } // De-dupe defensively: the repository emits one node per execution, but // a corrupt feed could repeat an id. First write wins. var byId = new Dictionary(); foreach (var node in nodes) { byId.TryAdd(node.ExecutionId, node); } // Children grouped by parent id. A node whose parent is null or absent // from the list (a purged/ghost parent) is a root. var childrenByParent = new Dictionary>(); var roots = new List(); foreach (var node in byId.Values) { if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId)) { if (!childrenByParent.TryGetValue(parentId, out var bucket)) { bucket = new List(); childrenByParent[parentId] = bucket; } bucket.Add(node); } else { roots.Add(node); } } // Cycle guard: if the input is fully cyclic (A→B, B→A) every node has a // present parent, so `roots` is empty even though there is data to // show. Fall back to treating the lowest-ordered id as the root so the // chain still renders — the visited-set below then breaks the cycle. if (roots.Count == 0) { roots.Add(byId.Values.OrderBy(n => n.ExecutionId).First()); } var visited = new HashSet(); return roots .OrderBy(SortKey) .Select(root => BuildSubtree(root, childrenByParent, visited)) .ToList(); } /// /// Recursively builds one , tracking /// so a cyclic flat list cannot drive unbounded /// recursion — a node already attached is never descended into again. /// private static Subtree BuildSubtree( ExecutionTreeNode node, IReadOnlyDictionary> childrenByParent, HashSet visited) { visited.Add(node.ExecutionId); var children = new List(); if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren)) { foreach (var child in directChildren.OrderBy(SortKey)) { // Cycle / DAG guard: skip any execution already placed in the // tree so each renders exactly once and recursion terminates. if (visited.Contains(child.ExecutionId)) { continue; } children.Add(BuildSubtree(child, childrenByParent, visited)); } } return new Subtree(node, children); } // Stable child ordering: earliest activity first; stub nodes (null // timestamp) sort last; ExecutionId breaks ties so rendering is // deterministic across requests. private static (DateTime, Guid) SortKey(ExecutionTreeNode node) => (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId); private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId); private void ToggleExpand(Guid executionId) { if (!_collapsed.Remove(executionId)) { _collapsed.Add(executionId); } } /// Audit Log deep link filtered to one execution's rows. private static string AuditLogUrl(Guid executionId) => $"/audit/log?executionId={executionId}"; /// First 8 hex digits — the short-id presentation used across the Audit UI. private static string ShortId(Guid value) { var n = value.ToString("N"); return n.Length >= 8 ? n[..8] : n; } /// /// Renders the [first, last] occurrence span. Both null on a stub node /// (handled by the caller); a single-row execution shows one timestamp. /// private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc) { if (firstUtc is null && lastUtc is null) { return "—"; } var first = firstUtc ?? lastUtc!.Value; var last = lastUtc ?? firstUtc!.Value; var firstText = Iso(first); if (first == last) { return firstText; } return $"{firstText} → {Iso(last)}"; } private static string Iso(DateTime utc) { var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc; return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); } }