diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor index 15cac07..235b5b6 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor @@ -3,9 +3,9 @@ @* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8). Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page. - All form/field rendering follows the form-layout memory: - read-only fields first (definition list), then subsections stacked, - action buttons at the bottom of the drawer. *@ + The drawer owns only the offcanvas chrome (backdrop, header, Close buttons); + the single-AuditEvent detail body is delegated to , which + is shared with the execution-tree node-detail modal. *@ @if (IsOpen && Event is not null) { @@ -26,161 +26,12 @@
- @* Read-only field list — primary identification + provenance. *@ -
-
Channel / Kind
-
@Event.Channel / @Event.Kind
- -
Status
-
@Event.Status
- -
HttpStatus
-
@(Event.HttpStatus?.ToString() ?? "—")
- -
Target
-
@(Event.Target ?? "—")
- -
Actor
-
@(Event.Actor ?? "—")
- -
SourceSiteId
-
@(Event.SourceSiteId ?? "—")
- -
SourceInstanceId
-
@(Event.SourceInstanceId ?? "—")
- -
SourceScript
-
@(Event.SourceScript ?? "—")
- -
CorrelationId
-
@(Event.CorrelationId?.ToString() ?? "—")
- -
ExecutionId
-
@(Event.ExecutionId?.ToString() ?? "—")
- -
ParentExecutionId
-
@(Event.ParentExecutionId?.ToString() ?? "—")
- -
OccurredAtUtc
-
@FormatTimestamp(Event.OccurredAtUtc)
- -
IngestedAtUtc
-
@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")
- -
DurationMs
-
@(Event.DurationMs?.ToString() ?? "—")
-
- - @* Error subsection — only shown when there is something to report. *@ - @if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail)) - { -
-
Error
- @if (!string.IsNullOrEmpty(Event.ErrorMessage)) - { -

@Event.ErrorMessage

- } - @if (!string.IsNullOrEmpty(Event.ErrorDetail)) - { -
@Event.ErrorDetail
- } -
- } - - @* Request body (channel-aware renderer). *@ - @if (!string.IsNullOrEmpty(Event.RequestSummary)) - { -
-
- Request - @if (IsRedacted(Event.RequestSummary)) - { - - Redacted - - } -
-
- @RenderBody(Event.RequestSummary!, Event.Channel) -
-
- } - - @* Response body (channel-aware renderer). *@ - @if (!string.IsNullOrEmpty(Event.ResponseSummary)) - { -
-
- Response - @if (IsRedacted(Event.ResponseSummary)) - { - - Redacted - - } -
-
- @RenderBody(Event.ResponseSummary!, Event.Channel) -
-
- } - - @* Extra is always JSON when present. *@ - @if (!string.IsNullOrEmpty(Event.Extra)) - { -
-
Extra
-
@PrettyPrintJson(Event.Extra!)
-
- } + @* Single-row detail body + action buttons — shared component. *@ +
- @* Action buttons at the bottom per form-layout memory. *@ + @* Close button kept at the bottom per form-layout memory. *@ diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.cs new file mode 100644 index 0000000..1f02957 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.cs @@ -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; + +/// +/// Reusable single- detail body (#23 M7 Bundle C / +/// M7-T4..T8). Extracted verbatim from 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. +/// +/// +/// Body rendering. Request/Response/Extra summaries are strings. +/// JSON is pretty-printed when it parses; falls back to verbatim otherwise. +/// DbOutbound payloads carry a {sql, parameters} JSON shape and get a +/// SQL code block plus a parameter definition list. Syntax highlighting is +/// CSS-class-only (language-sql); no JS library is loaded — Blazor +/// Server + Bootstrap only per the project's UI rules. +/// +/// +/// +/// Redaction badges. The audit pipeline replaces redacted values +/// with the literal sentinels <redacted> or +/// <redacted: redactor error> (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. +/// +/// +/// +/// Copy as cURL. Best-effort: the URL comes from Target; +/// when the RequestSummary parses as {headers, body}, headers are +/// folded into -H flags and the body into --data-raw. The +/// command is written to the system clipboard via +/// . The button +/// is only surfaced for API channels (ApiOutbound / ApiInbound). +/// +/// +/// +/// Drill-back. When is set, +/// the "Show all events" button navigates to +/// /audit/log?correlationId={id}. Likewise, when +/// is set the "View this execution" +/// button navigates to /audit/log?executionId={id}. Likewise, when +/// is set the "View parent +/// execution" button navigates to /audit/log?executionId={parentId} +/// — 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. +/// +/// +public partial class AuditEventDetail +{ + [Inject] private IJSRuntime JS { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + + /// + /// The row to render. Required and non-null — the host (drawer or modal) + /// only mounts this component once it has a row to show. + /// + [Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!; + + private const string RedactionSentinel = ""; + private const string RedactorErrorSentinel = ""; + + 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); + } + + /// + /// Channel-aware body renderer. DbOutbound bodies that parse as + /// {sql, parameters} get a SQL block + parameter list; anything + /// else falls back to JSON-pretty-print, then plain-text verbatim. + /// + 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
 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> 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;
