diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
index a5a5aef..c415d48 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
+++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
@@ -44,7 +44,11 @@ public partial class ExecutionTree
/// Recursive — children are themselves values.
///
/// The execution this subtree is rooted at.
- /// Child subtrees, ordered by first-occurrence time.
+ ///
+ /// Child subtrees, ordered by (FirstOccurredAtUtc ?? DateTime.MaxValue,
+ /// ExecutionId) — earliest first-occurrence time first, stub nodes
+ /// (null timestamp) last, with ExecutionId breaking ties.
+ ///
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children);
///
@@ -78,6 +82,11 @@ public partial class ExecutionTree
// or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList _rootsToRender = Array.Empty();
+ // The Nodes reference the current _rootsToRender was assembled from. Used
+ // to skip a redundant re-assembly when OnParametersSet fires for an
+ // unrelated parameter change (the flat list itself is unchanged).
+ private IReadOnlyList? _assembledFrom;
+
// 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();
@@ -91,15 +100,25 @@ public partial class ExecutionTree
return;
}
- // Root instance: assemble the flat list into a tree.
- _rootsToRender = BuildForest(Nodes ?? Array.Empty());
+ // Root instance: assemble the flat list into a tree. Re-assemble only
+ // when the Nodes reference itself changes — OnParametersSet also fires
+ // for unrelated parameter changes (e.g. ArrivedFromExecutionId), and
+ // re-running assembly then would needlessly rebuild an identical tree.
+ if (!ReferenceEquals(Nodes, _assembledFrom))
+ {
+ _assembledFrom = Nodes;
+ _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.
+ /// the input ever contains disjoint fragments. A fully-cyclic feed has no
+ /// real root, so each remaining cyclic component is seeded with a fallback
+ /// root after the main pass — every execution in
+ /// is therefore placed in the forest exactly once.
///
private static IReadOnlyList BuildForest(IReadOnlyList nodes)
{
@@ -137,20 +156,29 @@ public partial class ExecutionTree
}
}
- // 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
+ var forest = roots
.OrderBy(SortKey)
.Select(root => BuildSubtree(root, childrenByParent, visited))
.ToList();
+
+ // Cycle guard: if the input is fully cyclic every node has a present
+ // parent, so a cyclic component contributes no entry to `roots`. Any
+ // execution still missing from `visited` after the pass above belongs
+ // to such a component (a corrupt feed may contain several independent
+ // cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of
+ // each remaining component as an extra root and assemble it, looping
+ // until every node has been placed — so every execution renders.
+ while (visited.Count < byId.Count)
+ {
+ var fallbackRoot = byId.Values
+ .Where(n => !visited.Contains(n.ExecutionId))
+ .OrderBy(SortKey)
+ .First();
+ forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited));
+ }
+
+ return forest;
}
///
@@ -231,11 +259,8 @@ public partial class ExecutionTree
return $"{firstText} → {Iso(last)}";
}
+ // Audit timestamps are UTC by system convention, so the value is formatted
+ // with a literal 'Z' suffix without re-tagging its DateTimeKind.
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);
- }
+ => utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs
index 5da931e..43b5825 100644
--- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs
@@ -184,6 +184,49 @@ public class ExecutionTreeTests : BunitContext
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
}
+ [Fact]
+ public void ToggleExpand_CollapsesAndReExpandsChildSubtree()
+ {
+ // root → child → grandchild. Clicking the root's toggle collapses its
+ // subtree (the child node disappears); clicking it again re-expands.
+ var root = Guid.Parse("aaaaaaaa-3333-3333-3333-333333333333");
+ var child = Guid.Parse("bbbbbbbb-3333-3333-3333-333333333333");
+ var grandchild = Guid.Parse("cccccccc-3333-3333-3333-333333333333");
+ var nodes = new List
+ {
+ Node(root, null),
+ Node(child, root),
+ Node(grandchild, child),
+ };
+
+ var cut = Render(p => p
+ .Add(c => c.Nodes, nodes)
+ .Add(c => c.ArrivedFromExecutionId, root));
+
+ // All nodes start expanded — the whole chain is visible on arrival.
+ Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
+ Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
+
+ var toggle = cut.Find($"[data-test=\"tree-toggle-{root}\"]");
+ Assert.Equal("true", toggle.GetAttribute("aria-expanded"));
+
+ // Collapse: the child (and its descendants) must disappear.
+ toggle.Click();
+ Assert.DoesNotContain($"data-test=\"tree-node-{child}\"", cut.Markup);
+ Assert.DoesNotContain($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
+ Assert.Equal(
+ "false",
+ cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
+
+ // Re-expand: the child subtree reappears.
+ cut.Find($"[data-test=\"tree-toggle-{root}\"]").Click();
+ Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
+ Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
+ Assert.Equal(
+ "true",
+ cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
+ }
+
private static int CountOccurrences(string haystack, string needle)
{
int count = 0, idx = 0;