refactor(centralui): extract AuditEventDetail from AuditDrilldownDrawer
This commit is contained in:
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
||||||
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
|
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
|
||||||
All form/field rendering follows the form-layout memory:
|
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
|
||||||
read-only fields first (definition list), then subsections stacked,
|
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
|
||||||
action buttons at the bottom of the drawer. *@
|
is shared with the execution-tree node-detail modal. *@
|
||||||
|
|
||||||
@if (IsOpen && Event is not null)
|
@if (IsOpen && Event is not null)
|
||||||
{
|
{
|
||||||
@@ -26,161 +26,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-body small">
|
<div class="offcanvas-body small">
|
||||||
@* Read-only field list — primary identification + provenance. *@
|
@* Single-row detail body + action buttons — shared component. *@
|
||||||
<dl class="row mb-3" data-test="drawer-fields">
|
<AuditEventDetail Event="Event" />
|
||||||
<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">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>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Action buttons at the bottom per form-layout memory. *@
|
@* Close button kept at the bottom per form-layout memory. *@
|
||||||
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
|
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
|
||||||
@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>
|
|
||||||
}
|
|
||||||
<button class="btn btn-primary btn-sm ms-auto"
|
<button class="btn btn-primary btn-sm ms-auto"
|
||||||
data-test="drawer-close-footer"
|
data-test="drawer-close-footer"
|
||||||
@onclick="HandleClose">
|
@onclick="HandleClose">
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Components.Audit;
|
namespace ScadaLink.CentralUI.Components.Audit;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
/// 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:
|
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
|
||||||
/// read-only fields, conditional Error/Request/Response/Extra subsections,
|
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
||||||
/// and action buttons (Copy as cURL, Show all events for this operation,
|
/// Close buttons; the single-row detail body (read-only fields, conditional
|
||||||
/// Close). The drawer is fully presentational — it has no DB or service
|
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
||||||
/// dependencies; the host page owns the open/close state.
|
/// to <see cref="AuditEventDetail"/>, which is shared with the execution-tree
|
||||||
///
|
/// node-detail modal so a row's detail renders identically in either host.
|
||||||
/// <para>
|
/// The drawer is fully presentational — it has no DB or service dependencies;
|
||||||
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
|
/// the host page owns the open/close state.
|
||||||
/// The drawer pretty-prints JSON 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><redacted></c> or
|
|
||||||
/// <c><redacted: redactor error></c> (see Component-AuditLog.md
|
|
||||||
/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body
|
|
||||||
/// section when its text contains either sentinel — it does not attempt
|
|
||||||
/// to un-redact or count occurrences.
|
|
||||||
/// </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?[])"/>. We only
|
|
||||||
/// surface the button 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>
|
/// </summary>
|
||||||
public partial class AuditDrilldownDrawer
|
public partial class AuditDrilldownDrawer
|
||||||
{
|
{
|
||||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
|
||||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The row to render. When null the drawer renders nothing — the host
|
/// The row to render. When null the drawer renders nothing — the host
|
||||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||||
@@ -81,12 +36,6 @@ public partial class AuditDrilldownDrawer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
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 ShortEventId(Guid eventId)
|
private static string ShortEventId(Guid eventId)
|
||||||
{
|
{
|
||||||
// Mirror the "first 8 hex digits" presentation common across the UI.
|
// Mirror the "first 8 hex digits" presentation common across the UI.
|
||||||
@@ -94,159 +43,6 @@ public partial class AuditDrilldownDrawer
|
|||||||
return n.Length >= 8 ? n[..8] : n;
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 HandleClose()
|
private async Task HandleClose()
|
||||||
{
|
{
|
||||||
if (OnClose.HasDelegate)
|
if (OnClose.HasDelegate)
|
||||||
@@ -254,170 +50,4 @@ public partial class AuditDrilldownDrawer
|
|||||||
await OnClose.InvokeAsync();
|
await OnClose.InvokeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CopyCurl()
|
|
||||||
{
|
|
||||||
if (Event is null) return;
|
|
||||||
|
|
||||||
var curl = BuildCurlCommand(Event);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Clipboard interop can fail (denied permission, prerender, etc.).
|
|
||||||
// The drawer stays open; 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}'";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Audit Log drilldown drawer (#23 M7 Bundle C).
|
/* Audit Log drilldown drawer (#23 M7 Bundle C).
|
||||||
The base offcanvas + backdrop classes come from Bootstrap. The local
|
The base offcanvas + backdrop classes come from Bootstrap. The local
|
||||||
overrides below pin our preferred width and pre-block behaviour. */
|
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 {
|
.audit-drilldown-drawer {
|
||||||
/* Slightly wider than the parked-messages drawer because audit rows can
|
/* Slightly wider than the parked-messages drawer because audit rows can
|
||||||
@@ -9,32 +10,6 @@
|
|||||||
width: min(720px, 95vw);
|
width: min(720px, 95vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-pre {
|
|
||||||
/* Wrap long lines and bound the per-block height so the drawer 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-footer {
|
.audit-drilldown-drawer .drawer-footer {
|
||||||
background-color: var(--bs-tertiary-bg);
|
background-color: var(--bs-tertiary-bg);
|
||||||
}
|
}
|
||||||
|
|||||||
165
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
165
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
@using ScadaLink.Commons.Entities.Audit
|
||||||
|
@using ScadaLink.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">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 ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.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><redacted></c> or
|
||||||
|
/// <c><redacted: redactor error></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,282 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Bunit.TestDoubles;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ScadaLink.CentralUI.Components.Audit;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// bUnit tests for <see cref="AuditEventDetail"/> — the reusable single-row
|
||||||
|
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> (Task 1 of the
|
||||||
|
/// Execution-Tree Node Detail Modal feature).
|
||||||
|
///
|
||||||
|
/// These tests render the detail component directly (not via the drawer) and
|
||||||
|
/// pin the contract the drawer — and any future modal host — relies on:
|
||||||
|
/// the read-only field block, the conditional Error/Request/Response/Extra
|
||||||
|
/// sections, the redaction badge, channel-aware body rendering, and the
|
||||||
|
/// action buttons. All <c>data-test</c> values must match the originals so the
|
||||||
|
/// existing <see cref="AuditDrilldownDrawer"/> selectors keep resolving.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditEventDetailTests : BunitContext
|
||||||
|
{
|
||||||
|
public AuditEventDetailTests()
|
||||||
|
{
|
||||||
|
// Loose so the cURL clipboard call does not blow up tests that do not
|
||||||
|
// exercise it. The clipboard test flips to Strict itself.
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent MakeEvent(
|
||||||
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
|
string? requestSummary = null,
|
||||||
|
string? responseSummary = null,
|
||||||
|
string? extra = null,
|
||||||
|
Guid? correlationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
Guid? parentExecutionId = null,
|
||||||
|
string? errorMessage = null,
|
||||||
|
string? errorDetail = null,
|
||||||
|
string? target = "demo-target")
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
|
||||||
|
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
|
||||||
|
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
|
||||||
|
Channel = channel,
|
||||||
|
Kind = kind,
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
ExecutionId = executionId,
|
||||||
|
ParentExecutionId = parentExecutionId,
|
||||||
|
SourceSiteId = "plant-a",
|
||||||
|
SourceInstanceId = "boiler-3",
|
||||||
|
SourceScript = "OnAlarm.csx",
|
||||||
|
Actor = "tester",
|
||||||
|
Target = target,
|
||||||
|
Status = status,
|
||||||
|
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
|
||||||
|
DurationMs = 42,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
ErrorDetail = errorDetail,
|
||||||
|
RequestSummary = requestSummary,
|
||||||
|
ResponseSummary = responseSummary,
|
||||||
|
Extra = extra,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RendersFieldBlock()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent();
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"drawer-fields\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"field-Channel\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"field-Status\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
|
||||||
|
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorSection_RendersWhenErrorPresent()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(
|
||||||
|
status: AuditStatus.Parked,
|
||||||
|
errorMessage: "boom",
|
||||||
|
errorDetail: "stack trace here");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"section-error\"", cut.Markup);
|
||||||
|
Assert.Contains("boom", cut.Markup);
|
||||||
|
Assert.Contains("stack trace here", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorSection_HiddenWhenNoError()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent();
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RequestSection_PrettyPrintsJson()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"section-request\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"request-body\"", cut.Markup);
|
||||||
|
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResponseSection_RendersWhenPresent()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(responseSummary: "{\"ok\":true}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"section-response\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"response-body\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtraSection_RendersWhenPresent()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(extra: "{\"note\":\"hi\"}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"section-extra\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RedactedBody_ShowsRedactionBadge()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonRedactedBody_HidesRedactionBadge()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DbOutboundChannel_RendersSqlBlock()
|
||||||
|
{
|
||||||
|
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
|
||||||
|
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("language-sql", cut.Markup);
|
||||||
|
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"sql-parameters\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiChannel_ShowsCopyAsCurlButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonApiChannel_HidesCopyAsCurlButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NullCorrelationId_HidesShowAllButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(correlationId: null);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonNullCorrelationId_ShowsShowAllButton()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(correlationId: Guid.NewGuid());
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"show-all-events\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExecutionButtons_ConditionalOnExecutionIds()
|
||||||
|
{
|
||||||
|
var ev = MakeEvent(
|
||||||
|
executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"),
|
||||||
|
parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
|
||||||
|
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
|
||||||
|
{
|
||||||
|
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||||
|
var ev = MakeEvent(correlationId: corr);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
cut.Find("[data-test=\"show-all-events\"]").Click();
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
|
||||||
|
{
|
||||||
|
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||||
|
var ev = MakeEvent(executionId: exec);
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
cut.Find("[data-test=\"view-execution-chain\"]").Click();
|
||||||
|
|
||||||
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||||
|
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||||
|
{
|
||||||
|
JSInterop.Mode = JSRuntimeMode.Strict;
|
||||||
|
var clipboardCall = JSInterop.SetupVoid(
|
||||||
|
"navigator.clipboard.writeText",
|
||||||
|
invocation => invocation.Arguments.Count == 1
|
||||||
|
&& invocation.Arguments[0] is string s
|
||||||
|
&& s.StartsWith("curl ", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var ev = MakeEvent(
|
||||||
|
channel: AuditChannel.ApiOutbound,
|
||||||
|
target: "https://example.test/api/v1/widgets",
|
||||||
|
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
|
||||||
|
|
||||||
|
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
|
||||||
|
|
||||||
|
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
|
||||||
|
|
||||||
|
var calls = clipboardCall.Invocations.ToList();
|
||||||
|
Assert.NotEmpty(calls);
|
||||||
|
var argString = (string)calls[0].Arguments[0]!;
|
||||||
|
Assert.StartsWith("curl ", argString);
|
||||||
|
Assert.Contains("https://example.test/api/v1/widgets", argString);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user