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:
111
src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
Normal file
111
src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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), "…");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
Normal file
32
src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
Normal file
30
src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user