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:
@@ -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), "…");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user