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

@@ -55,6 +55,9 @@
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -151,6 +154,14 @@
Show all events for this operation
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">

View File

@@ -47,9 +47,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. The parent page does not
/// auto-apply that filter today — it is a deep link the page can use
/// when Bundle D wires up query-string deserialization.
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
public partial class AuditDrilldownDrawer
@@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as

View File

@@ -117,6 +117,16 @@
placeholder="contains…" @bind="_model.ActorSearch" />
</div>
@* ExecutionId is an exact-match Guid filter — the operator pastes the
universal per-run correlation value. Lax-parsed in ToFilter so a
blank/malformed paste simply drops the constraint. *@
<div class="col-auto" data-test="filter-execution-id">
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
<input id="audit-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ExecutionId" />
</div>
<div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"

View File

@@ -135,6 +135,7 @@ public partial class AuditFilterBar
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ErrorsOnly = false;
}

View File

@@ -47,6 +47,14 @@ public sealed class AuditQueryModel
public string TargetSearch { get; set; } = string.Empty;
public string ActorSearch { get; set; } = string.Empty;
/// <summary>
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
/// unparseable value simply yields no constraint.
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
public bool ErrorsOnly { get; set; }
/// <summary>
@@ -114,6 +122,12 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
// constraint rather than an error, mirroring the optional-filter contract.
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
? parsedExecutionId
: null;
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
@@ -122,6 +136,7 @@ public sealed class AuditQueryModel
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}

View File

@@ -83,6 +83,15 @@
</div>
@code {
// Compact display for Guid id columns: the first 8 hex digits, mirroring
// the drilldown drawer's ShortEventId presentation. The full value is kept
// in the cell's title attribute so it stays copy-paste accessible.
private static string ShortGuid(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{
switch (key)
@@ -111,6 +120,18 @@
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
break;
case "ExecutionId":
@if (row.ExecutionId is { } executionId)
{
<span class="small font-monospace"
data-test="execution-id-@row.EventId"
title="@executionId">@ShortGuid(executionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;

View File

@@ -9,9 +9,10 @@ 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"/>
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core.
///
@@ -121,6 +122,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),

View File

@@ -22,7 +22,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -60,6 +61,16 @@ public partial class AuditLogPage
correlationId = parsedCorr;
}
// ?executionId= is the "View this execution" drill-in target — the
// universal per-run correlation value. Lax-parsed like ?correlationId=:
// an unparseable value is silently dropped (no constraint).
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
string? target = null;
if (query.TryGetValue("target", out var targetValues))
{
@@ -117,7 +128,7 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs
// to refine + Apply for those.
if (correlationId is null && target is null && actor is null
if (correlationId is null && executionId is null && target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
@@ -130,7 +141,8 @@ public partial class AuditLogPage
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId);
CorrelationId: correlationId,
ExecutionId: executionId);
}
/// <summary>
@@ -236,6 +248,10 @@ public partial class AuditLogPage
{
parts.Add(new("correlationId", corr.ToString()));
}
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));