diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor new file mode 100644 index 0000000..55a5f58 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor @@ -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 . *@ + +@if (IsOpen) +{ + + +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs new file mode 100644 index 0000000..8fdd70c --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs @@ -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; + +/// +/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature, +/// Task 3). Opened from an execution-tree node double-click: given an +/// it loads that execution's audit rows via +/// and shows a list → per-row detail. +/// +/// +/// Chrome. A hand-rolled Bootstrap modal — visibility is pure Blazor +/// state () plus the d-block/show CSS classes +/// and a sibling modal-backdrop, mirroring how +/// hand-rolls its offcanvas. No +/// bootstrap.bundle.js modal API is used. +/// +/// +/// +/// Load timing. The modal queries only on the closed → open transition +/// (detected in ), never on every parameter +/// change, so re-renders while open do not re-hit the service. +/// +/// +/// +/// States. 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 ExecutionTreePage.LoadChainAsync). +/// The per-row detail body is delegated to the shared . +/// +/// +public partial class ExecutionDetailModal +{ + [Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!; + + /// + /// 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 . + /// + [Parameter] public Guid? ExecutionId { get; set; } + + /// + /// True when the host wants the modal visible. The closed → open transition + /// triggers the row load; see . + /// + [Parameter] public bool IsOpen { get; set; } + + /// + /// Fired when the user dismisses the modal (header X, backdrop click, or + /// footer Close). The host is expected to flip to false. + /// + [Parameter] public EventCallback OnClose { get; set; } + + // The loaded rows for the current execution; empty until a load completes. + private IReadOnlyList _rows = Array.Empty(); + + // 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; + + /// + /// Page size for the execution-row query. One execution's audit rows are + /// few (cached calls top out around 4–5 rows); 100 comfortably covers a + /// whole execution without paging. + /// + 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; + } + + /// + /// 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. + /// + private async Task LoadRowsAsync() + { + _loading = true; + _error = null; + _selectedRow = null; + _rows = Array.Empty(); + + 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(); + _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(); + } + } + + /// First 8 hex digits of the execution id, mirroring the UI's short-id convention. + 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); + + /// + /// 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. + /// + 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", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css new file mode 100644 index 0000000..6879eaa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css @@ -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; +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs new file mode 100644 index 0000000..e783d5e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs @@ -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; + +/// +/// bUnit tests for (Execution-Tree Node Detail +/// Modal, Task 3). The modal opens on an execution-tree node double-click: given +/// an ExecutionId it loads that execution's audit rows via +/// 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. +/// +public class ExecutionDetailModalTests : BunitContext +{ + private readonly IAuditLogQueryService _service; + private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); + + public ExecutionDetailModalTests() + { + _service = Substitute.For(); + _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 rows) + { + _service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(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(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(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(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(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(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(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()); + + var cut = Render(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(), Arg.Any(), Arg.Any()) + .Returns>>(_ => 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(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(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(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(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); + } +}