using System.Text.Json; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Components.Audit; /// /// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). /// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc, /// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus, /// ErrorMessage — plus the ExecutionId per-run correlation column and the /// ParentExecutionId spawner-correlation column. Talks to /// /// — never to IAuditLogRepository directly — so tests can stub the data /// source without standing up EF Core. /// /// /// Column model. Each column has a stable string key. The default /// visible order is the parameter (or the spec /// order from Component-AuditLog.md §10 when the parameter is null). On top of /// that default the grid layers a per-browser override: drag-to-reorder and /// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column /// widths to sessionStorage, and the grid restores them on first /// render. A stored order that names an unknown/removed column degrades /// gracefully — unknown keys are dropped, missing columns appended in default /// order — so it never throws. /// /// /// /// Pagination. Each page is a single call to /// IAuditLogQueryService.QueryAsync. The "Next page" button uses the /// LAST row of the current page as the keyset cursor — repository orders by /// (OccurredAtUtc DESC, EventId DESC), so the oldest row in the visible /// page becomes AfterOccurredAtUtc + AfterEventId on the next /// request. The button is disabled when the current page is short (less than /// rows) — that's the conventional "we've reached the /// end" signal for keyset paging without a count query. /// /// /// /// Accessibility. Column resize and reorder are mouse/pointer-only — /// they use a pointer-driven resize handle and native HTML5 drag-and-drop with /// no keyboard equivalent and no ARIA for the reorder. This is a conscious /// scope decision for an internal tool, not an oversight: only the column- /// customisation gesture is mouse-only. The persisted layout itself /// renders as plain HTML, so keyboard and assistive-technology users still get /// a fully readable, navigable grid. /// /// public partial class AuditResultsGrid : IAsyncDisposable { private const int DefaultPageSize = 100; /// Minimum persisted column width — mirrors auditGrid.minWidth. private const int MinColumnWidthPx = 64; /// sessionStorage keys (namespaced under auditGrid: by the JS helper). private const string ColumnOrderStorageKey = "columnOrder"; private const string ColumnWidthsStorageKey = "columnWidths"; private readonly List _rows = new(); private int _pageNumber = 1; private bool _loading; private string? _error; private AuditLogQueryFilter? _activeFilter; [Inject] private IJSRuntime JS { get; set; } = default!; private ElementReference _tableRef; private DotNetObjectReference? _selfRef; // Effective column state. _columnOrder is the live display order (seeded // from the ColumnOrder parameter / spec default, then overridden by any // persisted sessionStorage order). _columnWidths holds per-key pixel // widths from a prior resize; absent keys render at auto width. private List? _columnOrder; private readonly Dictionary _columnWidths = new(); /// /// Filter to apply. When this parameter changes the grid resets to page 1 and /// reissues the query — that's the contract the parent page relies on so the /// filter-bar Apply button does not need to drive grid state manually. /// [Parameter] public AuditLogQueryFilter? Filter { get; set; } /// Page size. Defaults to 100 to match the service-level default. [Parameter] public int PageSize { get; set; } = DefaultPageSize; /// /// Optional column order — list of column keys in display order. When null or /// empty the default order from Component-AuditLog.md §10 is used. The grid /// silently drops unknown keys. /// [Parameter] public IReadOnlyList? ColumnOrder { get; set; } /// /// Raised when the user clicks a row. Bundle C wires this to the drilldown /// drawer. The event payload is the full . /// [Parameter] public EventCallback OnRowSelected { get; set; } // Effective page size used when paging. Mirrors PageSize but bounded > 0. private int _pageSize => Math.Max(1, PageSize); /// /// Default column definitions. The key is the stable identifier (used by /// data-test + the column-order parameter); the label is the user-facing /// header text. Mirrors Component-AuditLog.md §10. /// // Label intentionally equals Key for every column today; the separate Label // field is future-proofing for humanised headers (e.g. "Occurred (UTC)") — // populating it is a deliberate later change, out of scope here. private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] { ("OccurredAtUtc", "OccurredAtUtc"), ("Site", "Site"), ("Channel", "Channel"), ("Kind", "Kind"), ("Status", "Status"), ("Target", "Target"), ("Actor", "Actor"), ("ExecutionId", "ExecutionId"), ("ParentExecutionId", "ParentExecutionId"), ("DurationMs", "DurationMs"), ("HttpStatus", "HttpStatus"), ("ErrorMessage", "ErrorMessage"), }; private IReadOnlyList<(string Key, string Label)> OrderedColumns() => ResolveOrder(_columnOrder ?? ColumnOrder); /// /// Resolves a candidate list of column keys into the concrete display /// columns. Degrades gracefully so a stale persisted order is never fatal: /// unknown keys are dropped, and any column not named in the candidate /// list is appended in its default (spec) position. A null/empty candidate /// yields the full default order. /// private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList? candidate) { if (candidate is null || candidate.Count == 0) { return AllColumns; } var byKey = AllColumns.ToDictionary(c => c.Key, c => c); var ordered = new List<(string Key, string Label)>(AllColumns.Count); var seen = new HashSet(); foreach (var key in candidate) { // Drop unknown keys (removed/renamed columns) and any duplicates. if (byKey.TryGetValue(key, out var col) && seen.Add(key)) { ordered.Add(col); } } // Append any columns the candidate omitted, in default order, so a // newly-added column still appears after a restore of an older order. foreach (var col in AllColumns) { if (seen.Add(col.Key)) { ordered.Add(col); } } return ordered; } /// /// Inline style for a column's cells: emits the --audit-col-width /// custom property the scoped stylesheet reads, or an empty string when /// the column has no persisted width (auto layout). /// private string ColumnWidthStyle(string key) => _columnWidths.TryGetValue(key, out var width) ? $"--audit-col-width: {width}px;" : string.Empty; protected override async Task OnParametersSetAsync() { // Reset & reload whenever the filter reference changes. AuditLogQueryFilter // is a record, so equality-by-value gives us a free "did the user click Apply // with the same chips?" no-op signal. We pin to ReferenceEquals here so the // grid reloads only when the parent hands us a new filter instance — the // page wraps Apply in a fresh allocation, which is the canonical reload signal. if (!ReferenceEquals(_activeFilter, Filter)) { _activeFilter = Filter; _pageNumber = 1; _rows.Clear(); if (Filter is not null) { await LoadAsync(paging: null); } } } private async Task NextPage() { if (_rows.Count == 0 || _activeFilter is null) { return; } var last = _rows[^1]; var cursor = new AuditLogPaging( PageSize: _pageSize, AfterOccurredAtUtc: last.OccurredAtUtc, AfterEventId: last.EventId); await LoadAsync(cursor); _pageNumber++; } private async Task LoadAsync(AuditLogPaging? paging) { if (_activeFilter is null) { return; } _loading = true; _error = null; try { var effective = paging ?? new AuditLogPaging(_pageSize); var page = await QueryService.QueryAsync(_activeFilter, effective); _rows.Clear(); _rows.AddRange(page); } catch (Exception ex) { // Surface the error in-place; the grid stays alive so the user can // adjust the filter and retry without a page refresh. _error = $"Query failed: {ex.Message}"; } finally { _loading = false; } } private async Task HandleRowClick(AuditEvent row) { if (OnRowSelected.HasDelegate) { await OnRowSelected.InvokeAsync(row); } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { // Restore any persisted order + widths first; the StateHasChanged // inside triggers a re-render so the restored layout is on screen. await LoadPersistedStateAsync(); _selfRef = DotNetObjectReference.Create(this); } // Wire (or re-wire) the JS drag handlers on every render. auditGrid.init // is idempotent — already-bound cells are skipped, and the .NET // reference is refreshed — so a re-render after a reorder still leaves // every header cell wired without leaking handlers. // // OnColumnResized/OnColumnReordered both call StateHasChanged(), which // re-runs this method and calls init again. That repeat call is an // intentional cheap no-op: the @key-stable nodes plus the // __auditGridCellBound guard mean init re-scans the header and rebinds // nothing — so there is deliberately no gating logic here. if (_selfRef is not null) { try { await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef); } catch (JSDisconnectedException) { // Circuit gone before init completed — nothing to wire. } } } /// /// Reads the persisted column order + widths from sessionStorage and /// applies them. A missing, empty, or corrupt payload is treated as "no /// prior state" — the grid keeps its default order/widths and never throws. /// private async Task LoadPersistedStateAsync() { var orderJson = await TryLoadAsync(ColumnOrderStorageKey); var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey); var changed = false; if (!string.IsNullOrEmpty(orderJson)) { try { var stored = JsonSerializer.Deserialize>(orderJson); if (stored is { Count: > 0 }) { // Normalise through ResolveOrder so a stale key never sticks. _columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList(); changed = true; } } catch (JsonException) { // Corrupt payload — ignore, keep the default order. } } if (!string.IsNullOrEmpty(widthsJson)) { try { var stored = JsonSerializer.Deserialize>(widthsJson); if (stored is not null) { var validKeys = AllColumns.Select(c => c.Key).ToHashSet(); _columnWidths.Clear(); foreach (var (key, width) in stored) { // Drop widths for unknown columns; clamp to the minimum. if (validKeys.Contains(key)) { _columnWidths[key] = Math.Max(MinColumnWidthPx, width); } } changed = _columnWidths.Count > 0 || changed; } } catch (JsonException) { // Corrupt payload — ignore, keep auto widths. } } if (changed) { StateHasChanged(); } } private async Task TryLoadAsync(string key) { try { return await JS.InvokeAsync("auditGrid.load", key); } catch (JSDisconnectedException) { return null; } } /// /// JS callback: the user finished resizing a column. Persists the new /// per-column width and re-renders so the body cells track the header. /// [JSInvokable] public async Task OnColumnResized(string columnKey, int widthPx) { if (!AllColumns.Any(c => c.Key == columnKey)) { return; } _columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx); await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths)); StateHasChanged(); } /// /// JS callback: the user dropped column onto the /// header of . Moves the dragged column into the /// target's slot, persists the resulting order, and re-renders. /// [JSInvokable] public async Task OnColumnReordered(string fromKey, string toKey) { // Start from the current effective order so successive drags compose. var order = OrderedColumns().Select(c => c.Key).ToList(); var fromIndex = order.IndexOf(fromKey); var toIndex = order.IndexOf(toKey); if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) { return; } order.RemoveAt(fromIndex); // After the removal the target index shifts left by one when the // dragged column originally sat before it. if (fromIndex < toIndex) { toIndex--; } order.Insert(toIndex, fromKey); _columnOrder = order; await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order)); StateHasChanged(); } private async Task SaveAsync(string key, string json) { try { await JS.InvokeVoidAsync("auditGrid.save", key, json); } catch (JSDisconnectedException) { // Circuit gone — the in-memory state still drives this render. } } public ValueTask DisposeAsync() { _selfRef?.Dispose(); return ValueTask.CompletedTask; } private static string StatusBadgeClass(AuditStatus status) => status switch { AuditStatus.Delivered => "badge bg-success", AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger", _ => "badge bg-secondary", }; private static string TruncateError(string? message) { if (string.IsNullOrEmpty(message)) { return "—"; } const int max = 80; return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…"); } }