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);
}
}