Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid, with chosen widths + column order persisted in browser sessionStorage. - wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven resize handles, native HTML5 drag-and-drop reorder, and a sessionStorage save/load wrapper (mirrors treeview-storage.js). - AuditResultsGrid: renders a resize handle per <th>, makes headers draggable, applies persisted widths via a --audit-col-width custom property, and wires reorder into the existing ColumnOrder / OrderedColumns() mechanism. JS-invokable OnColumnResized / OnColumnReordered persist + re-render. A stored order naming an unknown column degrades gracefully (drops unknown keys, appends missing columns in default order); widths clamp to a 64px minimum. - AuditResultsGrid.razor.css: subtle scoped styling for the resize handle affordance and the reorder drop-target highlight. - App.razor references audit-grid.js alongside the other scripts. - Tests: 6 new bUnit tests for the load/apply/persist logic and graceful degradation; a new AuditGridColumnTests Playwright suite for the drag UX + reload persistence. Audit page bUnit tests set loose JSInterop mode since the grid now calls into audit-grid.js.
424 lines
15 KiB
C#
424 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
|
|
/// Renders the 10 columns named in Component-AuditLog.md §10:
|
|
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs,
|
|
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/>
|
|
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
|
/// source without standing up EF Core.
|
|
///
|
|
/// <para>
|
|
/// <b>Column model.</b> Each column has a stable string key. The default
|
|
/// visible order is the <see cref="ColumnOrder"/> 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 <c>sessionStorage</c>, 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Pagination.</b> Each page is a single call to
|
|
/// <c>IAuditLogQueryService.QueryAsync</c>. The "Next page" button uses the
|
|
/// LAST row of the current page as the keyset cursor — repository orders by
|
|
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>, so the oldest row in the visible
|
|
/// page becomes <c>AfterOccurredAtUtc</c> + <c>AfterEventId</c> on the next
|
|
/// request. The button is disabled when the current page is short (less than
|
|
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
|
|
/// end" signal for keyset paging without a count query.
|
|
/// </para>
|
|
/// </summary>
|
|
public partial class AuditResultsGrid : IAsyncDisposable
|
|
{
|
|
private const int DefaultPageSize = 100;
|
|
|
|
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
|
|
private const int MinColumnWidthPx = 64;
|
|
|
|
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
|
|
private const string ColumnOrderStorageKey = "columnOrder";
|
|
private const string ColumnWidthsStorageKey = "columnWidths";
|
|
|
|
private readonly List<AuditEvent> _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<AuditResultsGrid>? _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<string>? _columnOrder;
|
|
private readonly Dictionary<string, int> _columnWidths = new();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Parameter] public AuditLogQueryFilter? Filter { get; set; }
|
|
|
|
/// <summary>Page size. Defaults to 100 to match the service-level default.</summary>
|
|
[Parameter] public int PageSize { get; set; } = DefaultPageSize;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }
|
|
|
|
/// <summary>
|
|
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
|
|
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
|
|
/// </summary>
|
|
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
|
|
|
|
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
|
|
private int _pageSize => Math.Max(1, PageSize);
|
|
|
|
/// <summary>
|
|
/// Default column definitions. The key is the stable identifier (used by
|
|
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
|
|
/// header text. Mirrors Component-AuditLog.md §10.
|
|
/// </summary>
|
|
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
|
|
{
|
|
("OccurredAtUtc", "OccurredAtUtc"),
|
|
("Site", "Site"),
|
|
("Channel", "Channel"),
|
|
("Kind", "Kind"),
|
|
("Status", "Status"),
|
|
("Target", "Target"),
|
|
("Actor", "Actor"),
|
|
("DurationMs", "DurationMs"),
|
|
("HttpStatus", "HttpStatus"),
|
|
("ErrorMessage", "ErrorMessage"),
|
|
};
|
|
|
|
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
|
|
=> ResolveOrder(_columnOrder ?? ColumnOrder);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? 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<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
|
|
/// custom property the scoped stylesheet reads, or an empty string when
|
|
/// the column has no persisted width (auto layout).
|
|
/// </summary>
|
|
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.
|
|
if (_selfRef is not null)
|
|
{
|
|
try
|
|
{
|
|
await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
// Circuit gone before init completed — nothing to wire.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the persisted column order + widths from <c>sessionStorage</c> 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.
|
|
/// </summary>
|
|
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<List<string>>(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<Dictionary<string, int>>(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<string?> TryLoadAsync(string key)
|
|
{
|
|
try
|
|
{
|
|
return await JS.InvokeAsync<string?>("auditGrid.load", key);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// JS callback: the user finished resizing a column. Persists the new
|
|
/// per-column width and re-renders so the body cells track the header.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
|
|
/// header of <paramref name="toKey"/>. Moves the dragged column into the
|
|
/// target's slot, persists the resulting order, and re-renders.
|
|
/// </summary>
|
|
[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), "…");
|
|
}
|
|
}
|