feat(centralui): execution-chain tree view on the Audit Log page
This commit is contained in:
@@ -173,6 +173,14 @@
|
|||||||
View parent execution
|
View parent execution
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (Event.ExecutionId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="view-execution-chain"
|
||||||
|
@onclick="ViewExecutionChain">
|
||||||
|
View execution chain
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<button class="btn btn-primary btn-sm ms-auto"
|
<button class="btn btn-primary btn-sm ms-auto"
|
||||||
data-test="drawer-close-footer"
|
data-test="drawer-close-footer"
|
||||||
@onclick="HandleClose">
|
@onclick="HandleClose">
|
||||||
|
|||||||
@@ -309,6 +309,22 @@ public partial class AuditDrilldownDrawer
|
|||||||
Navigation.NavigateTo(uri);
|
Navigation.NavigateTo(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). Navigates to
|
||||||
|
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
|
||||||
|
/// resolves the whole chain rooted at the topmost ancestor and renders it
|
||||||
|
/// expandably, with this row's execution highlighted. The button is only
|
||||||
|
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
|
||||||
|
/// is total.
|
||||||
|
/// </summary>
|
||||||
|
private void ViewExecutionChain()
|
||||||
|
{
|
||||||
|
if (Event?.ExecutionId is not { } exec) return;
|
||||||
|
var uri = $"/audit/execution-tree?executionId={exec}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a cURL command from an audit event. The URL comes from
|
/// Build a cURL command from an audit event. The URL comes from
|
||||||
/// <c>Target</c>; when the RequestSummary parses as
|
/// <c>Target</c>; when the RequestSummary parses as
|
||||||
|
|||||||
123
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
123
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
@using ScadaLink.Commons.Types.Audit
|
||||||
|
|
||||||
|
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
|
||||||
|
list the repository returns; this component assembles it into a tree (joining
|
||||||
|
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
|
||||||
|
|
||||||
|
Recursion is expressed by the component rendering <ExecutionTree> for each
|
||||||
|
child subtree. To keep that recursion finite even on corrupt/cyclic input,
|
||||||
|
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
|
||||||
|
downward via the PreBuiltRoots parameter — child instances never re-run the
|
||||||
|
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
|
||||||
|
cycle is broken on first revisit. *@
|
||||||
|
|
||||||
|
@if (_rootsToRender.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
|
||||||
|
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
|
||||||
|
@foreach (var subtree in _rootsToRender)
|
||||||
|
{
|
||||||
|
var node = subtree.Node;
|
||||||
|
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
|
||||||
|
var isStub = node.RowCount == 0;
|
||||||
|
<li class="execution-tree-item" @key="node.ExecutionId">
|
||||||
|
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
|
||||||
|
data-test="tree-node-@node.ExecutionId">
|
||||||
|
@if (subtree.Children.Count > 0)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="execution-tree-toggle"
|
||||||
|
data-test="tree-toggle-@node.ExecutionId"
|
||||||
|
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
|
||||||
|
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
|
||||||
|
@onclick="() => ToggleExpand(node.ExecutionId)">
|
||||||
|
<span class="execution-tree-toggle-glyph" aria-hidden="true">
|
||||||
|
@(IsExpanded(node.ExecutionId) ? "−" : "+")
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="execution-tree-body">
|
||||||
|
<div class="execution-tree-headline">
|
||||||
|
<a class="execution-tree-link font-monospace"
|
||||||
|
data-test="tree-node-link-@node.ExecutionId"
|
||||||
|
href="@AuditLogUrl(node.ExecutionId)"
|
||||||
|
title="Open the Audit Log filtered to execution @node.ExecutionId">
|
||||||
|
@ShortId(node.ExecutionId)
|
||||||
|
</a>
|
||||||
|
@if (isCurrent)
|
||||||
|
{
|
||||||
|
<span class="badge text-bg-primary execution-tree-tag"
|
||||||
|
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
|
||||||
|
}
|
||||||
|
@if (isStub)
|
||||||
|
{
|
||||||
|
<span class="badge text-bg-secondary execution-tree-tag"
|
||||||
|
data-test="stub-node-@node.ExecutionId">No audited actions</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="execution-tree-rowcount text-muted small"
|
||||||
|
data-test="tree-rowcount-@node.ExecutionId">
|
||||||
|
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isStub)
|
||||||
|
{
|
||||||
|
<div class="execution-tree-meta text-muted small">
|
||||||
|
Execution with no audited actions — referenced as a parent, but it
|
||||||
|
emitted no audit rows of its own (or its rows have been purged).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="execution-tree-meta small">
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Source</span>
|
||||||
|
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
|
||||||
|
</span>
|
||||||
|
@if (node.Channels.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Channels</span>
|
||||||
|
@string.Join(", ", node.Channels)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (node.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Statuses</span>
|
||||||
|
@string.Join(", ", node.Statuses)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Time span</span>
|
||||||
|
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId))
|
||||||
|
{
|
||||||
|
@* Recurse: each child subtree is already assembled, so the
|
||||||
|
nested instance renders directly from PreBuiltRoots and skips
|
||||||
|
the flat-list assembly entirely. *@
|
||||||
|
<ExecutionTree PreBuiltRoots="subtree.Children"
|
||||||
|
ArrivedFromExecutionId="ArrivedFromExecutionId"
|
||||||
|
Depth="Depth + 1" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
241
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
241
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
137
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
Clean, corporate, internal-tool aesthetic — consistent with the Audit Log
|
||||||
|
grid / drilldown drawer. Bootstrap CSS variables drive every colour so the
|
||||||
|
tree tracks the active theme. No component framework, no JS for layout. */
|
||||||
|
|
||||||
|
.execution-tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested lists indent and carry a vertical guide rule that ties children to
|
||||||
|
their parent — the classic file-tree connector, kept subtle. */
|
||||||
|
.execution-tree--root {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree .execution-tree {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The node card: a flex row of [toggle][body]. */
|
||||||
|
.execution-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The execution the user drilled in from — a left accent rule + tinted
|
||||||
|
background so it stands out without shouting. */
|
||||||
|
.execution-tree-node--current {
|
||||||
|
border-color: var(--bs-primary-border-subtle);
|
||||||
|
background-color: var(--bs-primary-bg-subtle);
|
||||||
|
box-shadow: inset 3px 0 0 0 var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stub node — an execution with no audited actions. Muted + dashed border so
|
||||||
|
it reads as a placeholder rather than a real audited execution. */
|
||||||
|
.execution-tree-node--stub {
|
||||||
|
border-style: dashed;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand / collapse control. A small square that mirrors the table-light
|
||||||
|
header tone used elsewhere on the Audit pages. */
|
||||||
|
.execution-tree-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-top: 0.0625rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle:hover {
|
||||||
|
background-color: var(--bs-secondary-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle--leaf {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle-glyph {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headline row: short id link, tags, row count. */
|
||||||
|
.execution-tree-headline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-rowcount {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta row: source / channels / statuses / time span, pipe-separated visually
|
||||||
|
via spacing rather than literal separators. */
|
||||||
|
.execution-tree-meta {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-meta-item .text-muted {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
@page "/audit/execution-tree"
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||||
|
@using ScadaLink.CentralUI.Components.Audit
|
||||||
|
@using ScadaLink.CentralUI.Services
|
||||||
|
@using ScadaLink.Commons.Types.Audit
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@inject IAuditLogQueryService AuditLogQueryService
|
||||||
|
|
||||||
|
<PageTitle>Execution Chain</PageTitle>
|
||||||
|
|
||||||
|
@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
A drill-in target reached from the Audit Log drawer's "View execution chain"
|
||||||
|
action: /audit/execution-tree?executionId={guid}. The page parses the id,
|
||||||
|
asks the query service for the whole chain (flat ExecutionTreeNode list), and
|
||||||
|
hands it to the recursive ExecutionTree component. There is deliberately NO
|
||||||
|
nav-menu entry — this page is only meaningful in the context of a specific
|
||||||
|
execution, so it is reachable only via drill-in (the Audit nav group keeps
|
||||||
|
just the Audit Log + Configuration Audit Log pages). *@
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<h1 class="h4 mb-1">Execution Chain</h1>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
The full chain of script / inbound-request executions linked by
|
||||||
|
<span class="font-monospace">ParentExecutionId</span>, rooted at the
|
||||||
|
topmost ancestor. Select an execution to open the Audit Log filtered to
|
||||||
|
its rows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (_executionId is null)
|
||||||
|
{
|
||||||
|
@* No (or unparseable) ?executionId= — render guidance rather than an
|
||||||
|
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
|
||||||
|
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
|
||||||
|
No execution selected. Open this view from an audit row's
|
||||||
|
<strong>View execution chain</strong> action.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_loading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
|
||||||
|
}
|
||||||
|
else if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
|
||||||
|
}
|
||||||
|
else if (_nodes is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="execution-tree-back-to-log"
|
||||||
|
href="@($"/audit/log?executionId={_executionId}")">
|
||||||
|
View this execution in the Audit Log
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary small" data-test="execution-tree-empty">
|
||||||
|
No execution chain found for this id.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
|
||||||
|
/// Log drilldown drawer's "View execution chain" action with
|
||||||
|
/// <c>?executionId={guid}</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// On initialization the page parses <c>?executionId=</c> (lax-parsed, matching
|
||||||
|
/// the Audit Log page's drill-in contract — an absent or unparseable value
|
||||||
|
/// leaves the page in a guidance state and issues NO service call), then asks
|
||||||
|
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
|
||||||
|
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
|
||||||
|
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
|
||||||
|
/// the tree.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
|
||||||
|
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
|
||||||
|
/// directly, so the page can be unit-tested with a substituted service.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class ExecutionTreePage
|
||||||
|
{
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
|
// The parsed ?executionId= value, or null when absent / unparseable.
|
||||||
|
private Guid? _executionId;
|
||||||
|
|
||||||
|
// The flat chain returned by the query service; null until the load
|
||||||
|
// completes (or when no id was supplied).
|
||||||
|
private IReadOnlyList<ExecutionTreeNode>? _nodes;
|
||||||
|
|
||||||
|
private bool _loading;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_executionId = ParseExecutionId();
|
||||||
|
if (_executionId is null)
|
||||||
|
{
|
||||||
|
// No id — render guidance, do not touch the service.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadChainAsync(_executionId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
|
||||||
|
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
|
||||||
|
/// of an error, consistent with the Audit Log page's drill-in handling.
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseExecutionId()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (query.TryGetValue("executionId", out var values)
|
||||||
|
&& Guid.TryParse(values.ToString(), out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadChainAsync(Guid executionId)
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A transient DB outage degrades this page to an error banner
|
||||||
|
// rather than killing the circuit — the same defensive posture the
|
||||||
|
// Audit Log grid takes around its query.
|
||||||
|
_error = $"Could not load the execution chain: {ex.Message}";
|
||||||
|
_nodes = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
|
|
||||||
return repoSnapshot with { BacklogTotal = backlog };
|
return repoSnapshot with { BacklogTotal = backlog };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Test-seam ctor: use the injected repository directly.
|
||||||
|
if (_injectedRepository is not null)
|
||||||
|
{
|
||||||
|
return await _injectedRepository.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: a fresh scope (and thus a fresh DbContext) per call — the
|
||||||
|
// same context-isolation contract QueryAsync upholds, so the tree
|
||||||
|
// page's auto-load never shares the circuit-scoped context.
|
||||||
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
|
|||||||
/// dashboard.
|
/// dashboard.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log ParentExecutionId feature (Task 10) — returns the full
|
||||||
|
/// execution chain containing <paramref name="executionId"/> as a flat list
|
||||||
|
/// of <see cref="ExecutionTreeNode"/>, delegating to
|
||||||
|
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||||
|
/// The execution-chain tree view (<c>/audit/execution-tree</c>) assembles the
|
||||||
|
/// returned flat list into a tree by joining
|
||||||
|
/// <see cref="ExecutionTreeNode.ParentExecutionId"/> to a parent node's
|
||||||
|
/// <see cref="ExecutionTreeNode.ExecutionId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A pure pass-through, mirroring <see cref="QueryAsync"/> — the production
|
||||||
|
/// implementation opens its own DI scope per call so the tree page's
|
||||||
|
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>.
|
||||||
|
/// </remarks>
|
||||||
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,6 +429,88 @@ public class AuditLogPageTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
|
||||||
|
{
|
||||||
|
// Audit Log ParentExecutionId feature, Task 10: the drawer's "View
|
||||||
|
// execution chain" action opens /audit/execution-tree?executionId={id}.
|
||||||
|
// We seed a spawner row + a child row, open the child's drawer, click
|
||||||
|
// "View execution chain", assert the tree renders BOTH executions, then
|
||||||
|
// click the spawner node and assert the Audit Log grid filters to it.
|
||||||
|
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var runId = Guid.NewGuid().ToString("N");
|
||||||
|
var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/";
|
||||||
|
var parentExecutionId = Guid.NewGuid();
|
||||||
|
var childExecutionId = Guid.NewGuid();
|
||||||
|
var spawnerEventId = Guid.NewGuid();
|
||||||
|
var childEventId = Guid.NewGuid();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Spawner execution's own row.
|
||||||
|
await AuditDataSeeder.InsertAuditEventAsync(
|
||||||
|
eventId: spawnerEventId,
|
||||||
|
occurredAtUtc: now,
|
||||||
|
channel: "ApiInbound",
|
||||||
|
kind: "InboundRequest",
|
||||||
|
status: "Delivered",
|
||||||
|
target: targetPrefix + "spawner",
|
||||||
|
executionId: parentExecutionId,
|
||||||
|
httpStatus: 200,
|
||||||
|
durationMs: 7);
|
||||||
|
|
||||||
|
// Child (spawned) row — links to the spawner via ParentExecutionId.
|
||||||
|
await AuditDataSeeder.InsertAuditEventAsync(
|
||||||
|
eventId: childEventId,
|
||||||
|
occurredAtUtc: now,
|
||||||
|
channel: "ApiOutbound",
|
||||||
|
kind: "ApiCall",
|
||||||
|
status: "Delivered",
|
||||||
|
target: targetPrefix + "child",
|
||||||
|
executionId: childExecutionId,
|
||||||
|
parentExecutionId: parentExecutionId,
|
||||||
|
httpStatus: 200,
|
||||||
|
durationMs: 13);
|
||||||
|
|
||||||
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||||
|
|
||||||
|
// Open the child row's drawer via its ExecutionId filter.
|
||||||
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}");
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
|
||||||
|
await Assertions.Expect(childRow).ToBeVisibleAsync();
|
||||||
|
await childRow.ClickAsync();
|
||||||
|
|
||||||
|
// "View execution chain" opens the tree view.
|
||||||
|
var viewChain = page.Locator("[data-test='view-execution-chain']");
|
||||||
|
await Assertions.Expect(viewChain).ToBeVisibleAsync();
|
||||||
|
await viewChain.ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
// The tree page rendered both executions as nodes.
|
||||||
|
Assert.Contains($"executionId={childExecutionId}", page.Url);
|
||||||
|
await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync();
|
||||||
|
await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
// Clicking the spawner node's link filters the Audit Log to its rows.
|
||||||
|
await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync();
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
|
Assert.Contains($"executionId={parentExecutionId}", page.Url);
|
||||||
|
await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -303,6 +303,48 @@ public class AuditDrilldownDrawerTests : BunitContext
|
|||||||
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
|
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(executionId: null);
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
|
||||||
|
{
|
||||||
|
// The "View execution chain" action opens the tree view rooted at the
|
||||||
|
// chain containing this row's ExecutionId.
|
||||||
|
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||||
|
var ev = MakeEvent(executionId: exec);
|
||||||
|
|
||||||
|
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||||
|
.Add(c => c.Event, ev)
|
||||||
|
.Add(c => c.IsOpen, true));
|
||||||
|
|
||||||
|
cut.Find("[data-test=\"view-execution-chain\"]").Click();
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using Bunit;
|
||||||
|
using ScadaLink.CentralUI.Components.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). The component takes the FLAT
|
||||||
|
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
|
||||||
|
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
|
||||||
|
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
|
||||||
|
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
|
||||||
|
/// presentation, the arrived-from highlight, node-click navigation, and
|
||||||
|
/// cycle-safety (a corrupt flat list must not infinite-loop).
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionTreeTests : BunitContext
|
||||||
|
{
|
||||||
|
private static ExecutionTreeNode Node(
|
||||||
|
Guid executionId,
|
||||||
|
Guid? parentExecutionId,
|
||||||
|
int rowCount = 2,
|
||||||
|
string? site = "plant-a",
|
||||||
|
string? instance = "boiler-3")
|
||||||
|
=> new(
|
||||||
|
executionId,
|
||||||
|
parentExecutionId,
|
||||||
|
rowCount,
|
||||||
|
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||||
|
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
|
||||||
|
rowCount == 0 ? null : site,
|
||||||
|
rowCount == 0 ? null : instance,
|
||||||
|
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleNode_RendersOneTreeNode()
|
||||||
|
{
|
||||||
|
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
var nodes = new List<ExecutionTreeNode> { Node(id, null) };
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, id));
|
||||||
|
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiLevel_AssemblesTree_FromFlatList()
|
||||||
|
{
|
||||||
|
// root → child → grandchild — a deliberately shuffled flat list so the
|
||||||
|
// component must reconstruct parent/child links rather than rely on
|
||||||
|
// input ordering.
|
||||||
|
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
|
||||||
|
var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000");
|
||||||
|
var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000");
|
||||||
|
var nodes = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(grandchild, child),
|
||||||
|
Node(root, null),
|
||||||
|
Node(child, root),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, child));
|
||||||
|
|
||||||
|
// All three executions render as nodes.
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
|
||||||
|
|
||||||
|
// The root must appear before the child, and the child before the
|
||||||
|
// grandchild — recursive depth-first rendering preserves ancestry.
|
||||||
|
var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal);
|
||||||
|
var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal);
|
||||||
|
var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal);
|
||||||
|
Assert.True(rootIdx < childIdx, "root must render before child");
|
||||||
|
Assert.True(childIdx < grandIdx, "child must render before grandchild");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StubNode_RendersStubMarker()
|
||||||
|
{
|
||||||
|
// A stub parent (RowCount = 0) referenced by a real child must still
|
||||||
|
// render, visibly marked as "no audited actions".
|
||||||
|
var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000");
|
||||||
|
var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000");
|
||||||
|
var nodes = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(stubParent, null, rowCount: 0),
|
||||||
|
Node(child, stubParent),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, child));
|
||||||
|
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup);
|
||||||
|
Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup);
|
||||||
|
Assert.Contains("no audited actions", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ArrivedFromNode_IsVisuallyHighlighted()
|
||||||
|
{
|
||||||
|
var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
|
||||||
|
var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111");
|
||||||
|
var nodes = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(root, null),
|
||||||
|
Node(child, root),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, child));
|
||||||
|
|
||||||
|
// The arrived-from node carries the highlight marker; a non-arrived
|
||||||
|
// sibling does not.
|
||||||
|
var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]");
|
||||||
|
Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class"));
|
||||||
|
|
||||||
|
var other = cut.Find($"[data-test=\"tree-node-{root}\"]");
|
||||||
|
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
|
||||||
|
{
|
||||||
|
// Each node's id is a real <a href> deep link — clicking it lands on
|
||||||
|
// the Audit Log filtered to that execution's rows. A genuine anchor
|
||||||
|
// (rather than an @onclick navigate) keeps the link middle-click /
|
||||||
|
// open-in-new-tab friendly, matching the rest of the Audit UI.
|
||||||
|
var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222");
|
||||||
|
var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
|
||||||
|
var nodes = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(root, null),
|
||||||
|
Node(child, root),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, root));
|
||||||
|
|
||||||
|
var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]");
|
||||||
|
Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href"));
|
||||||
|
|
||||||
|
var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]");
|
||||||
|
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyNodeList_RendersNothingWithoutThrowing()
|
||||||
|
{
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, Guid.NewGuid()));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
|
||||||
|
{
|
||||||
|
// Defensive: a corrupt flat list where A→B and B→A must not hang the
|
||||||
|
// renderer. Each execution is rendered at most once.
|
||||||
|
var a = Guid.Parse("a0000000-0000-0000-0000-000000000000");
|
||||||
|
var b = Guid.Parse("b0000000-0000-0000-0000-000000000000");
|
||||||
|
var nodes = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(a, b),
|
||||||
|
Node(b, a),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cut = Render<ExecutionTree>(p => p
|
||||||
|
.Add(c => c.Nodes, nodes)
|
||||||
|
.Add(c => c.ArrivedFromExecutionId, a));
|
||||||
|
|
||||||
|
// Both render exactly once — no runaway recursion.
|
||||||
|
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\""));
|
||||||
|
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountOccurrences(string haystack, string needle)
|
||||||
|
{
|
||||||
|
int count = 0, idx = 0;
|
||||||
|
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
idx += needle.Length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). The page is reached via the "View execution chain"
|
||||||
|
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
|
||||||
|
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
|
||||||
|
/// and hands the flat node list to the <c>ExecutionTree</c> component.
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionTreePageTests : BunitContext
|
||||||
|
{
|
||||||
|
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim> { new("Username", "tester") };
|
||||||
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||||
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
|
||||||
|
{
|
||||||
|
var user = BuildPrincipal(roles);
|
||||||
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||||
|
Services.AddAuthorizationCore();
|
||||||
|
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||||
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
Services.AddSingleton(_queryService);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(query))
|
||||||
|
{
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
nav.NavigateTo($"/audit/execution-tree?{query}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||||
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||||
|
{
|
||||||
|
builder.OpenComponent<ExecutionTreePage>(0);
|
||||||
|
builder.CloseComponent();
|
||||||
|
})));
|
||||||
|
|
||||||
|
return host.FindComponent<ExecutionTreePage>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
|
||||||
|
=> new(
|
||||||
|
id, parent, rowCount,
|
||||||
|
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||||
|
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
|
||||||
|
rowCount == 0 ? null : "plant-a",
|
||||||
|
rowCount == 0 ? null : "boiler-3",
|
||||||
|
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithExecutionId_CallsService_AndRendersTree()
|
||||||
|
{
|
||||||
|
var root = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
var child = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
Node(root, null),
|
||||||
|
Node(child, root),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var cut = RenderPage($"executionId={child}", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() =>
|
||||||
|
{
|
||||||
|
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
|
||||||
|
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
var cut = RenderPage(query: null, "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||||
|
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||||
|
{
|
||||||
|
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||||
|
|
||||||
|
var cut = RenderPage("executionId=not-a-guid", "Admin");
|
||||||
|
|
||||||
|
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||||
|
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
||||||
|
{
|
||||||
|
var attributes = typeof(ExecutionTreePage)
|
||||||
|
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||||
|
.Cast<AuthorizeAttribute>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests
|
|||||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
|
||||||
|
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
|
||||||
|
// QueryAsync's scope-per-call contract on the production path.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||||
|
var expected = new List<ExecutionTreeNode>
|
||||||
|
{
|
||||||
|
new(executionId, null, 3,
|
||||||
|
new[] { "ApiOutbound" }, new[] { "Delivered" },
|
||||||
|
"plant-a", "boiler-3",
|
||||||
|
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
|
||||||
|
};
|
||||||
|
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
|
||||||
|
|
||||||
|
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||||
|
|
||||||
|
var result = await sut.GetExecutionTreeAsync(executionId);
|
||||||
|
|
||||||
|
Assert.Same(expected, result);
|
||||||
|
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
|
||||||
|
{
|
||||||
|
// The production ctor must resolve a fresh repository per call — same
|
||||||
|
// scope-per-query contract QueryAsync upholds, so the page's auto-load
|
||||||
|
// never shares the circuit-scoped DbContext.
|
||||||
|
var resolvedRepos = new List<IAuditLogRepository>();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped<IAuditLogRepository>(_ =>
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
|
||||||
|
resolvedRepos.Add(repo);
|
||||||
|
return repo;
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var provider = services.BuildServiceProvider();
|
||||||
|
var sut = new AuditLogQueryService(
|
||||||
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
EmptyAggregator());
|
||||||
|
|
||||||
|
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||||
|
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.Equal(2, resolvedRepos.Count);
|
||||||
|
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||||
|
}
|
||||||
|
|
||||||
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
||||||
{
|
{
|
||||||
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
||||||
|
|||||||
Reference in New Issue
Block a user