feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page
This commit is contained in:
@@ -65,6 +65,7 @@ internal static class AuditDataSeeder
|
||||
string? target = null,
|
||||
string? actor = null,
|
||||
Guid? correlationId = null,
|
||||
Guid? executionId = null,
|
||||
int? httpStatus = null,
|
||||
int? durationMs = null,
|
||||
string? errorMessage = null,
|
||||
@@ -76,13 +77,13 @@ internal static class AuditDataSeeder
|
||||
const string sql = @"
|
||||
INSERT INTO [AuditLog]
|
||||
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
|
||||
[SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status],
|
||||
[HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
|
||||
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
|
||||
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
|
||||
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
|
||||
VALUES
|
||||
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
|
||||
@sourceSiteId, NULL, NULL, @actor, @target, @status,
|
||||
@httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
|
||||
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
|
||||
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
|
||||
@responseSummary, 0, @extra, NULL);";
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
@@ -94,6 +95,7 @@ VALUES
|
||||
cmd.Parameters.AddWithValue("@channel", channel);
|
||||
cmd.Parameters.AddWithValue("@kind", kind);
|
||||
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
|
||||
|
||||
@@ -24,6 +24,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
/// link relies on; verified by reproducing the link target directly because
|
||||
/// seeding a notification visible to the report page requires the Akka query
|
||||
/// path, not just an INSERT).</item>
|
||||
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
|
||||
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
|
||||
/// auto-loads the grid filtered by ExecutionId.</item>
|
||||
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||
/// the report page wires drill-in links when notifications are present.</item>
|
||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
||||
@@ -289,6 +292,64 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
|
||||
{
|
||||
// Mirrors the correlationId drill-in: the "View this execution" drawer
|
||||
// action navigates to /audit/log?executionId={ExecutionId}. We seed a row
|
||||
// carrying that ExecutionId, hit the deep link directly, and assert the
|
||||
// page deserializes the param and auto-loads the seeded row.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/exec-drill-in/{runId}/";
|
||||
var executionId = Guid.NewGuid();
|
||||
var eventId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: eventId,
|
||||
occurredAtUtc: now,
|
||||
channel: "ApiOutbound",
|
||||
kind: "ApiCall",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "endpoint",
|
||||
executionId: executionId,
|
||||
httpStatus: 200,
|
||||
durationMs: 11);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// The exact URL the drawer's "View this execution" button produces.
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains($"executionId={executionId}", page.Url);
|
||||
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
|
||||
|
||||
// Auto-load: the query-string drill-in resolves the ?executionId=
|
||||
// filter on OnInitialized and the seeded row appears without an
|
||||
// Apply click.
|
||||
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
|
||||
await Assertions.Expect(seededRow).ToBeVisibleAsync();
|
||||
|
||||
// The ExecutionId column renders the row's short-form value.
|
||||
var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
|
||||
await Assertions.Expect(execCell).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
string? responseSummary = null,
|
||||
string? extra = null,
|
||||
Guid? correlationId = null,
|
||||
Guid? executionId = null,
|
||||
string? errorMessage = null,
|
||||
string? errorDetail = null,
|
||||
string? target = "demo-target")
|
||||
@@ -51,6 +52,7 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
Channel = channel,
|
||||
Kind = kind,
|
||||
CorrelationId = correlationId,
|
||||
ExecutionId = executionId,
|
||||
SourceSiteId = "plant-a",
|
||||
SourceInstanceId = "boiler-3",
|
||||
SourceScript = "OnAlarm.csx",
|
||||
@@ -216,6 +218,46 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
Assert.Contains(corr.ToString(), nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: null);
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
|
||||
{
|
||||
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
|
||||
var ev = MakeEvent(executionId: exec);
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
cut.Find("[data-test=\"view-this-execution\"]").Click();
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||
{
|
||||
|
||||
@@ -61,6 +61,7 @@ public class AuditFilterBarTests : BunitContext
|
||||
"data-test=\"filter-script\"",
|
||||
"data-test=\"filter-target\"",
|
||||
"data-test=\"filter-actor\"",
|
||||
"data-test=\"filter-execution-id\"",
|
||||
"data-test=\"filter-errors-only\"",
|
||||
};
|
||||
foreach (var marker in markers)
|
||||
@@ -178,6 +179,42 @@ public class AuditFilterBarTests : BunitContext
|
||||
Assert.Contains(AuditStatus.Failed, captured.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
|
||||
{
|
||||
// The operator pastes a Guid into the Execution ID box; Apply must map it
|
||||
// straight onto AuditLogQueryFilter.ExecutionId.
|
||||
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(executionId, captured!.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
|
||||
{
|
||||
// Lax parsing: a blank box yields no constraint; garbage text likewise.
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Blank — never typed into.
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ExecutionId);
|
||||
|
||||
// Unparseable paste — still dropped, no error.
|
||||
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
Assert.Null(captured!.ExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ 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")
|
||||
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
|
||||
=> new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
@@ -33,6 +33,7 @@ public class AuditResultsGridTests : BunitContext
|
||||
SourceSiteId = site,
|
||||
Target = "demo-target",
|
||||
Actor = "tester",
|
||||
ExecutionId = executionId,
|
||||
DurationMs = 42,
|
||||
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
|
||||
@@ -121,6 +122,49 @@ public class AuditResultsGridTests : BunitContext
|
||||
Assert.Equal(target.EventId, captured!.EventId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_IncludesExecutionIdColumn()
|
||||
{
|
||||
StubPage(new List<AuditEvent>
|
||||
{
|
||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||
});
|
||||
|
||||
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||
|
||||
// The ExecutionId column header is present alongside the spec columns.
|
||||
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
|
||||
{
|
||||
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
|
||||
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
|
||||
StubPage(new[] { row });
|
||||
|
||||
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||
|
||||
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
|
||||
// Short form: first 8 hex digits of the "N" form.
|
||||
Assert.Equal("abcdef01", cell.TextContent.Trim());
|
||||
// Monospace presentation; full value retained in the title attribute.
|
||||
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
|
||||
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
|
||||
{
|
||||
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
|
||||
StubPage(new[] { row });
|
||||
|
||||
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
|
||||
|
||||
// A null ExecutionId renders the em-dash placeholder, not a value cell.
|
||||
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_FailedRow_HasErrorBadgeClass()
|
||||
{
|
||||
|
||||
@@ -75,6 +75,20 @@ public class AuditLogPageExportUrlTests
|
||||
Assert.Equal("Notification", query["channel"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
|
||||
{
|
||||
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
|
||||
var filter = new AuditLogQueryFilter(ExecutionId: exec);
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
Assert.StartsWith("/api/centralui/audit/export?", url);
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Single(query);
|
||||
Assert.Equal(exec.ToString(), query["executionId"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
|
||||
{
|
||||
|
||||
@@ -176,6 +176,44 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
|
||||
{
|
||||
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
|
||||
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
|
||||
// set, and auto-loads the grid.
|
||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
|
||||
|
||||
// An unparseable executionId leaves ExecutionId null. With no other filter
|
||||
// params present the page renders but does NOT call the query service.
|
||||
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
|
||||
_queryService.DidNotReceive().QueryAsync(
|
||||
Arg.Any<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user