197 lines
7.3 KiB
C#
197 lines
7.3 KiB
C#
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 4–5 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",
|
||
};
|
||
}
|