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,111 @@
@using ScadaLink.CentralUI.Components.Shared
@using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Types.Audit
@using ScadaLink.Commons.Types.Enums
@inject IAuditLogQueryService QueryService
<div data-test="audit-results-grid">
@if (_error is not null)
{
<div class="alert alert-danger small mb-2">@_error</div>
}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
@foreach (var col in OrderedColumns())
{
<th data-test="col-header-@col.Key">@col.Label</th>
}
</tr>
</thead>
<tbody>
@if (_rows.Count == 0)
{
<tr>
<td colspan="@OrderedColumns().Count" class="text-muted small text-center py-4">
@if (_loading)
{
<span>Loading…</span>
}
else
{
<span>No audit events match the current filter.</span>
}
</td>
</tr>
}
else
{
@foreach (var row in _rows)
{
<tr @key="row.EventId"
data-test="grid-row-@row.EventId"
class="audit-row"
style="cursor: pointer;"
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td>
@RenderCell(col.Key, row)
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_pageNumber · @_rows.Count rows</span>
<button class="btn btn-outline-secondary btn-sm"
data-test="grid-next-page"
disabled="@(_loading || _rows.Count < _pageSize)"
@onclick="NextPage">Next page</button>
</div>
</div>
@code {
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{
switch (key)
{
case "OccurredAtUtc":
var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc));
<span title="@row.OccurredAtUtc.ToString("u")">
<TimestampDisplay Value="occurredOffset" Format="yyyy-MM-dd HH:mm:ss" />
</span>
break;
case "Site":
<span class="small">@(row.SourceSiteId ?? "—")</span>
break;
case "Channel":
<span class="small">@row.Channel</span>
break;
case "Kind":
<span class="small">@row.Kind</span>
break;
case "Status":
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
break;
case "Target":
<span class="small">@(row.Target ?? "—")</span>
break;
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;
case "HttpStatus":
<span class="small font-monospace">@(row.HttpStatus?.ToString() ?? "—")</span>
break;
case "ErrorMessage":
<span class="small text-danger" title="@row.ErrorMessage">@TruncateError(row.ErrorMessage)</span>
break;
}
};
}

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), "…");
}
}

View File

@@ -1,16 +1,25 @@
@page "/audit/log"
@attribute [Authorize]
@using ScadaLink.CentralUI.Components.Audit
@using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Types.Audit
@inject IAuditLogQueryService AuditLogQueryService
<PageTitle>Audit Log</PageTitle>
<div class="container-fluid mt-3">
<h1 class="h4 mb-3">Audit Log</h1>
@* AuditFilterBar will go here (Bundle B). *@
@* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. *@
<div class="mb-3">
<AuditFilterBar OnFilterChanged="HandleFilterChanged" />
</div>
@* AuditResultsGrid will go here (Bundle B). *@
@* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's
drilldown drawer; the grid stays in "no events" mode until the user applies a
filter so the page does not auto-load the full audit table on first render. *@
<div>
<AuditResultsGrid Filter="@_currentFilter" OnRowSelected="HandleRowSelected" />
</div>
</div>

View File

@@ -1,12 +1,32 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <summary>
/// Code-behind for the central Audit Log page (#23 M7-T1). The Bundle A
/// scaffold has no behaviour — the filter bar and results grid arrive in
/// Bundle B (M7-T2..M7-T7). Keeping the partial class in place now lets
/// later bundles add injected services and event handlers without
/// touching the route or page-title markup.
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
/// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the
/// grid on every Apply (the grid uses reference identity as its "reload"
/// trigger). Row clicks land in <see cref="HandleRowSelected"/> — Bundle C wires
/// this to the drilldown drawer; for now it is a no-op seam so test stubs do
/// not error.
/// </summary>
public partial class AuditLogPage
{
private AuditLogQueryFilter? _currentFilter;
private void HandleFilterChanged(AuditLogQueryFilter filter)
{
// Always reassign — the grid keys reloads on reference change, so even a
// chip-for-chip identical filter must allocate a fresh instance.
_currentFilter = filter;
}
private void HandleRowSelected(AuditEvent row)
{
// Reserved for Bundle C (drilldown drawer). Intentionally left empty: the
// grid still raises the event, but we do nothing with it yet.
_ = row;
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.CentralUI.ScriptAnalysis;
using ScadaLink.CentralUI.Services;
namespace ScadaLink.CentralUI;
@@ -27,6 +28,10 @@ public static class ServiceCollectionExtensions
// Components/Shared/IDialogService.cs.
services.AddScoped<IDialogService, DialogService>();
// Audit Log (#23 M7-T3): CentralUI facade over IAuditLogRepository so the
// results grid can be tested with a stubbed query source.
services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
// Roslyn-backed C# analysis for the Monaco script editor.
// Scoped because SharedScriptCatalog wraps a scoped service.
services.AddMemoryCache(o => o.SizeLimit = 200);

View File

@@ -0,0 +1,32 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Services;
/// <summary>
/// Default <see cref="IAuditLogQueryService"/> implementation — a thin pass-through
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
/// AuditResultsGrid default for #23 M7).
/// </summary>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
private readonly IAuditLogRepository _repository;
public AuditLogQueryService(IAuditLogRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public int DefaultPageSize => 100;
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(filter);
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
return _repository.QueryAsync(filter, effective, ct);
}
}

View File

@@ -0,0 +1,30 @@
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Services;
/// <summary>
/// CentralUI facade over <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository"/>
/// (#23 M7-T3). The Audit Log page's results grid talks to this service rather than
/// the repository directly so tests can substitute a fake without spinning up EF
/// Core, and so a future caching / shaping layer (e.g. server-side CSV streaming)
/// can hang off the same seam.
/// </summary>
public interface IAuditLogQueryService
{
/// <summary>
/// Returns a keyset-paged result page for <paramref name="filter"/>. When
/// <paramref name="paging"/> is <c>null</c>, defaults to <see cref="DefaultPageSize"/>
/// rows with no cursor (first page). The repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>; pass the last row's
/// <see cref="AuditEvent.OccurredAtUtc"/> + <see cref="AuditEvent.EventId"/>
/// back as the cursor for the next page.
/// </summary>
Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter,
AuditLogPaging? paging = null,
CancellationToken ct = default);
/// <summary>Default page size when callers don't specify one.</summary>
int DefaultPageSize { get; }
}