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; /// /// 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 { // 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(); _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(); } } /// /// Closes the modal when Escape is pressed, matching the header X, backdrop /// click, and footer Close affordances. The root .modal div carries /// tabindex="-1" so it can receive the keydown. /// private async Task HandleKeyDown(KeyboardEventArgs e) { if (e.Key == "Escape") { await HandleClose(); } } /// 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", }; }