feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page

This commit is contained in:
Joseph Doherty
2026-05-21 18:38:02 -04:00
parent 252bf0a970
commit 0b5723b777
17 changed files with 387 additions and 9 deletions

View File

@@ -66,6 +66,7 @@ internal static class AuditDataSeeder
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
@@ -77,12 +78,12 @@ internal static class AuditDataSeeder
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
@executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target,
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
@@ -96,6 +97,7 @@ VALUES
cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@parentExecutionId", (object?)parentExecutionId ?? 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);

View File

@@ -27,6 +27,10 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <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>DrillInFromParentExecution_FiltersGridToSpawnerExecution</c> — the
/// drawer's "View parent execution" action on a spawned (child) row drills in
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
/// rows.</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
@@ -350,6 +354,81 @@ public class AuditLogPageTests
}
}
[Fact]
public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution()
{
// The drawer's "View parent execution" action navigates a routed (child)
// row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row
// (its ExecutionId == the parent id) and a child row (ParentExecutionId
// pointing at the spawner), open the child's drawer, click the action, and
// assert the grid auto-loads filtered to the spawner's own rows.
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/parent-exec-drill-in/{runId}/";
var parentExecutionId = Guid.NewGuid();
var spawnerEventId = Guid.NewGuid();
var childEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// The spawner execution's own row — carries ExecutionId == parentExecutionId.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: spawnerEventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "spawner",
executionId: parentExecutionId,
httpStatus: 200,
durationMs: 7);
// The child (spawned) row — ParentExecutionId points at the spawner.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: childEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "child",
executionId: Guid.NewGuid(),
parentExecutionId: parentExecutionId,
httpStatus: 200,
durationMs: 13);
var page = await _fixture.NewAuthenticatedPageAsync();
// Land on the child row via its ParentExecutionId filter, open the drawer.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?parentExecutionId={parentExecutionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
await Assertions.Expect(childRow).ToBeVisibleAsync();
await childRow.ClickAsync();
// The "View parent execution" action drills in to the spawner.
var viewParent = page.Locator("[data-test='view-parent-execution']");
await Assertions.Expect(viewParent).ToBeVisibleAsync();
await viewParent.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The drill-in lands on ?executionId={parentExecutionId} and auto-loads
// the spawner's own row.
Assert.Contains($"executionId={parentExecutionId}", page.Url);
var spawnerRow = page.Locator($"[data-test='grid-row-{spawnerEventId}']");
await Assertions.Expect(spawnerRow).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{