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;
}
};
}