feat(centralui): execution-chain tree view on the Audit Log page

This commit is contained in:
Joseph Doherty
2026-05-21 18:49:13 -04:00
parent 0b5723b777
commit 34a4356625
14 changed files with 1224 additions and 0 deletions

View File

@@ -173,6 +173,14 @@
View parent execution
</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"
data-test="drawer-close-footer"
@onclick="HandleClose">

View File

@@ -309,6 +309,22 @@ public partial class AuditDrilldownDrawer
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>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as

View 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>

View 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>&lt;ul&gt;</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);
}
}

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

View File

@@ -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>

View File

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

View File

@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
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);
}
}

View File

@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
/// dashboard.
/// </remarks>
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);
}