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

This commit is contained in:
Joseph Doherty
2026-05-21 15:52:57 -04:00
parent cfd8f1ecf4
commit 1ba62052d6
15 changed files with 343 additions and 14 deletions

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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()
{