+
+    /// 
+    /// Best-effort parse of a DbOutbound {sql, parameters} body.
+    /// Returns true only when the JSON has a string sql property;
+    /// parameters is treated as an optional object whose values
+    /// stringify to scalar text.
+    /// 
+    private static bool TryParseDbBody(string text, out string sql, out List>? 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>();
+                foreach (var p in paramsProp.EnumerateObject())
+                {
+                    parameters.Add(new KeyValuePair(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);
+    }
+
+    /// 
+    /// Drill-in to every audit row sharing this row's 
+    /// — the universal per-run correlation value, distinct from the per-operation
+    /// CorrelationId drill-back above. Navigates to /audit/log?executionId={id},
+    /// which the page parses on init and auto-loads. The button is only rendered
+    /// when  is non-null, so this is total.
+    /// 
+    private void ViewThisExecution()
+    {
+        if (Event.ExecutionId is not { } exec) return;
+        var uri = $"/audit/log?executionId={exec}";
+        Navigation.NavigateTo(uri);
+    }
+
+    /// 
+    /// Drill-in to the spawner execution: a routed (child) row carries a non-null
+    /// . Navigates to
+    /// /audit/log?executionId={ParentExecutionId} so the user sees the
+    /// spawner execution's own rows — the parent's id becomes the ?executionId=
+    /// drill-in target. The button is only rendered when
+    ///  is non-null, so this is total.
+    /// 
+    private void ViewParentExecution()
+    {
+        if (Event.ParentExecutionId is not { } parentExec) return;
+        var uri = $"/audit/log?executionId={parentExec}";
+        Navigation.NavigateTo(uri);
+    }
+
+    /// 
+    /// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
+    /// feature, Task 10). Navigates to
+    /// /audit/execution-tree?executionId={ExecutionId} — 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  is non-null, so this
+    /// is total.
+    /// 
+    private void ViewExecutionChain()
+    {
+        if (Event.ExecutionId is not { } exec) return;
+        var uri = $"/audit/execution-tree?executionId={exec}";
+        Navigation.NavigateTo(uri);
+    }
+
+    /// 
+    /// Build a cURL command from an audit event. The URL comes from
+    /// Target; when the RequestSummary parses as
+    /// {headers, body, method?}, headers fold into -H flags
+    /// and the body into --data-raw. Default method is POST for
+    /// outbound audit rows — the audit pipeline does not always capture
+    /// the verb explicitly.
+    /// 
+    private static string BuildCurlCommand(AuditEvent ev)
+    {
+        var sb = new StringBuilder();
+        sb.Append("curl");
+
+        string method = "POST";
+        List>? 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>? 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>();
+                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(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.
+        }
+    }
+
+    /// 
+    /// Quote a single shell argument with single quotes, escaping embedded
+    /// single quotes via the standard '\'' idiom. This is the same
+    /// quoting strategy curl examples use across man pages.
+    /// 
+    private static string QuoteShellArg(string value)
+    {
+        if (string.IsNullOrEmpty(value)) return "''";
+        var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
+        return $"'{escaped}'";
+    }
+}
diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.css b/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.css
new file mode 100644
index 0000000..f93d454
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor.css
@@ -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);
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs
new file mode 100644
index 0000000..f4b48e9
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs
@@ -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;
+
+/// 
+/// bUnit tests for  — the reusable single-row
+/// detail body extracted from  (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 data-test values must match the originals so the
+/// existing  selectors keep resolving.
+/// 
+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(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(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(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(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(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(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\":\"\"},\"body\":\"hello\"}");
+
+        var cut = Render(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(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(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(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(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(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(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(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(p => p.Add(c => c.Event, ev));
+
+        cut.Find("[data-test=\"show-all-events\"]").Click();
+
+        var nav = (BunitNavigationManager)Services.GetRequiredService();
+        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(p => p.Add(c => c.Event, ev));
+
+        cut.Find("[data-test=\"view-execution-chain\"]").Click();
+
+        var nav = (BunitNavigationManager)Services.GetRequiredService();
+        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(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);
+    }
+}