b7f5e887ee
Persist the canonical AuditOutcome and make structured audit rows visible. - ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome enum member name (nvarchar(16), mirroring how AdminRole is persisted). The AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so legacy rows and the bespoke stored-procedure path (no derived outcome) write NULL. - Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column, no backfill. Up adds the column, Down drops it. Chains after 20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations has-pending-model-changes` is clean. - ClusterAudit visibility fix: the page filtered solely on ClusterId, but the structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page and tests) which ORs in rows whose NodeId belongs to a node in the cluster — membership resolved from ClusterNode (NodeId -> ClusterId). SP-path ClusterId-stamped rows still match. Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts); new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched here, occasionally fails under parallel load and passes in isolation).
83 lines
3.3 KiB
Plaintext
83 lines
3.3 KiB
Plaintext
@page "/clusters/{ClusterId}/audit"
|
|
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
|
@rendermode RenderMode.InteractiveServer
|
|
@using Microsoft.EntityFrameworkCore
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Queries
|
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h4 class="mb-0">Audit log · <span class="mono">@ClusterId</span></h4>
|
|
</div>
|
|
|
|
<ClusterNav ClusterId="@ClusterId" ActiveTab="audit" />
|
|
|
|
@if (_rows is null)
|
|
{
|
|
<p>Loading…</p>
|
|
}
|
|
else
|
|
{
|
|
<section class="panel notice rise" style="animation-delay:.02s">
|
|
Latest @PageSize audit rows scoped to this cluster, newest first. EventId/CorrelationId
|
|
columns (F3) make cross-restart deduplication possible — Akka actors that retry an apply
|
|
won't insert duplicate rows. Details JSON is shown verbatim.
|
|
</section>
|
|
|
|
<section class="panel rise mt-3" style="animation-delay:.08s">
|
|
<div class="panel-head">@_rows.Count row@(_rows.Count == 1 ? "" : "s")</div>
|
|
@if (_rows.Count == 0)
|
|
{
|
|
<div style="padding:1rem" class="text-muted">No audit rows for this cluster yet.</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>Principal</th>
|
|
<th>Event</th>
|
|
<th>Node</th>
|
|
<th>Correlation</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var a in _rows)
|
|
{
|
|
<tr>
|
|
<td><span class="mono small">@a.Timestamp.ToString("u")</span></td>
|
|
<td>@a.Principal</td>
|
|
<td><span class="chip chip-idle">@a.EventType</span></td>
|
|
<td><span class="mono small">@(a.NodeId ?? "—")</span></td>
|
|
<td><span class="mono small">@(a.CorrelationId?.ToString("N")[..8] ?? "—")</span></td>
|
|
<td class="text-muted small" style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
|
@(a.DetailsJson ?? "")
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@code {
|
|
private const int PageSize = 200;
|
|
|
|
[Parameter] public string ClusterId { get; set; } = "";
|
|
private List<ConfigAuditLog>? _rows;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await using var db = await DbFactory.CreateDbContextAsync();
|
|
// Shared query: matches both the SP path (stamps ClusterId) and the structured
|
|
// AuditWriterActor path (stamps NodeId, ClusterId null) so the latter's rows are visible.
|
|
_rows = await ClusterAuditQuery.ForClusterAsync(db, ClusterId, PageSize);
|
|
}
|
|
}
|