242 lines
9.5 KiB
C#
242 lines
9.5 KiB
C#
using System.Globalization;
|
|
using Microsoft.AspNetCore.Components;
|
|
using ScadaLink.Commons.Types.Audit;
|
|
|
|
namespace ScadaLink.CentralUI.Components.Audit;
|
|
|
|
/// <summary>
|
|
/// Recursive Blazor tree component for the execution-chain view (Audit Log
|
|
/// ParentExecutionId feature, Task 10).
|
|
///
|
|
/// <para>
|
|
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
|
|
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
|
|
/// root instance (<see cref="Depth"/> == 0) assembles it once in
|
|
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
|
|
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
|
|
/// 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 <see cref="PreBuiltRoots"/>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
|
|
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
|
|
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Presentation.</b> Each node shows the short execution id (a link to
|
|
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
|
|
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
|
|
/// == 0) is marked "No audited actions". The node the user arrived from
|
|
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
|
|
/// are expandable; all nodes start expanded so the whole chain is visible.
|
|
/// </para>
|
|
/// </summary>
|
|
public partial class ExecutionTree
|
|
{
|
|
/// <summary>
|
|
/// One assembled subtree: a node plus its already-linked child subtrees.
|
|
/// Recursive — children are themselves <see cref="Subtree"/> values.
|
|
/// </summary>
|
|
/// <param name="Node">The execution this subtree is rooted at.</param>
|
|
/// <param name="Children">Child subtrees, ordered by first-occurrence time.</param>
|
|
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
|
|
|
|
/// <summary>
|
|
/// The flat node list to assemble into a tree. Supplied on the ROOT
|
|
/// instance only (<see cref="Depth"/> == 0); nested instances receive
|
|
/// <see cref="PreBuiltRoots"/> instead.
|
|
/// </summary>
|
|
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
|
|
|
|
/// <summary>
|
|
/// Pre-assembled child subtrees, threaded down from a parent
|
|
/// <see cref="ExecutionTree"/> so nested instances render without
|
|
/// re-running the flat-list assembly. Null / unused on the root instance.
|
|
/// </summary>
|
|
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
|
|
|
|
/// <summary>
|
|
/// The execution the user drilled in from — its node is visually
|
|
/// highlighted so the user keeps their bearings within the chain.
|
|
/// </summary>
|
|
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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 <c><ul></c> for styling.
|
|
/// </summary>
|
|
[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<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
|
|
|
// 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<Guid> _collapsed = new();
|
|
|
|
protected override void OnParametersSet()
|
|
{
|
|
// Nested instance: the parent already assembled our subtrees.
|
|
if (Depth > 0)
|
|
{
|
|
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
|
|
return;
|
|
}
|
|
|
|
// Root instance: assemble the flat list into a tree.
|
|
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
|
|
/// <see cref="Subtree"/> 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.
|
|
/// </summary>
|
|
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
|
|
{
|
|
if (nodes.Count == 0)
|
|
{
|
|
return Array.Empty<Subtree>();
|
|
}
|
|
|
|
// 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<Guid, ExecutionTreeNode>();
|
|
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<Guid, List<ExecutionTreeNode>>();
|
|
var roots = new List<ExecutionTreeNode>();
|
|
foreach (var node in byId.Values)
|
|
{
|
|
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
|
|
{
|
|
if (!childrenByParent.TryGetValue(parentId, out var bucket))
|
|
{
|
|
bucket = new List<ExecutionTreeNode>();
|
|
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<Guid>();
|
|
return roots
|
|
.OrderBy(SortKey)
|
|
.Select(root => BuildSubtree(root, childrenByParent, visited))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively builds one <see cref="Subtree"/>, tracking
|
|
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
|
|
/// recursion — a node already attached is never descended into again.
|
|
/// </summary>
|
|
private static Subtree BuildSubtree(
|
|
ExecutionTreeNode node,
|
|
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
|
|
HashSet<Guid> visited)
|
|
{
|
|
visited.Add(node.ExecutionId);
|
|
|
|
var children = new List<Subtree>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
|
|
private static string AuditLogUrl(Guid executionId)
|
|
=> $"/audit/log?executionId={executionId}";
|
|
|
|
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
|
|
private static string ShortId(Guid value)
|
|
{
|
|
var n = value.ToString("N");
|
|
return n.Length >= 8 ? n[..8] : n;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the [first, last] occurrence span. Both null on a stub node
|
|
/// (handled by the caller); a single-row execution shows one timestamp.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|