refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
is shared with the execution-tree node-detail modal. *@
@if (IsOpen && Event is not null)
{
<div class="offcanvas-backdrop fade show" data-test="drawer-backdrop"
@onclick="HandleClose"></div>
<div class="offcanvas offcanvas-end show audit-drilldown-drawer"
tabindex="-1"
style="visibility: visible;"
data-test="audit-drilldown-drawer">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Audit event</div>
<h5 class="offcanvas-title mb-0">Audit Event @ShortEventId(Event.EventId)</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="drawer-close"
@onclick="HandleClose"></button>
</div>
<div class="offcanvas-body small">
@* Single-row detail body + action buttons — shared component. *@
<AuditEventDetail Event="Event" />
</div>
@* Close button kept at the bottom per form-layout memory. *@
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
/// to <see cref="AuditEventDetail"/>, which is shared with the execution-tree
/// node-detail modal so a row's detail renders identically in either host.
/// The drawer is fully presentational — it has no DB or service dependencies;
/// the host page owns the open/close state.
/// </summary>
public partial class AuditDrilldownDrawer
{
/// <summary>
/// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
/// </summary>
[Parameter] public AuditEvent? Event { get; set; }
/// <summary>
/// True when the host wants the drawer visible. We deliberately keep
/// this as a separate parameter from <see cref="Event"/>: an open
/// drawer briefly with a null event renders nothing (the row may still
/// be loading); a closed drawer with a stale event is the resting state.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the drawer (close button or backdrop
/// click). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
private static string ShortEventId(Guid eventId)
{
// Mirror the "first 8 hex digits" presentation common across the UI.
var n = eventId.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
}
@@ -0,0 +1,15 @@
/* Audit Log drilldown drawer (#23 M7 Bundle C).
The base offcanvas + backdrop classes come from Bootstrap. The local
overrides below pin our preferred width and the footer tint. The body
(pre-block) styles travel with the markup in AuditEventDetail.razor.css. */
.audit-drilldown-drawer {
/* Slightly wider than the parked-messages drawer because audit rows can
carry larger JSON bodies and SQL blocks. Clamp to viewport so narrow
windows still get the close button on screen. */
width: min(720px, 95vw);
}
.audit-drilldown-drawer .drawer-footer {
background-color: var(--bs-tertiary-bg);
}
@@ -0,0 +1,168 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
Extracted from AuditDrilldownDrawer so the drawer and the execution-tree
node-detail modal share one rendering of a row's detail.
All form/field rendering follows the form-layout memory:
read-only fields first (definition list), then subsections stacked,
action buttons at the bottom. *@
@* Read-only field list — primary identification + provenance. *@
<dl class="row mb-3" data-test="drawer-fields">
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
<dt class="col-4 text-muted fw-normal">Status</dt>
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Target</dt>
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Actor</dt>
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceNode</dt>
<dd class="col-8" data-test="field-SourceNode">@(Event.SourceNode ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
<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">ParentExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.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>
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
</dl>
@* Error subsection — only shown when there is something to report. *@
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
{
<section class="mb-3" data-test="section-error">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
{
<p class="text-danger mb-1">@Event.ErrorMessage</p>
}
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
{
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
}
</section>
}
@* Request body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.RequestSummary))
{
<section class="mb-3" data-test="section-request">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Request</span>
@if (IsRedacted(Event.RequestSummary))
{
<span data-test="redaction-badge-request"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="request-body">
@RenderBody(Event.RequestSummary!, Event.Channel)
</div>
</section>
}
@* Response body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
{
<section class="mb-3" data-test="section-response">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Response</span>
@if (IsRedacted(Event.ResponseSummary))
{
<span data-test="redaction-badge-response"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="response-body">
@RenderBody(Event.ResponseSummary!, Event.Channel)
</div>
</section>
}
@* Extra is always JSON when present. *@
@if (!string.IsNullOrEmpty(Event.Extra))
{
<section class="mb-3" data-test="section-extra">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
</section>
}
@* Action buttons at the bottom per form-layout memory. *@
<div class="d-flex gap-2 flex-wrap" data-test="audit-event-detail-actions">
@if (IsApiChannel(Event.Channel))
{
<button class="btn btn-outline-secondary btn-sm"
data-test="copy-as-curl"
@onclick="CopyCurl">
Copy as cURL
</button>
}
@if (Event.CorrelationId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="show-all-events"
@onclick="ShowAllForOperation">
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>
}
@if (Event.ParentExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-parent-execution"
@onclick="ViewParentExecution">
View parent execution
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-execution-chain"
@onclick="ViewExecutionChain">
View execution chain
</button>
}
</div>
@@ -0,0 +1,393 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Reusable single-<see cref="AuditEvent"/> detail body (#23 M7 Bundle C /
/// M7-T4..T8). Extracted verbatim from <see cref="AuditDrilldownDrawer"/> so
/// the drawer and the execution-tree node-detail modal render a row's detail
/// identically. Renders the read-only field list, the conditional
/// Error/Request/Response/Extra subsections, and the action buttons (Copy as
/// cURL, Show all events for this operation, View this/parent execution, View
/// execution chain). The component is fully presentational apart from the
/// clipboard interop and drill-back navigation it owns; the host owns its
/// surrounding chrome.
///
/// <para>
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
/// JSON is pretty-printed when it parses; falls back to verbatim otherwise.
/// DbOutbound payloads carry a <c>{sql, parameters}</c> JSON shape and get a
/// SQL code block plus a parameter definition list. Syntax highlighting is
/// CSS-class-only (<c>language-sql</c>); no JS library is loaded — Blazor
/// Server + Bootstrap only per the project's UI rules.
/// </para>
///
/// <para>
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
/// with the literal sentinels <c>&lt;redacted&gt;</c> or
/// <c>&lt;redacted: redactor error&gt;</c> (see Component-AuditLog.md
/// §Redaction). A yellow "Redacted" badge surfaces on a body section when
/// its text contains either sentinel — no un-redaction or counting.
/// </para>
///
/// <para>
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
/// command is written to the system clipboard via
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. The button
/// is only surfaced for API channels (ApiOutbound / ApiInbound).
/// </para>
///
/// <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>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
public partial class AuditEventDetail
{
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary>
/// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show.
/// </summary>
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
private static bool IsApiChannel(AuditChannel channel)
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
private static string FormatTimestamp(DateTime utc)
{
// Force UTC kind in case the row arrived as Unspecified, then emit
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
return kind.ToString("o", CultureInfo.InvariantCulture);
}
private static bool IsRedacted(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
}
/// <summary>
/// Channel-aware body renderer. DbOutbound bodies that parse as
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
/// else falls back to JSON-pretty-print, then plain-text verbatim.
/// </summary>
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
{
// DbOutbound special-case: try to extract {sql, parameters}.
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
{
builder.OpenElement(0, "pre");
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
builder.OpenElement(2, "code");
// Highlighting is CSS-class-only — no JS library is loaded.
builder.AddAttribute(3, "class", "language-sql");
builder.AddContent(4, sql);
builder.CloseElement();
builder.CloseElement();
if (parameters is not null && parameters.Count > 0)
{
builder.OpenElement(10, "dl");
builder.AddAttribute(11, "class", "row mb-0 small");
builder.AddAttribute(12, "data-test", "sql-parameters");
// The analyzer (ASP0006) requires literal sequence numbers
// inside a render fragment. We delegate parameter rendering
// to a helper fragment that uses a stable @key per entry,
// so per-row diffing stays correct even though the outer
// sequence number is fixed.
builder.AddContent(13, BuildSqlParameterRows(parameters));
builder.CloseElement();
}
return;
}
// Generic JSON pretty-print path.
if (TryPrettyPrintJson(body, out var pretty))
{
builder.OpenElement(20, "pre");
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
builder.AddContent(22, pretty);
builder.CloseElement();
return;
}
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
// is useful when the body is multi-line plain text or a partial JSON.
builder.OpenElement(30, "pre");
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
builder.AddContent(32, body);
builder.CloseElement();
};
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
{
foreach (var kv in parameters)
{
// Literal sequence numbers (ASP0006) + per-element SetKey so
// Blazor's diff is still keyed on parameter name. The "0" base
// is fine here — each loop iteration produces a disjoint
// dt/dd pair, and the diff keys on @key, not sequence.
builder.OpenElement(0, "dt");
builder.SetKey($"dt-{kv.Key}");
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
builder.AddContent(2, kv.Key);
builder.CloseElement();
builder.OpenElement(3, "dd");
builder.SetKey($"dd-{kv.Key}");
builder.AddAttribute(4, "class", "col-8 font-monospace");
builder.AddContent(5, kv.Value);
builder.CloseElement();
}
};
private static bool TryPrettyPrintJson(string text, out string formatted)
{
formatted = text;
try
{
using var doc = JsonDocument.Parse(text);
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
return true;
}
catch (JsonException)
{
return false;
}
}
private static string PrettyPrintJson(string text)
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
/// <summary>
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
/// Returns true only when the JSON has a string <c>sql</c> property;
/// <c>parameters</c> is treated as an optional object whose values
/// stringify to scalar text.
/// </summary>
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
{
sql = string.Empty;
parameters = null;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
return false;
sql = sqlProp.GetString() ?? string.Empty;
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
&& paramsProp.ValueKind == JsonValueKind.Object)
{
parameters = new List<KeyValuePair<string, string>>();
foreach (var p in paramsProp.EnumerateObject())
{
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => value.GetRawText(),
_ => value.GetRawText(),
};
private async Task CopyCurl()
{
var curl = BuildCurlCommand(Event);
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
}
catch
{
// Clipboard interop can fail (denied permission, prerender, etc.).
// The component stays mounted; the failure surfaces in the dev
// console only — we deliberately do not toast here because the
// parent page owns toast state.
}
}
private void ShowAllForOperation()
{
if (Event.CorrelationId is not { } corr) return;
var uri = $"/audit/log?correlationId={corr}";
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>
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
/// drill-in target. The button is only rendered when
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewParentExecution()
{
if (Event.ParentExecutionId is not { } parentExec) return;
var uri = $"/audit/log?executionId={parentExec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
/// is total.
/// </summary>
private void ViewExecutionChain()
{
if (Event.ExecutionId is not { } exec) return;
var uri = $"/audit/execution-tree?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
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
/// and the body into <c>--data-raw</c>. Default method is POST for
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
{
var sb = new StringBuilder();
sb.Append("curl");
string method = "POST";
List<KeyValuePair<string, string>>? headers = null;
string? body = null;
if (!string.IsNullOrEmpty(ev.RequestSummary))
{
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
}
sb.Append(' ').Append("-X ").Append(method);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
sb.Append(' ').Append("-H ");
sb.Append(QuoteShellArg($"{name}: {value}"));
}
}
if (!string.IsNullOrEmpty(body))
{
sb.Append(' ').Append("--data-raw ");
sb.Append(QuoteShellArg(body!));
}
var url = ev.Target ?? string.Empty;
sb.Append(' ').Append(QuoteShellArg(url));
return sb.ToString();
}
private static void TryExtractCurlPartsFromJson(
string requestSummary,
ref string method,
ref List<KeyValuePair<string, string>>? headers,
ref string? body)
{
try
{
using var doc = JsonDocument.Parse(requestSummary);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
{
method = m.GetString() ?? method;
}
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
{
headers = new List<KeyValuePair<string, string>>();
foreach (var h in hs.EnumerateObject())
{
var value = h.Value.ValueKind == JsonValueKind.String
? h.Value.GetString() ?? string.Empty
: h.Value.GetRawText();
headers.Add(new KeyValuePair<string, string>(h.Name, value));
}
}
if (doc.RootElement.TryGetProperty("body", out var b))
{
body = b.ValueKind == JsonValueKind.String
? b.GetString()
: b.GetRawText();
}
}
catch (JsonException)
{
// RequestSummary wasn't the expected {headers, body} shape —
// just produce a bare cURL with no body/headers.
}
}
/// <summary>
/// Quote a single shell argument with single quotes, escaping embedded
/// single quotes via the standard <c>'\''</c> idiom. This is the same
/// quoting strategy curl examples use across man pages.
/// </summary>
private static string QuoteShellArg(string value)
{
if (string.IsNullOrEmpty(value)) return "''";
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
return $"'{escaped}'";
}
}
@@ -0,0 +1,30 @@
/* Body-specific styles for the shared single-AuditEvent detail
(#23 M7 Bundle C). Moved here from AuditDrilldownDrawer.razor.css so the
scoped CSS travels with the markup — these rules apply wherever the
detail body is hosted (drilldown drawer or execution-tree node modal). */
.drawer-pre {
/* Wrap long lines and bound the per-block height so the host body stays
scrollable end-to-end instead of pushing the action buttons below the
fold. */
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow-y: auto;
margin: 0;
font-size: 0.8125rem;
}
.drawer-pre.json {
/* JSON blocks get a faint left rule so they read as quoted material. */
border-left: 3px solid var(--bs-info-border-subtle);
}
.drawer-pre code.language-sql {
/* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
a slightly different background so the SQL block reads distinct from
generic JSON pretty-prints without loading a syntax-highlighter JS
library. */
font-family: var(--bs-font-monospace);
color: var(--bs-emphasis-color);
}
@@ -0,0 +1,173 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject ISiteRepository SiteRepository
@inject IAuditLogQueryService AuditLogQueryService
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@* All filters sit in one wrapped row. Kind / Status / Site use compact
MultiSelectDropdown controls; Channel is a single-select because the
Kind options narrow to the chosen channel — so the bar stays a row or
two tall instead of four stacked blocks of chip buttons. *@
<div class="row g-2 align-items-end">
@* Single-select: one channel at a time, so the Kind options below
narrow cleanly to that channel. "All channels" clears it. *@
<div class="col-auto" data-test="filter-channel">
<label class="form-label small mb-1" for="audit-channel">Channel</label>
<select id="audit-channel" data-test="filter-channel-select"
class="form-select form-select-sm" @bind="SelectedChannel">
<option value="">All channels</option>
@foreach (var channel in _channels)
{
<option value="@channel">@channel</option>
}
</select>
</div>
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
<div class="col-auto" data-test="filter-kind">
<label class="form-label small mb-1">Kind</label>
<div>
<MultiSelectDropdown TValue="AuditKind"
Items="_model.VisibleKinds()"
Selected="_model.Kinds"
DataTest="filter-kind-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-status">
<label class="form-label small mb-1">Status</label>
<div>
<MultiSelectDropdown TValue="AuditStatus"
Items="_statuses"
Selected="_model.Statuses"
DataTest="filter-status-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-site">
<label class="form-label small mb-1">Site</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_siteIds"
Selected="_model.SiteIdentifiers"
Display="SiteName"
EmptyText="No sites available"
DataTest="filter-site-ms" />
</div>
</div>
@* Node multi-select. Options are the distinct SourceNode values
observed in the AuditLog table; the service-side lookup is cached
for 60s so a render of this bar costs at most one DB hit per
minute per circuit. *@
<div class="col-auto" data-test="filter-node">
<label class="form-label small mb-1">Node</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_sourceNodes"
Selected="_model.SourceNodes"
EmptyText="No nodes available"
DataTest="filter-node-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm"
@bind="_model.TimeRange">
<option value="@AuditTimeRangePreset.Last5Minutes">Last 5 min</option>
<option value="@AuditTimeRangePreset.LastHour">Last 1h</option>
<option value="@AuditTimeRangePreset.Last24Hours">Last 24h</option>
<option value="@AuditTimeRangePreset.Custom">Custom</option>
</select>
</div>
@* Custom datetime range; only the pickers are conditional, the wrapper is
always emitted so tests can find it. *@
<div class="col-auto" data-test="filter-custom-range">
@if (_model.TimeRange == AuditTimeRangePreset.Custom)
{
<div class="d-flex gap-1 align-items-end">
<div>
<label class="form-label small mb-1" for="audit-from">From (UTC)</label>
<input id="audit-from" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomFromUtc" />
</div>
<div>
<label class="form-label small mb-1" for="audit-to">To (UTC)</label>
<input id="audit-to" type="datetime-local" class="form-control form-control-sm"
@bind="_model.CustomToUtc" />
</div>
</div>
}
else
{
<span class="text-muted small">Window: @TimeRangeLabel(_model.TimeRange)</span>
}
</div>
<div class="col-auto" data-test="filter-instance">
<label class="form-label small mb-1" for="audit-instance">Instance</label>
<input id="audit-instance" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.InstanceSearch" />
</div>
<div class="col-auto" data-test="filter-script">
<label class="form-label small mb-1" for="audit-script">Script</label>
<input id="audit-script" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.ScriptSearch" />
</div>
<div class="col-auto" data-test="filter-target">
<label class="form-label small mb-1" for="audit-target">Target</label>
<input id="audit-target" type="text" class="form-control form-control-sm"
placeholder="contains…" @bind="_model.TargetSearch" />
</div>
<div class="col-auto" data-test="filter-actor">
<label class="form-label small mb-1" for="audit-actor">Actor</label>
<input id="audit-actor" type="text" class="form-control form-control-sm"
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>
@* ParentExecutionId is an exact-match Guid filter — the operator pastes
the spawner execution's id to find every run it spawned. Lax-parsed
in ToFilter, exactly like ExecutionId above. *@
<div class="col-auto" data-test="filter-parent-execution-id">
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
<input id="audit-parent-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
</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"
@bind="_model.ErrorsOnly" />
<label class="form-check-label small" for="audit-errors-only">Errors only</label>
</div>
</div>
<div class="col-auto ms-auto">
<button class="btn btn-outline-secondary btn-sm me-1"
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
<button class="btn btn-primary btn-sm"
@onclick="Apply" data-test="filter-apply">Apply</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,210 @@
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
/// — Channel as a single-select (one channel at a time, so the Kind options
/// narrow to it cleanly); Kind / Status / Site as compact
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls; plus the time range, free-text searches and the Errors-only
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
/// dimensions map through to the filter's list fields; see
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
/// </summary>
public partial class AuditFilterBar
{
private readonly AuditQueryModel _model = new();
private List<Site> _sites = new();
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary>
/// Distinct <c>SourceNode</c> identifiers in display order; populated once
/// when the filter bar initialises from the cached
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Services.IAuditLogQueryService.GetDistinctSourceNodesAsync"/>
/// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
/// "No nodes available", mirroring the site loader.
/// </summary>
private IReadOnlyList<string> _sourceNodes = Array.Empty<string>();
/// <summary>
/// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Services.IAuditLogQueryService"/>.
/// </summary>
[Parameter] public EventCallback<AuditLogQueryFilter> OnFilterChanged { get; set; }
/// <summary>
/// Test seam: overriding "now" is needed to make the time-range collapse tests
/// stable in unit suites. Production callers leave this null and the model
/// uses <see cref="DateTime.UtcNow"/>.
/// </summary>
[Parameter] public Func<DateTime>? NowUtcProvider { get; set; }
/// <summary>
/// Bundle D drill-in seam (#23 M7-T10..T12). When set on first render,
/// pre-populates the Instance free-text input. Instance is UI-only — the
/// repository filter contract has no instance column — so this flows in
/// through a separate parameter rather than the <see cref="AuditLogQueryFilter"/>
/// the parent page passes to the grid.
/// </summary>
[Parameter] public string? InitialInstanceSearch { get; set; }
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
// One-shot prefill from a drill-in deep link. Subsequent parameter changes
// do NOT overwrite user input — the field is owned by the operator after
// first render.
if (!string.IsNullOrWhiteSpace(InitialInstanceSearch))
{
_model.InstanceSearch = InitialInstanceSearch.Trim();
}
// Populate the Site dropdown at component init. Failure is non-fatal — the
// dropdown just shows "No sites available." Sites are listed by Name to
// match operator expectations from the Notification Report.
try
{
var sites = await SiteRepository.GetAllSitesAsync();
_sites = sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
catch
{
// Swallowed: filter bar still renders without the Site options. The page
// surfaces site-load errors elsewhere (the grid query path).
_sites = new();
}
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
// Populate the Node dropdown alongside the Site dropdown. The service
// caches the distinct-nodes lookup for 60s so this never costs more
// than one DB hit per minute per circuit; on failure the dropdown
// degrades to "No nodes available" like the site loader.
try
{
var nodes = await AuditLogQueryService.GetDistinctSourceNodesAsync();
_sourceNodes = nodes.ToArray();
}
catch
{
_sourceNodes = Array.Empty<string>();
}
}
/// <summary>
/// Single-select Channel binding for the filter bar. The Audit Log filters one
/// channel at a time so the Kind options narrow cleanly to it; the model still
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
/// </summary>
private AuditChannel? SelectedChannel
{
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
set
{
_model.Channels.Clear();
if (value is { } channel)
{
_model.Channels.Add(channel);
}
OnChannelsChanged();
}
}
/// <summary>
/// Runs after the Channel selection changes. Drops any Kind selections that fell
/// outside the new visible set — without this, changing the channel could leave
/// stale Kind selections that no longer match any visible option.
/// </summary>
private void OnChannelsChanged()
{
var visible = _model.VisibleKinds().ToHashSet();
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
}
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
private string SiteName(string siteIdentifier)
{
var site = _sites.FirstOrDefault(s =>
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
return site?.Name ?? siteIdentifier;
}
private void ClearFilters()
{
_model.Channels.Clear();
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
_model.SourceNodes.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;
_model.InstanceSearch = string.Empty;
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ParentExecutionId = string.Empty;
_model.ErrorsOnly = false;
}
private async Task Apply()
{
// CentralUI-026: <input type="datetime-local"> binds with DateTimeKind.Unspecified
// — the value is the user's browser-local wall-clock. Tag it as Local then convert
// to UTC before the model emits the filter, otherwise a non-UTC operator's window
// is silently shifted by their UTC offset. Done on a swap-and-restore basis so the
// bound inputs still show the user's local picks on the next render.
var originalFrom = _model.CustomFromUtc;
var originalTo = _model.CustomToUtc;
try
{
_model.CustomFromUtc = LocalInputToUtc(originalFrom);
_model.CustomToUtc = LocalInputToUtc(originalTo);
var now = NowUtcProvider?.Invoke() ?? DateTime.UtcNow;
var filter = _model.ToFilter(now);
await OnFilterChanged.InvokeAsync(filter);
}
finally
{
_model.CustomFromUtc = originalFrom;
_model.CustomToUtc = originalTo;
}
}
/// <summary>
/// Converts a value bound from <c>&lt;input type="datetime-local"&gt;</c> (which Blazor
/// surfaces as <see cref="DateTimeKind.Unspecified"/>) into UTC. The input represents
/// the operator's browser-local wall-clock, so we must tag it <see cref="DateTimeKind.Local"/>
/// before <see cref="DateTime.ToUniversalTime"/> can do anything meaningful.
/// </summary>
private static DateTime? LocalInputToUtc(DateTime? value) =>
value.HasValue
? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
: (DateTime?)null;
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
{
AuditTimeRangePreset.Last5Minutes => "now 5 min → now",
AuditTimeRangePreset.LastHour => "now 1h → now",
AuditTimeRangePreset.Last24Hours => "now 24h → now",
_ => "—",
};
}
@@ -0,0 +1,227 @@
using System.Collections.Immutable;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// UI-side binding model for <see cref="AuditFilterBar"/> (#23 M7-T2).
///
/// <para>
/// The model mirrors <see cref="AuditLogQueryFilter"/> but allows multi-select chip
/// state for Channel / Kind / Status / Site (each a <see cref="HashSet{T}"/>) plus
/// extra UI-only fields the underlying filter does not carry: the Errors-only toggle,
/// the time-range preset, and free-text Instance / Script searches.
/// </para>
///
/// <para>
/// The repository filter contract (<see cref="AuditLogQueryFilter"/>) is multi-value
/// per dimension: the chip multi-selects map straight through to the
/// <c>Channels</c> / <c>Kinds</c> / <c>Statuses</c> / <c>SourceSiteIds</c> filter
/// lists when the model is published via <see cref="ToFilter"/> — an empty set means
/// "do not constrain". Instance and Script free-text remain UI-only: the underlying
/// filter has no matching columns, so they are dropped when the model is published.
/// </para>
///
/// <para>
/// The Errors-only toggle is a convenience: when true AND no explicit Status chips
/// are selected, <see cref="ToFilter"/> targets the full error-status set
/// {<see cref="AuditStatus.Failed"/>, <see cref="AuditStatus.Parked"/>,
/// <see cref="AuditStatus.Discarded"/>}. When Status chips ARE selected the toggle
/// is a no-op — the explicit Status chips win.
/// </para>
/// </summary>
public sealed class AuditQueryModel
{
/// <summary>Selected channel filter chips; empty means all channels.</summary>
public HashSet<AuditChannel> Channels { get; } = new();
/// <summary>Selected kind filter chips; empty means all kinds.</summary>
public HashSet<AuditKind> Kinds { get; } = new();
/// <summary>Selected status filter chips; empty means all statuses.</summary>
public HashSet<AuditStatus> Statuses { get; } = new();
/// <summary>Selected source-site identifier chips; empty means all sites.</summary>
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Selected source-node identifiers (e.g. <c>"central-a"</c>,
/// <c>"site-plant-a-node-a"</c>). Mirrors <see cref="SiteIdentifiers"/> —
/// chip multi-select state, empty = "do not constrain", mapped through to
/// <see cref="AuditLogQueryFilter.SourceNodes"/> by <see cref="ToFilter"/>.
/// </summary>
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Selected time-range preset controlling which historical window is queried.</summary>
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
/// <summary>Custom start of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomFromUtc { get; set; }
/// <summary>Custom end of the time window; used only when <see cref="TimeRange"/> is <see cref="AuditTimeRangePreset.Custom"/>.</summary>
public DateTime? CustomToUtc { get; set; }
/// <summary>Free-text filter applied to instance names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string InstanceSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to script names (UI-only; dropped when converting to <see cref="AuditLogQueryFilter"/>).</summary>
public string ScriptSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the target field (external system / DB name / notification list).</summary>
public string TargetSearch { get; set; } = string.Empty;
/// <summary>Free-text filter applied to the actor field (instance or inbound API key name).</summary>
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;
/// <summary>
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
/// execution's Guid to find every run it spawned. 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, mirroring <see cref="ExecutionId"/>.
/// </summary>
public string ParentExecutionId { get; set; } = string.Empty;
/// <summary>When true and no explicit status chips are selected, the filter targets the full non-success status set.</summary>
public bool ErrorsOnly { get; set; }
/// <summary>
/// Maps each channel to the kinds it can emit (per Component-AuditLog.md §4).
/// <c>CachedSubmit</c> and <c>CachedResolve</c> appear under both
/// <see cref="AuditChannel.ApiOutbound"/> and <see cref="AuditChannel.DbOutbound"/>
/// because the cached-call lifecycle is channel-agnostic at submit/resolve time.
/// Used by the filter bar to narrow the Kind chip list once Channels are picked.
/// </summary>
public static readonly IReadOnlyDictionary<AuditChannel, ImmutableList<AuditKind>> KindsByChannel =
new Dictionary<AuditChannel, ImmutableList<AuditKind>>
{
[AuditChannel.ApiOutbound] = ImmutableList.Create(
AuditKind.ApiCall, AuditKind.ApiCallCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.DbOutbound] = ImmutableList.Create(
AuditKind.DbWrite, AuditKind.DbWriteCached,
AuditKind.CachedSubmit, AuditKind.CachedResolve),
[AuditChannel.Notification] = ImmutableList.Create(
AuditKind.NotifySend, AuditKind.NotifyDeliver),
[AuditChannel.ApiInbound] = ImmutableList.Create(
AuditKind.InboundRequest, AuditKind.InboundAuthFailure),
};
/// <summary>
/// Returns the kinds visible in the Kind chip list given the currently selected
/// Channels. With no Channel selected, all 10 kinds are visible (no narrowing).
/// With one or more Channels selected, the union of the channel-specific kind
/// lists is returned (deduplicated and order-stable on first-seen).
/// </summary>
public IReadOnlyList<AuditKind> VisibleKinds()
{
if (Channels.Count == 0)
{
return Enum.GetValues<AuditKind>();
}
var seen = new HashSet<AuditKind>();
var result = new List<AuditKind>();
foreach (var ch in Channels)
{
if (!KindsByChannel.TryGetValue(ch, out var kinds))
{
continue;
}
foreach (var k in kinds)
{
if (seen.Add(k))
{
result.Add(k);
}
}
}
return result;
}
/// <summary>
/// Publishes this UI model as the repository's multi-value filter: each chip
/// multi-select maps straight through to its filter list (an empty set yields
/// <c>null</c> — "do not constrain"). See class doc for the Errors-only rule.
/// </summary>
/// <param name="utcNow">The current UTC timestamp used to compute relative time-range windows.</param>
/// <returns>A populated <see cref="AuditLogQueryFilter"/> ready for the repository.</returns>
public AuditLogQueryFilter ToFilter(DateTime utcNow)
{
var statuses = ResolveStatuses();
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;
// Same lax-parse contract for the pasted ParentExecutionId.
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
? parsedParentExecutionId
: null;
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
Statuses: statuses,
SourceSiteIds: SiteIdentifiers.Count > 0 ? SiteIdentifiers.ToArray() : null,
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc,
SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
}
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
private static readonly AuditStatus[] ErrorStatuses =
{ AuditStatus.Failed, AuditStatus.Parked, AuditStatus.Discarded };
private IReadOnlyList<AuditStatus>? ResolveStatuses()
{
if (Statuses.Count > 0)
{
// Explicit chips win — Errors-only is a no-op.
return Statuses.ToArray();
}
if (ErrorsOnly)
{
// Multi-value filter: Errors-only targets the full non-success set.
return ErrorStatuses;
}
return null;
}
private (DateTime? From, DateTime? To) ResolveTimeWindow(DateTime utcNow)
{
return TimeRange switch
{
AuditTimeRangePreset.Last5Minutes => (utcNow.AddMinutes(-5), null),
AuditTimeRangePreset.LastHour => (utcNow.AddHours(-1), null),
AuditTimeRangePreset.Last24Hours => (utcNow.AddHours(-24), null),
AuditTimeRangePreset.Custom => (CustomFromUtc, CustomToUtc),
_ => (null, null),
};
}
}
/// <summary>
/// Time-range presets surfaced in the filter bar. <see cref="Custom"/> reveals the
/// FromUtc / ToUtc datetime pickers; the other presets compute From relative to
/// "now" at the moment Apply is clicked.
/// </summary>
public enum AuditTimeRangePreset
{
Last5Minutes,
LastHour,
Last24Hours,
Custom,
}
@@ -0,0 +1,172 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@inject IAuditLogQueryService QueryService
<div data-test="audit-results-grid">
@if (_error is not null)
{
<div class="alert alert-danger small mb-2">@_error</div>
}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle" @ref="_tableRef">
<thead class="table-light">
<tr>
@foreach (var col in OrderedColumns())
{
// @key keeps Blazor reusing one DOM node per column across
// re-renders (reorder/resize), so audit-grid.js binds drag
// listeners exactly once per <th> and never leaks them onto
// discarded nodes — the __auditGridCellBound guard relies on
// this node stability to be fully sound.
<th class="audit-grid-th"
@key="col.Key"
data-test="col-header-@col.Key"
data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)">
@col.Label
<span class="audit-grid-resize-handle"
data-test="col-resize-@col.Key"
aria-hidden="true"></span>
</th>
}
</tr>
</thead>
<tbody>
@if (_rows.Count == 0)
{
<tr>
<td colspan="@OrderedColumns().Count" class="text-muted small text-center py-4">
@if (_loading)
{
<span>Loading…</span>
}
else
{
<span>No audit events match the current filter.</span>
}
</td>
</tr>
}
else
{
@foreach (var row in _rows)
{
<tr @key="row.EventId"
data-test="grid-row-@row.EventId"
class="audit-row"
style="cursor: pointer;"
@onclick="() => HandleRowClick(row)">
@foreach (var col in OrderedColumns())
{
<td class="audit-grid-td" style="@ColumnWidthStyle(col.Key)">
@RenderCell(col.Key, row)
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted small">Page @_pageNumber · @_rows.Count rows</span>
@* CentralUI-032: keyset paging is naturally forward-only, but the
in-component _cursorStack lets the user step back through previous
pages by replaying the prior cursor. The Previous button is gated
on the stack having at least one prior cursor — i.e. we are not on
the first page. *@
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm"
data-test="grid-prev-page"
disabled="@(_loading || !CanGoBack)"
@onclick="PrevPage">Previous page</button>
<button class="btn btn-outline-secondary btn-sm"
data-test="grid-next-page"
disabled="@(_loading || _rows.Count < _pageSize)"
@onclick="NextPage">Next page</button>
</div>
</div>
</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)
{
case "OccurredAtUtc":
var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc));
<span title="@row.OccurredAtUtc.ToString("u")">
<TimestampDisplay Value="occurredOffset" Format="yyyy-MM-dd HH:mm:ss" />
</span>
break;
case "Site":
<span class="small">@(row.SourceSiteId ?? "—")</span>
break;
case "Node":
<span class="small">@(row.SourceNode ?? "—")</span>
break;
case "Channel":
<span class="small">@row.Channel</span>
break;
case "Kind":
<span class="small">@row.Kind</span>
break;
case "Status":
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
break;
case "Target":
<span class="small">@(row.Target ?? "—")</span>
break;
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 "ParentExecutionId":
@if (row.ParentExecutionId is { } parentExecutionId)
{
<span class="small font-monospace"
data-test="parent-execution-id-@row.EventId"
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;
case "HttpStatus":
<span class="small font-monospace">@(row.HttpStatus?.ToString() ?? "—")</span>
break;
case "ErrorMessage":
<span class="small text-danger" title="@row.ErrorMessage">@TruncateError(row.ErrorMessage)</span>
break;
}
};
}
@@ -0,0 +1,497 @@
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// 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 and the
/// ParentExecutionId spawner-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.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// that default the grid layers a per-browser override: drag-to-reorder and
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// widths to <c>sessionStorage</c>, and the grid restores them on first
/// render. A stored order that names an unknown/removed column degrades
/// gracefully — unknown keys are dropped, missing columns appended in default
/// order — so it never throws.
/// </para>
///
/// <para>
/// <b>Pagination.</b> Each page is a single call to
/// <c>IAuditLogQueryService.QueryAsync</c>. The "Next page" button uses the
/// LAST row of the current page as the keyset cursor — repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>, so the oldest row in the visible
/// page becomes <c>AfterOccurredAtUtc</c> + <c>AfterEventId</c> on the next
/// request. The button is disabled when the current page is short (less than
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query.
/// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary>
public partial class AuditResultsGrid : IAsyncDisposable
{
private const int DefaultPageSize = 100;
/// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
private const int MinColumnWidthPx = 64;
/// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
private const string ColumnOrderStorageKey = "columnOrder";
private const string ColumnWidthsStorageKey = "columnWidths";
private readonly List<AuditEvent> _rows = new();
private int _pageNumber = 1;
private bool _loading;
private string? _error;
// CentralUI-032: small in-component stack of prior-page cursors so the user
// can step backwards through results. Each Next push captures the cursor
// that produced the current page (null for page 1) before advancing; each
// Previous pop reloads the page at the recovered cursor. Mirrors the
// SiteCallsReport keyset-paging shape called out in the finding.
private readonly Stack<AuditLogPaging?> _cursorStack = new();
// The cursor that produced the page currently on screen — kept so Next can
// push it before advancing without recomputing it from _rows.
private AuditLogPaging? _currentPaging;
private AuditLogQueryFilter? _activeFilter;
[Inject] private IJSRuntime JS { get; set; } = default!;
private ElementReference _tableRef;
private DotNetObjectReference<AuditResultsGrid>? _selfRef;
// Effective column state. _columnOrder is the live display order (seeded
// from the ColumnOrder parameter / spec default, then overridden by any
// persisted sessionStorage order). _columnWidths holds per-key pixel
// widths from a prior resize; absent keys render at auto width.
private List<string>? _columnOrder;
private readonly Dictionary<string, int> _columnWidths = new();
/// <summary>
/// Filter to apply. When this parameter changes the grid resets to page 1 and
/// reissues the query — that's the contract the parent page relies on so the
/// filter-bar Apply button does not need to drive grid state manually.
/// </summary>
[Parameter] public AuditLogQueryFilter? Filter { get; set; }
/// <summary>Page size. Defaults to 100 to match the service-level default.</summary>
[Parameter] public int PageSize { get; set; } = DefaultPageSize;
/// <summary>
/// Optional column order — list of column keys in display order. When null or
/// empty the default order from Component-AuditLog.md §10 is used. The grid
/// silently drops unknown keys.
/// </summary>
[Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }
/// <summary>
/// Raised when the user clicks a row. Bundle C wires this to the drilldown
/// drawer. The event payload is the full <see cref="AuditEvent"/>.
/// </summary>
[Parameter] public EventCallback<AuditEvent> OnRowSelected { get; set; }
// Effective page size used when paging. Mirrors PageSize but bounded > 0.
private int _pageSize => Math.Max(1, PageSize);
/// <summary>
/// Default column definitions. The key is the stable identifier (used by
/// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10.
/// </summary>
// Label intentionally equals Key for every column today; the separate Label
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
// populating it is a deliberate later change, out of scope here.
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("ParentExecutionId", "ParentExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),
};
private IReadOnlyList<(string Key, string Label)> OrderedColumns()
=> ResolveOrder(_columnOrder ?? ColumnOrder);
/// <summary>
/// Resolves a candidate list of column keys into the concrete display
/// columns. Degrades gracefully so a stale persisted order is never fatal:
/// unknown keys are dropped, and any column not named in the candidate
/// list is appended in its default (spec) position. A null/empty candidate
/// yields the full default order.
/// </summary>
private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
{
if (candidate is null || candidate.Count == 0)
{
return AllColumns;
}
var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
var ordered = new List<(string Key, string Label)>(AllColumns.Count);
var seen = new HashSet<string>();
foreach (var key in candidate)
{
// Drop unknown keys (removed/renamed columns) and any duplicates.
if (byKey.TryGetValue(key, out var col) && seen.Add(key))
{
ordered.Add(col);
}
}
// Append any columns the candidate omitted, in default order, so a
// newly-added column still appears after a restore of an older order.
foreach (var col in AllColumns)
{
if (seen.Add(col.Key))
{
ordered.Add(col);
}
}
return ordered;
}
/// <summary>
/// Inline style for a column's cells: emits the <c>--audit-col-width</c>
/// custom property the scoped stylesheet reads, or an empty string when
/// the column has no persisted width (auto layout).
/// </summary>
private string ColumnWidthStyle(string key)
=> _columnWidths.TryGetValue(key, out var width)
? $"--audit-col-width: {width}px;"
: string.Empty;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Reset & reload whenever the filter reference changes. AuditLogQueryFilter
// is a record, so equality-by-value gives us a free "did the user click Apply
// with the same chips?" no-op signal. We pin to ReferenceEquals here so the
// grid reloads only when the parent hands us a new filter instance — the
// page wraps Apply in a fresh allocation, which is the canonical reload signal.
if (!ReferenceEquals(_activeFilter, Filter))
{
_activeFilter = Filter;
_pageNumber = 1;
_rows.Clear();
_cursorStack.Clear();
_currentPaging = null;
if (Filter is not null)
{
await LoadAsync(paging: null);
}
}
}
private async Task NextPage()
{
if (_rows.Count == 0 || _activeFilter is null)
{
return;
}
var last = _rows[^1];
var cursor = new AuditLogPaging(
PageSize: _pageSize,
AfterOccurredAtUtc: last.OccurredAtUtc,
AfterEventId: last.EventId);
// CentralUI-032: remember the cursor that produced the current page so
// a later Previous can navigate back to it. The page-1 entry is pushed
// as null — LoadAsync treats null as "first page" (PageSize-only).
_cursorStack.Push(_currentPaging);
await LoadAsync(cursor);
_pageNumber++;
}
// CentralUI-032: pops the previous-page cursor off the stack and reloads
// at that position. The pop only happens AFTER a successful reload — a
// failed page-fetch leaves the user on the current page with the error
// banner instead of stranding them between pages.
private async Task PrevPage()
{
if (_cursorStack.Count == 0 || _activeFilter is null)
{
return;
}
var prior = _cursorStack.Peek();
await LoadAsync(prior);
if (_error is null)
{
_cursorStack.Pop();
_pageNumber = Math.Max(1, _pageNumber - 1);
}
}
private bool CanGoBack => _cursorStack.Count > 0;
private async Task LoadAsync(AuditLogPaging? paging)
{
if (_activeFilter is null)
{
return;
}
_loading = true;
_error = null;
try
{
var effective = paging ?? new AuditLogPaging(_pageSize);
var page = await QueryService.QueryAsync(_activeFilter, effective);
_rows.Clear();
_rows.AddRange(page);
// Track the cursor that produced the page now on screen so a later
// Next can push it onto the stack before advancing.
_currentPaging = paging;
}
catch (Exception ex)
{
// Surface the error in-place; the grid stays alive so the user can
// adjust the filter and retry without a page refresh.
_error = $"Query failed: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRowClick(AuditEvent row)
{
if (OnRowSelected.HasDelegate)
{
await OnRowSelected.InvokeAsync(row);
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Restore any persisted order + widths first; the StateHasChanged
// inside triggers a re-render so the restored layout is on screen.
await LoadPersistedStateAsync();
_selfRef = DotNetObjectReference.Create(this);
}
// Wire (or re-wire) the JS drag handlers on every render. auditGrid.init
// is idempotent — already-bound cells are skipped, and the .NET
// reference is refreshed — so a re-render after a reorder still leaves
// every header cell wired without leaking handlers.
//
// OnColumnResized/OnColumnReordered both call StateHasChanged(), which
// re-runs this method and calls init again. That repeat call is an
// intentional cheap no-op: the @key-stable <th> nodes plus the
// __auditGridCellBound guard mean init re-scans the header and rebinds
// nothing — so there is deliberately no gating logic here.
if (_selfRef is not null)
{
try
{
await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
}
catch (JSDisconnectedException)
{
// Circuit gone before init completed — nothing to wire.
}
}
}
/// <summary>
/// Reads the persisted column order + widths from <c>sessionStorage</c> and
/// applies them. A missing, empty, or corrupt payload is treated as "no
/// prior state" — the grid keeps its default order/widths and never throws.
/// </summary>
private async Task LoadPersistedStateAsync()
{
var orderJson = await TryLoadAsync(ColumnOrderStorageKey);
var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey);
var changed = false;
if (!string.IsNullOrEmpty(orderJson))
{
try
{
var stored = JsonSerializer.Deserialize<List<string>>(orderJson);
if (stored is { Count: > 0 })
{
// Normalise through ResolveOrder so a stale key never sticks.
_columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList();
changed = true;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep the default order.
}
}
if (!string.IsNullOrEmpty(widthsJson))
{
try
{
var stored = JsonSerializer.Deserialize<Dictionary<string, int>>(widthsJson);
if (stored is not null)
{
var validKeys = AllColumns.Select(c => c.Key).ToHashSet();
_columnWidths.Clear();
foreach (var (key, width) in stored)
{
// Drop widths for unknown columns; clamp to the minimum.
if (validKeys.Contains(key))
{
_columnWidths[key] = Math.Max(MinColumnWidthPx, width);
}
}
changed = _columnWidths.Count > 0 || changed;
}
}
catch (JsonException)
{
// Corrupt payload — ignore, keep auto widths.
}
}
if (changed)
{
StateHasChanged();
}
}
private async Task<string?> TryLoadAsync(string key)
{
try
{
return await JS.InvokeAsync<string?>("auditGrid.load", key);
}
catch (JSDisconnectedException)
{
return null;
}
}
/// <summary>
/// JS callback: the user finished resizing a column. Persists the new
/// per-column width and re-renders so the body cells track the header.
/// </summary>
/// <param name="columnKey">The stable key of the resized column.</param>
/// <param name="widthPx">The new column width in pixels.</param>
[JSInvokable]
public async Task OnColumnResized(string columnKey, int widthPx)
{
if (!AllColumns.Any(c => c.Key == columnKey))
{
return;
}
_columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
StateHasChanged();
}
/// <summary>
/// JS callback: the user dropped column <paramref name="fromKey"/> onto the
/// header of <paramref name="toKey"/>. Moves the dragged column into the
/// target's slot, persists the resulting order, and re-renders.
/// </summary>
/// <param name="fromKey">The stable key of the column being dragged.</param>
/// <param name="toKey">The stable key of the target column drop slot.</param>
[JSInvokable]
public async Task OnColumnReordered(string fromKey, string toKey)
{
// Start from the current effective order so successive drags compose.
var order = OrderedColumns().Select(c => c.Key).ToList();
var fromIndex = order.IndexOf(fromKey);
var toIndex = order.IndexOf(toKey);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
{
return;
}
order.RemoveAt(fromIndex);
// After the removal the target index shifts left by one when the
// dragged column originally sat before it.
if (fromIndex < toIndex)
{
toIndex--;
}
order.Insert(toIndex, fromKey);
_columnOrder = order;
await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
StateHasChanged();
}
private async Task SaveAsync(string key, string json)
{
try
{
await JS.InvokeVoidAsync("auditGrid.save", key, json);
}
catch (JSDisconnectedException)
{
// Circuit gone — the in-memory state still drives this render.
}
}
/// <summary>
/// Releases the .NET object reference held for JS interop callbacks.
/// </summary>
public ValueTask DisposeAsync()
{
_selfRef?.Dispose();
return ValueTask.CompletedTask;
}
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "badge bg-success",
AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger",
_ => "badge bg-secondary",
};
private static string TruncateError(string? message)
{
if (string.IsNullOrEmpty(message))
{
return "—";
}
const int max = 80;
return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…");
}
}
@@ -0,0 +1,82 @@
/* Audit results grid — column resize + reorder UX (#23 follow-ups Task 10).
The base .table classes come from Bootstrap; the rules below add the
resize-handle affordance and the drag-to-reorder drop feedback. The
interaction itself lives in wwwroot/js/audit-grid.js — this file is purely
the visual treatment. Internal-tool aesthetic: subtle, no flashy motion. */
/* A persisted width is delivered as the --audit-col-width custom property on
the <th> and matching <td> cells (set inline by the component / by
audit-grid.js during a drag). When present it pins the cell; when absent
the column falls back to Bootstrap auto-layout. The body cells also clip
overflowing text so a narrowed column stays tidy. */
.audit-grid-th[style*="--audit-col-width"],
.audit-grid-td[style*="--audit-col-width"] {
width: var(--audit-col-width);
min-width: var(--audit-col-width);
max-width: var(--audit-col-width);
}
.audit-grid-td[style*="--audit-col-width"] {
overflow: hidden;
text-overflow: ellipsis;
}
/* The header cell hosts the resize handle on its right edge, so it must be a
positioning context. Padding on the right is trimmed so the 6px handle does
not crowd the label text. */
.audit-grid-th {
position: relative;
padding-right: 0.75rem;
/* The whole header is draggable for reorder — a grab cursor signals it. */
cursor: grab;
user-select: none;
white-space: nowrap;
}
.audit-grid-th:active {
cursor: grabbing;
}
/* V — resize handle. A thin invisible hit-strip on the right edge: 6px wide
for a comfortable grab target, transparent at rest so the header reads
clean. On hover a hairline primary rule fades in via the inset box-shadow
so the affordance is discoverable without being visually noisy. */
.audit-grid-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
/* Sit above the draggable header so a resize never starts a reorder. */
z-index: 1;
transition: box-shadow 0.08s linear, background-color 0.08s linear;
}
.audit-grid-resize-handle:hover {
/* Hairline rule centred on the strip's right edge. */
box-shadow: inset -2px 0 0 -1px rgba(var(--bs-primary-rgb), 0.55);
background-color: rgba(var(--bs-primary-rgb), 0.06);
}
/* While a drag-resize is in progress the column gets a steady primary rule on
its right edge so the user keeps a clear visual anchor. */
.audit-grid-th.resizing {
box-shadow: inset -2px 0 0 0 var(--bs-primary);
}
.audit-grid-th.resizing .audit-grid-resize-handle {
background-color: rgba(var(--bs-primary-rgb), 0.55);
}
/* V — reorder feedback. The dragged header dims slightly; the prospective
drop target gets a left-edge accent rule + a faint info wash, matching the
TreeView drop-target idiom (a quiet, unmistakable cue, not an animation). */
.audit-grid-th.dragging {
opacity: 0.45;
}
.audit-grid-th.drop-target {
background-color: rgba(var(--bs-info-rgb), 0.18);
box-shadow: inset 2px 0 0 0 var(--bs-info);
}
@@ -0,0 +1,112 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit
@* Execution-Tree Node Detail Modal (Task 3).
Opened from an execution-tree node double-click. Given an ExecutionId it
loads that execution's audit rows and shows a list → per-row detail.
Hand-rolled Bootstrap modal — no bootstrap.bundle.js modal API; visibility
is pure Blazor state (the IsOpen bool) + the d-block/show CSS classes,
mirroring AuditDrilldownDrawer's hand-rolled offcanvas. The per-row detail
body is delegated to the shared <AuditEventDetail>. *@
@if (IsOpen)
{
<div class="modal-backdrop fade show" data-test="execution-detail-backdrop"
@onclick="HandleClose"></div>
<div class="modal fade show d-block execution-detail-modal" tabindex="-1"
data-test="execution-detail-modal" role="dialog"
aria-modal="true" aria-labelledby="execution-detail-modal-title"
@onkeydown="HandleKeyDown">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="text-muted small text-uppercase">Execution</div>
<h5 id="execution-detail-modal-title"
class="modal-title mb-0 d-flex align-items-baseline gap-2">
<span class="font-monospace">Execution @ShortExecutionId()</span>
@if (!_loading && _error is null)
{
<span class="badge rounded-pill text-bg-secondary fw-normal"
data-test="execution-detail-row-count">
@_rows.Count @(_rows.Count == 1 ? "row" : "rows")
</span>
}
</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="execution-detail-close"
@onclick="HandleClose"></button>
</div>
<div class="modal-body small">
@if (_loading)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-loading">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Loading execution rows…
</div>
}
else if (_error is not null)
{
<div class="alert alert-danger mb-0" role="alert"
data-test="execution-detail-error">
@_error
</div>
}
else if (_rows.Count == 0)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-empty">
This execution emitted no audit rows.
</div>
}
else if (_selectedRow is not null)
{
@* Detail view — shared single-row body. *@
@if (_rows.Count > 1)
{
<button type="button"
class="btn btn-link btn-sm px-0 mb-2 execution-detail-back-link"
data-test="execution-detail-back"
@onclick="BackToList">
&larr; Back to rows
</button>
}
<AuditEventDetail Event="_selectedRow" />
}
else
{
@* List view — one button per audit row. *@
<div class="list-group execution-detail-row-list">
@foreach (var row in _rows)
{
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-3"
data-test="execution-detail-row-@row.EventId"
@onclick="() => SelectRow(row)">
<span class="badge @StatusBadgeClass(row.Status) execution-detail-status">
@row.Status
</span>
<span class="execution-detail-kind fw-semibold">@row.Kind</span>
<span class="text-muted text-truncate flex-grow-1">
@(row.Target ?? "—")
</span>
<span class="text-muted font-monospace small flex-shrink-0">
@FormatTime(row.OccurredAtUtc)
</span>
</button>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm"
data-test="execution-detail-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
</div>
</div>
}
@@ -0,0 +1,197 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature,
/// Task 3). Opened from an execution-tree node double-click: given an
/// <see cref="ExecutionId"/> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// <para>
/// <b>Chrome.</b> A hand-rolled Bootstrap modal — visibility is pure Blazor
/// state (<see cref="IsOpen"/>) plus the <c>d-block</c>/<c>show</c> CSS classes
/// and a sibling <c>modal-backdrop</c>, mirroring how
/// <see cref="AuditDrilldownDrawer"/> hand-rolls its offcanvas. No
/// <c>bootstrap.bundle.js</c> modal API is used.
/// </para>
///
/// <para>
/// <b>Load timing.</b> The modal queries only on the closed → open transition
/// (detected in <see cref="OnParametersSetAsync"/>), never on every parameter
/// change, so re-renders while open do not re-hit the service.
/// </para>
///
/// <para>
/// <b>States.</b> Two-or-more rows → list view (one button per row, click sets
/// the selected row); exactly one row → opens straight to the detail view;
/// zero rows → a friendly empty state. A query failure degrades to an inline
/// error banner — it is never rethrown, so a transient DB outage cannot kill
/// the SignalR circuit (the same posture as <c>ExecutionTreePage.LoadChainAsync</c>).
/// The per-row detail body is delegated to the shared <see cref="AuditEventDetail"/>.
/// </para>
/// </summary>
public partial class ExecutionDetailModal
{
[Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!;
/// <summary>
/// The execution whose audit rows the modal loads. When null an open modal
/// loads nothing and shows the empty state — the host is expected to pair a
/// non-null id with <see cref="IsOpen"/>.
/// </summary>
[Parameter] public Guid? ExecutionId { get; set; }
/// <summary>
/// True when the host wants the modal visible. The closed → open transition
/// triggers the row load; see <see cref="OnParametersSetAsync"/>.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the modal (header X, backdrop click, or
/// footer Close). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private bool _loading;
private string? _error;
// Tracks the previous IsOpen so OnParametersSet can detect the open
// transition and load exactly once per open, not on every parameter change.
private bool _wasOpen;
/// <summary>
/// Page size for the execution-row query. One execution's audit rows are
/// few (cached calls top out around 45 rows); 100 comfortably covers a
/// whole execution without paging.
/// </summary>
private const int RowPageSize = 100;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
// Load only on the closed → open transition. A re-render while already
// open (or while closed) must not re-hit the service.
if (IsOpen && !_wasOpen)
{
await LoadRowsAsync();
}
_wasOpen = IsOpen;
}
/// <summary>
/// Loads the current execution's audit rows. On success, a single-row
/// result opens straight to the detail view; otherwise the list view shows.
/// A query failure degrades to an inline error banner and is never
/// rethrown — audit drill-in is best-effort and must not kill the circuit.
/// </summary>
private async Task LoadRowsAsync()
{
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
if (ExecutionId is null)
{
// Nothing to load — fall through to the empty state.
_loading = false;
return;
}
try
{
// No CancellationToken is passed deliberately: this is a bounded,
// small (~100-row) query for one execution, so the IDisposable/CTS
// machinery is not worth it for a modal. The closed → open guard in
// OnParametersSetAsync cleanly re-loads on the next open if needed.
_rows = await AuditLogQueryService.QueryAsync(
new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
new AuditLogPaging(PageSize: RowPageSize));
// A single-row execution opens straight to its detail — there is
// no list to choose from.
if (_rows.Count == 1)
{
_selectedRow = _rows[0];
}
}
catch (Exception ex)
{
// Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_selectedRow = null;
}
finally
{
_loading = false;
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
/// <summary>
/// Closes the modal when Escape is pressed, matching the header X, backdrop
/// click, and footer Close affordances. The root <c>.modal</c> div carries
/// <c>tabindex="-1"</c> so it can receive the keydown.
/// </summary>
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
await HandleClose();
}
}
/// <summary>First 8 hex digits of the execution id, mirroring the UI's short-id convention.</summary>
private string ShortExecutionId()
{
if (ExecutionId is null)
{
return "—";
}
var n = ExecutionId.Value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTime(DateTime occurredAtUtc)
=> occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
/// <summary>
/// Bootstrap badge class for a row's status — green for the success
/// terminal state, red for failure/discard, amber for in-flight. Mirrors
/// the status-badge colouring used by the Audit Log results grid.
/// </summary>
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "text-bg-success",
AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger",
_ => "text-bg-warning",
};
}
@@ -0,0 +1,40 @@
/* Execution-Tree Node Detail Modal (Task 3).
The modal/backdrop base classes come from Bootstrap; this is hand-rolled
(no bootstrap.bundle.js modal API), so the backdrop needs an explicit
stacking context and the dialog a comfortable max width. The per-row detail
body styles travel with AuditEventDetail.razor.css. */
/* Bootstrap's .modal-backdrop sits below .modal by default; with the hand-
rolled approach we render both as siblings, so pin the dialog above it. */
.execution-detail-modal {
z-index: 1055;
}
/* The audit detail body can carry larger JSON/SQL payloads — a slightly wider
dialog than the Bootstrap default keeps those readable. Clamp to the
viewport so narrow windows still get the close button on screen. */
.execution-detail-modal .modal-dialog {
max-width: min(720px, 95vw);
}
/* Row-list buttons: a calm hover lift and a fixed-width status badge so the
Kind / Target columns align down the list. */
.execution-detail-row-list .list-group-item-action {
cursor: pointer;
}
.execution-detail-status {
flex-shrink: 0;
min-width: 5.5rem;
text-align: center;
}
/* Keep the back-to-list affordance quiet — it is navigation chrome, not a
primary action. */
.execution-detail-back-link {
text-decoration: none;
}
.execution-detail-back-link:hover {
text-decoration: underline;
}
@@ -0,0 +1,125 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
list the repository returns; this component assembles it into a tree (joining
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
Recursion is expressed by the component rendering <ExecutionTree> for each
child subtree. To keep that recursion finite even on corrupt/cyclic input,
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
downward via the PreBuiltRoots parameter — child instances never re-run the
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
cycle is broken on first revisit. *@
@if (_rootsToRender.Count == 0)
{
return;
}
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
@foreach (var subtree in _rootsToRender)
{
var node = subtree.Node;
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
var isStub = node.RowCount == 0;
<li class="execution-tree-item" @key="node.ExecutionId">
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
data-test="tree-node-@node.ExecutionId">
@if (subtree.Children.Count > 0)
{
<button type="button"
class="execution-tree-toggle"
data-test="tree-toggle-@node.ExecutionId"
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
@onclick="() => ToggleExpand(node.ExecutionId)">
<span class="execution-tree-toggle-glyph" aria-hidden="true">
@(IsExpanded(node.ExecutionId) ? "" : "+")
</span>
</button>
}
else
{
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
}
<div class="execution-tree-body"
@ondblclick="() => OnNodeActivated.InvokeAsync(node.ExecutionId)">
<div class="execution-tree-headline">
<a class="execution-tree-link font-monospace"
data-test="tree-node-link-@node.ExecutionId"
href="@AuditLogUrl(node.ExecutionId)"
title="Open the Audit Log filtered to execution @node.ExecutionId">
@ShortId(node.ExecutionId)
</a>
@if (isCurrent)
{
<span class="badge text-bg-primary execution-tree-tag"
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
}
@if (isStub)
{
<span class="badge text-bg-secondary execution-tree-tag"
data-test="stub-node-@node.ExecutionId">No audited actions</span>
}
else
{
<span class="execution-tree-rowcount text-muted small"
data-test="tree-rowcount-@node.ExecutionId">
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
</span>
}
</div>
@if (isStub)
{
<div class="execution-tree-meta text-muted small">
Execution with no audited actions — referenced as a parent, but it
emitted no audit rows of its own (or its rows have been purged).
</div>
}
else
{
<div class="execution-tree-meta small">
<span class="execution-tree-meta-item">
<span class="text-muted">Source</span>
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
</span>
@if (node.Channels.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Channels</span>
@string.Join(", ", node.Channels)
</span>
}
@if (node.Statuses.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Statuses</span>
@string.Join(", ", node.Statuses)
</span>
}
<span class="execution-tree-meta-item">
<span class="text-muted">Time span</span>
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
</span>
</div>
}
</div>
</div>
@if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId))
{
@* Recurse: each child subtree is already assembled, so the
nested instance renders directly from PreBuiltRoots and skips
the flat-list assembly entirely. *@
<ExecutionTree PreBuiltRoots="subtree.Children"
ArrivedFromExecutionId="ArrivedFromExecutionId"
OnNodeActivated="OnNodeActivated"
Depth="Depth + 1" />
}
</li>
}
</ul>
@@ -0,0 +1,276 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
/// <summary>
/// Recursive Blazor tree component for the execution-chain view (Audit Log
/// ParentExecutionId feature, Task 10).
///
/// <para>
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
/// root instance (<see cref="Depth"/> == 0) assembles it once in
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
/// and identifies the roots (nodes whose parent is null or not present in the
/// list — a purged/ghost parent). Nested instances skip assembly: the parent
/// hands each child subtree down pre-built via <see cref="PreBuiltRoots"/>.
/// </para>
///
/// <para>
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> values while
/// walking children, so a node is attached to the tree at most once — a cycle
/// (A→B, B→A) is broken at the first revisit and every execution still renders
/// exactly once.
/// </para>
///
/// <para>
/// <b>Presentation.</b> Each node shows the short execution id (a link to
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
/// == 0) is marked "No audited actions". The node the user arrived from
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
/// are expandable; all nodes start expanded so the whole chain is visible.
/// </para>
/// </summary>
public partial class ExecutionTree
{
/// <summary>
/// One assembled subtree: a node plus its already-linked child subtrees.
/// Recursive — children are themselves <see cref="Subtree"/> values.
/// </summary>
/// <param name="Node">The execution this subtree is rooted at.</param>
/// <param name="Children">
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
/// </param>
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
/// <summary>
/// The flat node list to assemble into a tree. Supplied on the ROOT
/// instance only (<see cref="Depth"/> == 0); nested instances receive
/// <see cref="PreBuiltRoots"/> instead.
/// </summary>
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
/// <summary>
/// Pre-assembled child subtrees, threaded down from a parent
/// <see cref="ExecutionTree"/> so nested instances render without
/// re-running the flat-list assembly. Null / unused on the root instance.
/// </summary>
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
/// <summary>
/// The execution the user drilled in from — its node is visually
/// highlighted so the user keeps their bearings within the chain.
/// </summary>
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
/// <summary>
/// Nesting depth. 0 on the root instance (which owns flat-list assembly);
/// each recursive child increments it. Used purely to pick the assembly
/// path and to tag the root <c>&lt;ul&gt;</c> for styling.
/// </summary>
[Parameter] public int Depth { get; set; }
/// <summary>
/// Raised when a node is double-clicked, carrying that node's
/// <see cref="ExecutionTreeNode.ExecutionId"/>. The same callback is
/// threaded unchanged into every recursive child instance, so a
/// double-click on a node at any depth invokes the root-supplied handler
/// (used to open the node detail modal).
/// </summary>
[Parameter] public EventCallback<Guid> OnNodeActivated { get; set; }
// The subtrees this instance renders: assembled from Nodes on the root,
// or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
// The Nodes reference the current _rootsToRender was assembled from. Used
// to skip a redundant re-assembly when OnParametersSet fires for an
// unrelated parameter change (the flat list itself is unchanged).
private IReadOnlyList<ExecutionTreeNode>? _assembledFrom;
// Per-execution expand/collapse state. Absent => expanded (the default):
// the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new();
/// <inheritdoc />
protected override void OnParametersSet()
{
// Nested instance: the parent already assembled our subtrees.
if (Depth > 0)
{
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
return;
}
// Root instance: assemble the flat list into a tree. Re-assemble only
// when the Nodes reference itself changes — OnParametersSet also fires
// for unrelated parameter changes (e.g. ArrivedFromExecutionId), and
// re-running assembly then would needlessly rebuild an identical tree.
if (!ReferenceEquals(Nodes, _assembledFrom))
{
_assembledFrom = Nodes;
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>());
}
}
/// <summary>
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
/// <see cref="Subtree"/> values. There is normally exactly one root (the
/// chain's topmost ancestor); the method returns a list to stay total if
/// the input ever contains disjoint fragments. A fully-cyclic feed has no
/// real root, so each remaining cyclic component is seeded with a fallback
/// root after the main pass — every execution in <paramref name="nodes"/>
/// is therefore placed in the forest exactly once.
/// </summary>
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
{
if (nodes.Count == 0)
{
return Array.Empty<Subtree>();
}
// De-dupe defensively: the repository emits one node per execution, but
// a corrupt feed could repeat an id. First write wins.
var byId = new Dictionary<Guid, ExecutionTreeNode>();
foreach (var node in nodes)
{
byId.TryAdd(node.ExecutionId, node);
}
// Children grouped by parent id. A node whose parent is null or absent
// from the list (a purged/ghost parent) is a root.
var childrenByParent = new Dictionary<Guid, List<ExecutionTreeNode>>();
var roots = new List<ExecutionTreeNode>();
foreach (var node in byId.Values)
{
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
{
if (!childrenByParent.TryGetValue(parentId, out var bucket))
{
bucket = new List<ExecutionTreeNode>();
childrenByParent[parentId] = bucket;
}
bucket.Add(node);
}
else
{
roots.Add(node);
}
}
var visited = new HashSet<Guid>();
var forest = roots
.OrderBy(SortKey)
.Select(root => BuildSubtree(root, childrenByParent, visited))
.ToList();
// Cycle guard: if the input is fully cyclic every node has a present
// parent, so a cyclic component contributes no entry to `roots`. Any
// execution still missing from `visited` after the pass above belongs
// to such a component (a corrupt feed may contain several independent
// cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of
// each remaining component as an extra root and assemble it, looping
// until every node has been placed — so every execution renders.
while (visited.Count < byId.Count)
{
var fallbackRoot = byId.Values
.Where(n => !visited.Contains(n.ExecutionId))
.OrderBy(SortKey)
.First();
forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited));
}
return forest;
}
/// <summary>
/// Recursively builds one <see cref="Subtree"/>, tracking
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
/// recursion — a node already attached is never descended into again.
/// </summary>
private static Subtree BuildSubtree(
ExecutionTreeNode node,
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
HashSet<Guid> visited)
{
visited.Add(node.ExecutionId);
var children = new List<Subtree>();
if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren))
{
foreach (var child in directChildren.OrderBy(SortKey))
{
// Cycle / DAG guard: skip any execution already placed in the
// tree so each renders exactly once and recursion terminates.
if (visited.Contains(child.ExecutionId))
{
continue;
}
children.Add(BuildSubtree(child, childrenByParent, visited));
}
}
return new Subtree(node, children);
}
// Stable child ordering: earliest activity first; stub nodes (null
// timestamp) sort last; ExecutionId breaks ties so rendering is
// deterministic across requests.
private static (DateTime, Guid) SortKey(ExecutionTreeNode node)
=> (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId);
private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId);
private void ToggleExpand(Guid executionId)
{
if (!_collapsed.Remove(executionId))
{
_collapsed.Add(executionId);
}
}
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
private static string AuditLogUrl(Guid executionId)
=> $"/audit/log?executionId={executionId}";
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
private static string ShortId(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
/// <summary>
/// Renders the [first, last] occurrence span. Both null on a stub node
/// (handled by the caller); a single-row execution shows one timestamp.
/// </summary>
private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc)
{
if (firstUtc is null && lastUtc is null)
{
return "—";
}
var first = firstUtc ?? lastUtc!.Value;
var last = lastUtc ?? firstUtc!.Value;
var firstText = Iso(first);
if (first == last)
{
return firstText;
}
return $"{firstText} → {Iso(last)}";
}
// Audit timestamps are UTC by system convention, so the value is formatted
// with a literal 'Z' suffix without re-tagging its DateTimeKind.
private static string Iso(DateTime utc)
=> utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
@@ -0,0 +1,141 @@
/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
Clean, corporate, internal-tool aesthetic — consistent with the Audit Log
grid / drilldown drawer. Bootstrap CSS variables drive every colour so the
tree tracks the active theme. No component framework, no JS for layout. */
.execution-tree {
list-style: none;
margin: 0;
padding: 0;
}
/* Nested lists indent and carry a vertical guide rule that ties children to
their parent — the classic file-tree connector, kept subtle. */
.execution-tree--root {
padding-left: 0;
}
.execution-tree .execution-tree {
margin-left: 0.75rem;
padding-left: 1rem;
border-left: 1px solid var(--bs-border-color);
}
.execution-tree-item {
position: relative;
}
/* The node card: a flex row of [toggle][body].
user-select: none — the body is double-clickable (opens the node detail
modal), so suppress the text selection a double-click would otherwise
leave behind. */
.execution-tree-node {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
margin: 0.25rem 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.375rem;
background-color: var(--bs-body-bg);
user-select: none;
}
/* The execution the user drilled in from — a left accent rule + tinted
background so it stands out without shouting. */
.execution-tree-node--current {
border-color: var(--bs-primary-border-subtle);
background-color: var(--bs-primary-bg-subtle);
box-shadow: inset 3px 0 0 0 var(--bs-primary);
}
/* Stub node — an execution with no audited actions. Muted + dashed border so
it reads as a placeholder rather than a real audited execution. */
.execution-tree-node--stub {
border-style: dashed;
background-color: var(--bs-tertiary-bg);
}
/* Expand / collapse control. A small square that mirrors the table-light
header tone used elsewhere on the Audit pages. */
.execution-tree-toggle {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
margin-top: 0.0625rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background-color: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
line-height: 1;
cursor: pointer;
}
.execution-tree-toggle:hover {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
.execution-tree-toggle--leaf {
border-color: transparent;
background-color: transparent;
cursor: default;
}
.execution-tree-toggle-glyph {
font-size: 0.875rem;
font-weight: 600;
}
.execution-tree-body {
flex: 1 1 auto;
min-width: 0;
}
/* Headline row: short id link, tags, row count. */
.execution-tree-headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.execution-tree-link {
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
}
.execution-tree-link:hover {
text-decoration: underline;
}
.execution-tree-tag {
font-weight: 500;
font-size: 0.6875rem;
}
.execution-tree-rowcount {
margin-left: auto;
}
/* Meta row: source / channels / statuses / time span, pipe-separated visually
via spacing rather than literal separators. */
.execution-tree-meta {
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
color: var(--bs-body-color);
}
.execution-tree-meta-item .text-muted {
margin-right: 0.25rem;
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.02em;
}