feat(centralui): ExecutionDetailModal — execution rows with per-row detail

This commit is contained in:
Joseph Doherty
2026-05-22 01:39:04 -04:00
parent 603995d43a
commit 386cd0b955
4 changed files with 609 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
@using ScadaLink.Commons.Entities.Audit
@* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it
loads that execution's audit rows and shows a list → per-row detail.
Hand-rolled Bootstrap modal — no bootstrap.bundle.js modal API; visibility
is pure Blazor state (the IsOpen bool) + the d-block/show CSS classes,
mirroring AuditDrilldownDrawer's hand-rolled offcanvas. The per-row detail
body is delegated to the shared <AuditEventDetail>. *@
@if (IsOpen)
{
<div class="modal-backdrop fade show" data-test="execution-detail-backdrop"
@onclick="HandleClose"></div>
<div class="modal fade show d-block execution-detail-modal" tabindex="-1"
data-test="execution-detail-modal" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="text-muted small text-uppercase">Execution</div>
<h5 class="modal-title mb-0 d-flex align-items-baseline gap-2">
<span class="font-monospace">Execution @ShortExecutionId()</span>
@if (!_loading && _error is null)
{
<span class="badge rounded-pill text-bg-secondary fw-normal"
data-test="execution-detail-row-count">
@_rows.Count @(_rows.Count == 1 ? "row" : "rows")
</span>
}
</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="execution-detail-close"
@onclick="HandleClose"></button>
</div>
<div class="modal-body small">
@if (_loading)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-loading">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Loading execution rows…
</div>
}
else if (_error is not null)
{
<div class="alert alert-danger mb-0" role="alert"
data-test="execution-detail-error">
@_error
</div>
}
else if (_rows.Count == 0)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-empty">
This execution emitted no audit rows.
</div>
}
else if (_selectedRow is not null)
{
@* Detail view — shared single-row body. *@
@if (_rows.Count > 1)
{
<button type="button"
class="btn btn-link btn-sm px-0 mb-2 execution-detail-back-link"
data-test="execution-detail-back"
@onclick="BackToList">
&larr; Back to rows
</button>
}
<AuditEventDetail Event="_selectedRow" />
}
else
{
@* List view — one button per audit row. *@
<div class="list-group execution-detail-row-list">
@foreach (var row in _rows)
{
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-3"
data-test="execution-detail-row-@row.EventId"
@onclick="() => SelectRow(row)">
<span class="badge @StatusBadgeClass(row.Status) execution-detail-status">
@row.Status
</span>
<span class="execution-detail-kind fw-semibold">@row.Kind</span>
<span class="text-muted text-truncate flex-grow-1">
@(row.Target ?? "—")
</span>
<span class="text-muted font-monospace small flex-shrink-0">
@FormatTime(row.OccurredAtUtc)
</span>
</button>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm"
data-test="execution-detail-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,178 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature,
/// Task 3). Opened from an execution-tree node double-click: given an
/// <see cref="ExecutionId"/> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// <para>
/// <b>Chrome.</b> A hand-rolled Bootstrap modal — visibility is pure Blazor
/// state (<see cref="IsOpen"/>) plus the <c>d-block</c>/<c>show</c> CSS classes
/// and a sibling <c>modal-backdrop</c>, mirroring how
/// <see cref="AuditDrilldownDrawer"/> hand-rolls its offcanvas. No
/// <c>bootstrap.bundle.js</c> modal API is used.
/// </para>
///
/// <para>
/// <b>Load timing.</b> The modal queries only on the closed → open transition
/// (detected in <see cref="OnParametersSetAsync"/>), never on every parameter
/// change, so re-renders while open do not re-hit the service.
/// </para>
///
/// <para>
/// <b>States.</b> Two-or-more rows → list view (one button per row, click sets
/// the selected row); exactly one row → opens straight to the detail view;
/// zero rows → a friendly empty state. A query failure degrades to an inline
/// error banner — it is never rethrown, so a transient DB outage cannot kill
/// the SignalR circuit (the same posture as <c>ExecutionTreePage.LoadChainAsync</c>).
/// The per-row detail body is delegated to the shared <see cref="AuditEventDetail"/>.
/// </para>
/// </summary>
public partial class ExecutionDetailModal
{
[Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!;
/// <summary>
/// The execution whose audit rows the modal loads. When null an open modal
/// loads nothing and shows the empty state — the host is expected to pair a
/// non-null id with <see cref="IsOpen"/>.
/// </summary>
[Parameter] public Guid? ExecutionId { get; set; }
/// <summary>
/// True when the host wants the modal visible. The closed → open transition
/// triggers the row load; see <see cref="OnParametersSetAsync"/>.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the modal (header X, backdrop click, or
/// footer Close). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private bool _loading;
private string? _error;
// Tracks the previous IsOpen so OnParametersSet can detect the open
// transition and load exactly once per open, not on every parameter change.
private bool _wasOpen;
/// <summary>
/// Page size for the execution-row query. One execution's audit rows are
/// few (cached calls top out around 45 rows); 100 comfortably covers a
/// whole execution without paging.
/// </summary>
private const int RowPageSize = 100;
protected override async Task OnParametersSetAsync()
{
// Load only on the closed → open transition. A re-render while already
// open (or while closed) must not re-hit the service.
if (IsOpen && !_wasOpen)
{
await LoadRowsAsync();
}
_wasOpen = IsOpen;
}
/// <summary>
/// Loads the current execution's audit rows. On success, a single-row
/// result opens straight to the detail view; otherwise the list view shows.
/// A query failure degrades to an inline error banner and is never
/// rethrown — audit drill-in is best-effort and must not kill the circuit.
/// </summary>
private async Task LoadRowsAsync()
{
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
if (ExecutionId is null)
{
// Nothing to load — fall through to the empty state.
_loading = false;
return;
}
try
{
_rows = await AuditLogQueryService.QueryAsync(
new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
new AuditLogPaging(PageSize: RowPageSize));
// A single-row execution opens straight to its detail — there is
// no list to choose from.
if (_rows.Count == 1)
{
_selectedRow = _rows[0];
}
}
catch (Exception ex)
{
// Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_selectedRow = null;
}
finally
{
_loading = false;
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
/// <summary>First 8 hex digits of the execution id, mirroring the UI's short-id convention.</summary>
private string ShortExecutionId()
{
if (ExecutionId is null)
{
return "—";
}
var n = ExecutionId.Value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTime(DateTime occurredAtUtc)
=> occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
/// <summary>
/// Bootstrap badge class for a row's status — green for the success
/// terminal state, red for failure/discard, amber for in-flight. Mirrors
/// the status-badge colouring used by the Audit Log results grid.
/// </summary>
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "text-bg-success",
AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger",
_ => "text-bg-warning",
};
}

View File

@@ -0,0 +1,40 @@
/* Execution-Tree Node Detail Modal (Task 3).
The modal/backdrop base classes come from Bootstrap; this is hand-rolled
(no bootstrap.bundle.js modal API), so the backdrop needs an explicit
stacking context and the dialog a comfortable max width. The per-row detail
body styles travel with AuditEventDetail.razor.css. */
/* Bootstrap's .modal-backdrop sits below .modal by default; with the hand-
rolled approach we render both as siblings, so pin the dialog above it. */
.execution-detail-modal {
z-index: 1055;
}
/* The audit detail body can carry larger JSON/SQL payloads — a slightly wider
dialog than the Bootstrap default keeps those readable. Clamp to the
viewport so narrow windows still get the close button on screen. */
.execution-detail-modal .modal-dialog {
max-width: min(720px, 95vw);
}
/* Row-list buttons: a calm hover lift and a fixed-width status badge so the
Kind / Target columns align down the list. */
.execution-detail-row-list .list-group-item-action {
cursor: pointer;
}
.execution-detail-status {
flex-shrink: 0;
min-width: 5.5rem;
text-align: center;
}
/* Keep the back-to-list affordance quiet — it is navigation chrome, not a
primary action. */
.execution-detail-back-link {
text-decoration: none;
}
.execution-detail-back-link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,282 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// an <c>ExecutionId</c> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// Tests pin the behaviours the spec cannot lose: load-on-open-transition,
/// the four data states (multi-row list, single-row straight-to-detail,
/// zero-row empty, query-failure error), and that closing raises OnClose.
/// </summary>
public class ExecutionDetailModalTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
public ExecutionDetailModalTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// AuditEventDetail (the per-row detail body) owns a clipboard interop
// call. Loose mode lets that no-op for tests that don't exercise it.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
Guid executionId,
AuditStatus status = AuditStatus.Delivered,
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
string? target = "demo-target")
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
Status = status,
ExecutionId = executionId,
SourceSiteId = "plant-a",
Target = target,
Actor = "tester",
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
};
private void StubRows(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void ClosedModal_RendersNothing_AndDoesNotQuery()
{
StubRows(new[] { MakeEvent(Guid.NewGuid()) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, Guid.NewGuid())
.Add(c => c.IsOpen, false));
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
Assert.Empty(_calls);
}
[Fact]
public void OpenTransition_QueriesByExecutionId_WithPageSize100()
{
var executionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, false));
// Closed on first render — no query yet.
Assert.Empty(_calls);
// Flip open: the modal loads exactly once for the open transition.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
Assert.Equal(executionId, _calls[0].Filter.ExecutionId);
Assert.NotNull(_calls[0].Paging);
Assert.Equal(100, _calls[0].Paging!.PageSize);
}
[Fact]
public void StillOpen_NonOpenParameterChange_DoesNotRequery()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
Assert.Single(_calls);
// A parameter set that does NOT flip IsOpen must not re-query.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
}
[Fact]
public void MultiRow_RendersListView_WithOneButtonPerRow()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// List view: a row button per audit row, keyed by EventId.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]"));
// Not in detail view yet — no shared detail body rendered.
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void MultiRow_ClickRow_ShowsAuditEventDetail()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]").Click();
// The shared AuditEventDetail body is now rendered (its field list).
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// And a Back control to return to the list.
Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void MultiRow_BackControl_ReturnsToList()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]").Click();
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
cut.Find("[data-test=\"execution-detail-back\"]").Click();
// Back in the list view: row buttons present, detail body gone.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void SingleRow_OpensStraightToDetail_NoBackControl()
{
var executionId = Guid.NewGuid();
var only = MakeEvent(executionId);
StubRows(new[] { only });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// Straight to detail — the shared body is rendered without a click.
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// Nothing to go back to: the Back control is hidden for a single row.
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void ZeroRow_ShowsFriendlyEmptyState()
{
var executionId = Guid.NewGuid();
StubRows(Array.Empty<AuditEvent>());
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var empty = cut.Find("[data-test=\"execution-detail-empty\"]");
Assert.Contains("This execution emitted no audit rows.", empty.TextContent);
}
[Fact]
public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow()
{
var executionId = Guid.NewGuid();
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
// Rendering with IsOpen=true must not throw — the modal degrades to an
// inline error banner rather than killing the SignalR circuit.
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var error = cut.Find("[data-test=\"execution-detail-error\"]");
Assert.Contains("db is down", error.TextContent);
}
[Fact]
public void CloseButton_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-close\"]").Click();
Assert.True(closed);
}
[Fact]
public void BackdropClick_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-backdrop\"]").Click();
Assert.True(closed);
}
[Fact]
public void Header_ShowsShortExecutionId_AndRowCount()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var modal = cut.Find("[data-test=\"execution-detail-modal\"]");
// Short id (first 8 hex of the "N" form) appears in the header.
Assert.Contains("abcdef01", modal.TextContent);
// Row count surfaces in the header chrome.
Assert.Contains("3", modal.TextContent);
}
}