Files
scadalink-design/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs

197 lines
7.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
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
{
// No CancellationToken is passed deliberately: this is a bounded,
// small (~100-row) query for one execution, so the IDisposable/CTS
// machinery is not worth it for a modal. The closed → open guard in
// OnParametersSetAsync cleanly re-loads on the next open if needed.
_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>
/// Closes the modal when Escape is pressed, matching the header X, backdrop
/// click, and footer Close affordances. The root <c>.modal</c> div carries
/// <c>tabindex="-1"</c> so it can receive the keydown.
/// </summary>
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
await HandleClose();
}
}
/// <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",
};
}