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"
|
@page "/audit/log"
|
||||||
@attribute [Authorize]
|
@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>
|
<PageTitle>Audit Log</PageTitle>
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<h1 class="h4 mb-3">Audit Log</h1>
|
<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">
|
<div class="mb-3">
|
||||||
|
<AuditFilterBar OnFilterChanged="HandleFilterChanged" />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
|
<AuditResultsGrid Filter="@_currentFilter" OnRowSelected="HandleRowSelected" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Code-behind for the central Audit Log page (#23 M7-T1). The Bundle A
|
/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3)
|
||||||
/// scaffold has no behaviour — the filter bar and results grid arrive in
|
/// wires up <c>AuditFilterBar</c> and <c>AuditResultsGrid</c>: the page owns the
|
||||||
/// Bundle B (M7-T2..M7-T7). Keeping the partial class in place now lets
|
/// active <see cref="AuditLogQueryFilter"/> and re-pushes a fresh instance to the
|
||||||
/// later bundles add injected services and event handlers without
|
/// grid on every Apply (the grid uses reference identity as its "reload"
|
||||||
/// touching the route or page-title markup.
|
/// 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>
|
/// </summary>
|
||||||
public partial class AuditLogPage
|
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.Auth;
|
||||||
using ScadaLink.CentralUI.Components.Shared;
|
using ScadaLink.CentralUI.Components.Shared;
|
||||||
using ScadaLink.CentralUI.ScriptAnalysis;
|
using ScadaLink.CentralUI.ScriptAnalysis;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI;
|
namespace ScadaLink.CentralUI;
|
||||||
|
|
||||||
@@ -27,6 +28,10 @@ public static class ServiceCollectionExtensions
|
|||||||
// Components/Shared/IDialogService.cs.
|
// Components/Shared/IDialogService.cs.
|
||||||
services.AddScoped<IDialogService, DialogService>();
|
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.
|
// Roslyn-backed C# analysis for the Monaco script editor.
|
||||||
// Scoped because SharedScriptCatalog wraps a scoped service.
|
// Scoped because SharedScriptCatalog wraps a scoped service.
|
||||||
services.AddMemoryCache(o => o.SizeLimit = 200);
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.Components.Audit;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
|
||||||
|
/// renders 10 columns, paginates via keyset (passing the last row's
|
||||||
|
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
|
||||||
|
/// that Bundle C wires to the drilldown drawer, and styles non-success status
|
||||||
|
/// rows with an error-coded badge.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditResultsGridTests : BunitContext
|
||||||
|
{
|
||||||
|
private readonly IAuditLogQueryService _service;
|
||||||
|
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
|
||||||
|
|
||||||
|
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a")
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = occurredAtUtc,
|
||||||
|
Channel = channel,
|
||||||
|
Kind = kind,
|
||||||
|
Status = status,
|
||||||
|
SourceSiteId = site,
|
||||||
|
Target = "demo-target",
|
||||||
|
Actor = "tester",
|
||||||
|
DurationMs = 42,
|
||||||
|
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||||
|
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public AuditResultsGridTests()
|
||||||
|
{
|
||||||
|
_service = Substitute.For<IAuditLogQueryService>();
|
||||||
|
_service.DefaultPageSize.Returns(100);
|
||||||
|
Services.AddSingleton(_service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StubPage(IReadOnlyList<AuditEvent> rows)
|
||||||
|
{
|
||||||
|
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(callInfo =>
|
||||||
|
{
|
||||||
|
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
|
||||||
|
return Task.FromResult(rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_TenColumns_FromStubService()
|
||||||
|
{
|
||||||
|
StubPage(new List<AuditEvent>
|
||||||
|
{
|
||||||
|
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||||
|
});
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// 10 column headers per Component-AuditLog.md §10.
|
||||||
|
var expectedHeaders = new[]
|
||||||
|
{
|
||||||
|
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
|
||||||
|
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
|
||||||
|
};
|
||||||
|
foreach (var header in expectedHeaders)
|
||||||
|
{
|
||||||
|
Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Click_NextPage_CallsService_WithCursor_OfLastRow()
|
||||||
|
{
|
||||||
|
// First page: two rows, descending by OccurredAtUtc. The grid must pass the
|
||||||
|
// LAST row (the older one) back as the keyset cursor for the next page.
|
||||||
|
var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered);
|
||||||
|
var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed);
|
||||||
|
StubPage(new[] { first, second });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
cut.Find("[data-test=\"grid-next-page\"]").Click();
|
||||||
|
|
||||||
|
// Two service calls: initial + next.
|
||||||
|
Assert.Equal(2, _calls.Count);
|
||||||
|
var nextCall = _calls[1];
|
||||||
|
Assert.NotNull(nextCall.Paging);
|
||||||
|
Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc);
|
||||||
|
Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Click_Row_RaisesOnRowSelected()
|
||||||
|
{
|
||||||
|
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
|
||||||
|
StubPage(new[] { target });
|
||||||
|
|
||||||
|
AuditEvent? captured = null;
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p
|
||||||
|
.Add(c => c.Filter, new AuditLogQueryFilter())
|
||||||
|
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
|
||||||
|
|
||||||
|
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
|
||||||
|
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(target.EventId, captured!.EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Status_FailedRow_HasErrorBadgeClass()
|
||||||
|
{
|
||||||
|
var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed);
|
||||||
|
var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered);
|
||||||
|
StubPage(new[] { delivered, failed });
|
||||||
|
|
||||||
|
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||||
|
|
||||||
|
// Failed badge => bg-danger (red). Delivered => bg-success (green).
|
||||||
|
var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]");
|
||||||
|
Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty);
|
||||||
|
|
||||||
|
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
|
||||||
|
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
|
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
|
||||||
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
|
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
|
||||||
@@ -36,6 +38,11 @@ public class AuditLogPageScaffoldTests : BunitContext
|
|||||||
Services.AddAuthorizationCore();
|
Services.AddAuthorizationCore();
|
||||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||||
|
// The page now hosts AuditFilterBar + AuditResultsGrid which depend on
|
||||||
|
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
|
||||||
|
// Provide stand-ins so the scaffold smoke tests still render the page.
|
||||||
|
Services.AddSingleton(Substitute.For<ScadaLink.Commons.Interfaces.Repositories.ISiteRepository>());
|
||||||
|
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
|
||||||
return Render<AuditLogPage>();
|
return Render<AuditLogPage>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using NSubstitute;
|
||||||
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
|
||||||
|
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
|
||||||
|
/// these tests pin the filter forwarding contract and the 100-row default-page-size
|
||||||
|
/// rule the grid relies on.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogQueryServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
|
||||||
|
var paging = new AuditLogPaging(PageSize: 25);
|
||||||
|
var expected = new List<AuditEvent>
|
||||||
|
{
|
||||||
|
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
|
||||||
|
};
|
||||||
|
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
|
||||||
|
|
||||||
|
var sut = new AuditLogQueryService(repo);
|
||||||
|
|
||||||
|
var result = await sut.QueryAsync(filter, paging);
|
||||||
|
|
||||||
|
Assert.Same(expected, result);
|
||||||
|
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
|
||||||
|
{
|
||||||
|
var repo = Substitute.For<IAuditLogRepository>();
|
||||||
|
AuditLogPaging? observed = null;
|
||||||
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
||||||
|
|
||||||
|
var sut = new AuditLogQueryService(repo);
|
||||||
|
|
||||||
|
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
|
||||||
|
|
||||||
|
Assert.NotNull(observed);
|
||||||
|
Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
|
||||||
|
Assert.Equal(100, sut.DefaultPageSize);
|
||||||
|
Assert.Null(observed.AfterOccurredAtUtc);
|
||||||
|
Assert.Null(observed.AfterEventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user