feat(ui): AuditResultsGrid + AuditLogQueryService with keyset paging (#23 M7)

Adds the results grid + query facade for the central Audit Log page
(#23 M7-T3):

* IAuditLogQueryService / AuditLogQueryService — CentralUI facade over
  IAuditLogRepository.QueryAsync so the grid can be tested with a stubbed
  query source. Default page size is 100; callers can override per call.

* AuditResultsGrid.razor + .razor.cs — Blazor Server component (Bootstrap
  only, no third-party UI libs). Renders the 10 columns from
  Component-AuditLog.md §10 (OccurredAtUtc, Site, Channel, Kind, Status,
  Target, Actor, DurationMs, HttpStatus, ErrorMessage). Keyset-paged via
  the last visible row's (OccurredAtUtc, EventId) as the cursor; Next-page
  button disabled when the current page is short (no count query). Row
  clicks emit OnRowSelected(AuditEvent) for Bundle C's drilldown drawer.
  Status badges are colour-coded (Delivered=green; Failed/Parked/Discarded
  =red; other=gray). Error messages truncated to 80 chars with full text
  on hover.

* Column model framework: a ColumnOrder [Parameter] reorders columns by
  stable string keys; unknown keys are dropped. M7 scope decision (in the
  class doc): the framework is in place but drag-reorder / resize UX is
  not implemented — M7.x can add persisted-per-user reordering without
  rewriting the column model.

* AuditLogPage wired: hosts AuditFilterBar + AuditResultsGrid, threads
  the filter through and stubs OnRowSelected for Bundle C.

* AuditLogQueryService registered as scoped in AddCentralUI.

* Tests: 4 grid bUnit tests (10 columns rendered, next-page cursor
  carries last row, row click raises callback, badge classes for
  Failed vs Delivered), 2 service tests (filter+paging pass-through,
  default page size of 100). AuditLogPageScaffoldTests updated to
  provide the new ISiteRepository + IAuditLogQueryService stubs the
  page now resolves.
This commit is contained in:
Joseph Doherty
2026-05-20 20:02:46 -04:00
parent 13e84a76a7
commit e052aa4ff8
10 changed files with 611 additions and 7 deletions

View File

@@ -0,0 +1,199 @@
using Microsoft.AspNetCore.Components;
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 visible order
/// is the <see cref="ColumnOrder"/> parameter. M7 scope: the column-model
/// framework is in place but resize / drag-reorder UX is intentionally NOT
/// implemented — the full spec calls for persisted-per-user reordering and
/// resizing, which M7.x can ship without rewriting the column model. Resizing
/// today is CSS-based via Bootstrap's <c>.table-responsive</c> wrapper.
/// </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
{
private const int DefaultPageSize = 100;
private readonly List<AuditEvent> _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
private AuditLogQueryFilter? _activeFilter;
/// <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()
{
if (ColumnOrder is null || ColumnOrder.Count == 0)
{
return AllColumns;
}
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(ColumnOrder.Count);
foreach (var key in ColumnOrder)
{
if (byKey.TryGetValue(key, out var col))
{
ordered.Add(col);
}
}
return ordered.Count == 0 ? AllColumns : ordered;
}
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);
}
}
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), "…");
}
}