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