From d5623e98bd221cec4dfb5712dafc06d422b3f858 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 01:13:11 -0400 Subject: [PATCH 1/9] docs(centralui): execution-tree node detail modal design --- ...-05-22-execution-tree-node-modal-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/plans/2026-05-22-execution-tree-node-modal-design.md diff --git a/docs/plans/2026-05-22-execution-tree-node-modal-design.md b/docs/plans/2026-05-22-execution-tree-node-modal-design.md new file mode 100644 index 0000000..4ce989b --- /dev/null +++ b/docs/plans/2026-05-22-execution-tree-node-modal-design.md @@ -0,0 +1,91 @@ +# Execution-Tree Node Detail Modal (Design) + +**Date:** 2026-05-22 +**Status:** Validated — ready for implementation planning. + +## Problem + +On the Central UI execution-chain tree page (`/audit/execution-tree`, the +`ParentExecutionId` feature's Task 10), each node represents one execution and +shows a small inline summary. The only interaction is the short-id link, which +navigates away to `/audit/log?executionId=…`. There is no way to inspect an +execution's actual audit rows without leaving the tree. + +## Decision + +Double-clicking a tree node opens a **modal** showing that execution's audit +rows. The modal mirrors the `/audit/log` detail experience: a list of the +execution's rows, and clicking a row reveals that row's full field/payload +detail — the exact content the Audit Log drilldown drawer shows. + +Resolved during brainstorming: +- **Modal content** — the execution's audit rows, with per-row full detail. +- **Multi-row executions** — list the rows; clicking one shows its detail. A + single-row execution opens straight to the detail view. +- **Trigger** — double-click anywhere on the node. The short-id link keeps its + single-click navigation to the Audit Log grid (unchanged). + +### Considered and rejected + +- **Reuse `AuditDrilldownDrawer` directly.** The drawer renders one + `AuditEvent` by design; bending it into a list-or-detail hybrid is more + invasive to a well-tested component than a purpose-built modal. +- **Inline expansion under the node.** The user asked for a modal, and an + inline panel inside the recursive tree fights the existing expand/collapse + toggle and is visually messy. + +## Components + +| Component | Change | +|---|---| +| `AuditEventDetail.razor` | **New.** The single-`AuditEvent` field/payload/drill-in-button block, extracted verbatim from `AuditDrilldownDrawer`'s body. | +| `AuditDrilldownDrawer.razor` | **Modified.** Keeps its offcanvas chrome + close button; its body becomes ``. The one refactor with regression risk — existing drawer bUnit + Playwright tests guard it. | +| `ExecutionDetailModal.razor` (+ `.razor.cs` + `.razor.css`) | **New.** A custom Bootstrap modal — hand-rolled `modal` / `modal-backdrop` markup, Blazor-toggled, no component framework (the same way `AuditDrilldownDrawer` hand-rolls `offcanvas`). | +| `ExecutionTree.razor` / `.razor.cs` | **Modified.** `@ondblclick` on the node body invokes a new `OnNodeActivated` `EventCallback`; recursive child instances re-raise it upward so the event bubbles to the root. | +| `ExecutionTreePage.razor` / `.razor.cs` | **Modified.** Hosts one `ExecutionDetailModal`; wires the tree's `OnNodeActivated` to open it. | + +No database, repository, or service changes — purely Central UI. The +`IAuditLogQueryService.QueryAsync` method already filters by `ExecutionId`; the +modal reuses it (no new service method). + +## Data flow + +1. Double-click a node → `ExecutionTree` invokes `OnNodeActivated(node.ExecutionId)`. +2. The event bubbles up the recursive `ExecutionTree` instances to + `ExecutionTreePage`. +3. The page opens `ExecutionDetailModal` with the `ExecutionId`. +4. The modal calls `IAuditLogQueryService.QueryAsync(new AuditLogQueryFilter(ExecutionId: id), new AuditLogPaging(PageSize: 100))` → `IReadOnlyList`. +5. Render by row count: + - **≥ 2 rows** — a compact row list (kind / status / target / time, each row a button); clicking a row swaps to its `` with a "← Back to rows" control. + - **1 row** — opens straight to the detail view. + - **0 rows** — a stub execution; a friendly empty state. +6. Close via the X button, the backdrop, or Esc. + +The list rows are full `AuditEvent` objects (that is what `QueryAsync` returns), +so the list→detail transition needs no second fetch. + +## Error handling + +- A `QueryAsync` failure surfaces an inline error inside the modal ("Couldn't + load this execution's rows") and never tears down the SignalR circuit — + mirroring the tree page's existing `try/catch` degrade-gracefully pattern. +- An empty result renders the friendly empty state, not an error. + +## Testing + +- **bUnit** — `ExecutionTree` raises `OnNodeActivated` on `@ondblclick` and + bubbles it through a nested instance; `ExecutionDetailModal` list renders from + a fake query service, row click → detail, 1-row jump-straight, 0-row empty + state, close; `AuditEventDetail` renders the field block; the existing + `AuditDrilldownDrawer` tests stay green after the body extraction. +- **Playwright** — on `/audit/execution-tree`, double-click a node → modal opens + → (multi-row) row list → click a row → detail → close. Uses a seeded chain. +- `frontend-design` skill for the modal markup/CSS — clean corporate aesthetic, + custom Blazor + Bootstrap, no component frameworks. + +## Constraints + +- Central UI only — no DB / repository / service-contract changes. +- Custom Blazor + Bootstrap; no component frameworks. +- The short-id link's single-click navigation to `/audit/log?executionId=…` is + unchanged. From fd07654c685b8c239cf72c8afdaa7c8869690bd2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 01:15:53 -0400 Subject: [PATCH 2/9] docs(centralui): execution-tree node modal implementation plan + task tracking --- .../2026-05-22-execution-tree-node-modal.md | 110 ++++++++++++++++++ ...22-execution-tree-node-modal.md.tasks.json | 12 ++ 2 files changed, 122 insertions(+) create mode 100644 docs/plans/2026-05-22-execution-tree-node-modal.md create mode 100644 docs/plans/2026-05-22-execution-tree-node-modal.md.tasks.json diff --git a/docs/plans/2026-05-22-execution-tree-node-modal.md b/docs/plans/2026-05-22-execution-tree-node-modal.md new file mode 100644 index 0000000..9eaa82f --- /dev/null +++ b/docs/plans/2026-05-22-execution-tree-node-modal.md @@ -0,0 +1,110 @@ +# Execution-Tree Node Detail Modal — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review). + +**Goal:** Double-clicking a node on the `/audit/execution-tree` page opens a modal listing that execution's audit rows; clicking a row shows its full detail — the same content the `/audit/log` drilldown drawer renders. + +**Architecture:** Extract the drawer's single-`AuditEvent` body into a shared `AuditEventDetail` component reused by both the drawer and a new `ExecutionDetailModal`. The `ExecutionTree` node gains a double-click that raises an `EventCallback` bubbling up the recursive instances to `ExecutionTreePage`, which hosts the modal. The modal fetches the execution's rows via the existing `IAuditLogQueryService.QueryAsync` (filter by `ExecutionId`) — no DB / repository / service-contract change. Validated design: `docs/plans/2026-05-22-execution-tree-node-modal-design.md`. + +**Tech Stack:** .NET 10, Blazor Server + Bootstrap (custom components, no component frameworks), xUnit + bUnit, Playwright. + +**Ground rules (every task):** branch is `feature/execution-tree-node-modal` (already created) — never commit to `main`. TDD — failing test first, then minimal implementation. Edit in place; never touch `infra/*` or `alog.md`; `docker/*` only if a task says so (none do). Stage with explicit `git add ` — never `git add .` / `commit -am`. Full solution stays green: `dotnet build ScadaLink.slnx` 0 warnings (`TreatWarningsAsErrors` on); `dotnet test tests/ScadaLink.CentralUI.Tests` for touched UI work. Use the `frontend-design` skill for new markup/CSS. Do not push. + +--- + +## Task 0: Prep — verify branch + baseline + +**Files:** none. + +**Steps:** confirm `git branch --show-current` is `feature/execution-tree-node-modal`; `dotnet build ScadaLink.slnx` succeeds with 0 warnings. + +**Acceptance:** on the branch, solution builds clean. + +--- + +## Task 1: Extract `AuditEventDetail` from `AuditDrilldownDrawer` + +**What:** Pull the drawer's single-`AuditEvent` body — the read-only field list, the Error/Request/Response/Extra sections, and the action buttons (Copy as cURL, Show all events, View this/parent execution, View execution chain) — into a new reusable component. The drawer keeps only its offcanvas chrome (header, the two Close buttons) and delegates its body to the new component. This is a pure refactor — no behaviour change. + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor` (+ `.razor.cs`, + `.razor.css` if body-specific styles move). +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` — the `offcanvas-body` content + the action buttons in `drawer-footer` become ``. The drawer keeps the offcanvas backdrop/header, `ShortEventId`, the `drawer-close` / `drawer-close-footer` Close buttons. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.cs` — move the body/action members to `AuditEventDetail.razor.cs`: `IsApiChannel`, `FormatTimestamp`, `IsRedacted`, `RenderBody`, `BuildSqlParameterRows`, `TryPrettyPrintJson`, `PrettyPrintJson`, `TryParseDbBody`, `StringifyJsonValue`, the `RedactionSentinel`/`RedactorErrorSentinel` consts, `CopyCurl`, `ShowAllForOperation`, `ViewThisExecution`, `ViewParentExecution`, `ViewExecutionChain`, `BuildCurlCommand`, `TryExtractCurlPartsFromJson`, `QuoteShellArg`, and the `[Inject] IJSRuntime JS` + `[Inject] NavigationManager Navigation`. The drawer keeps `Event`, `IsOpen`, `OnClose`, `ShortEventId`, `HandleClose`. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor.css` — move body-specific rules (e.g. `drawer-pre`) into `AuditEventDetail.razor.css` (Blazor scoped CSS follows the markup). Keep the `drawer-pre` class name to minimise churn. +- Test: create `tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditEventDetailTests.cs` — render `AuditEventDetail` directly; assert the field block (`data-test="field-..."`), the Error/Request/Response/Extra sections, the redaction badge, and the action buttons render for representative events. + +**Approach:** the markup moves verbatim — every existing `data-test` attribute (`drawer-fields`, `field-*`, `section-error`, `request-body`, `copy-as-curl`, `view-parent-execution`, …) must keep its exact value so the existing `AuditDrilldownDrawerTests` bUnit suite and the `/audit/log` Playwright drawer tests still pass unchanged (they render the drawer, which now contains the child — the selectors still resolve). `AuditEventDetail` takes a non-null `[Parameter] AuditEvent Event`. + +**Verify:** `dotnet build ScadaLink.slnx` (0 warnings); `dotnet test tests/ScadaLink.CentralUI.Tests` — the existing `AuditDrilldownDrawerTests` MUST still pass. + +**Commit:** `refactor(centralui): extract AuditEventDetail from AuditDrilldownDrawer` + +--- + +## Task 2: `ExecutionTree` — double-click raises `OnNodeActivated` + +**What:** A double-click anywhere on a tree node raises an `EventCallback` carrying the node's `ExecutionId`; the callback bubbles up the recursive `ExecutionTree` instances to the root. + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs` — add `[Parameter] public EventCallback OnNodeActivated { get; set; }`. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor` — add `@ondblclick="() => OnNodeActivated.InvokeAsync(node.ExecutionId)"` to the `execution-tree-body` div (NOT the `execution-tree-toggle` button, which keeps its own `@onclick`). Pass the callback straight down on the recursive child: `` — threaded unchanged at every depth, so a deep node's double-click invokes the same root-supplied callback. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css` — add `user-select: none` to `.execution-tree-node` so a double-click does not leave an awkward text selection. +- Test: extend `tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs` — `DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId`; `DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot` (a multi-level tree, double-click a child/grandchild node, assert the root callback fires with the right id). + +**Approach:** the short-id `` link keeps its single-click navigation untouched — double-clicking the link itself still navigates (acceptable; the link is a small target and the design keeps it as the explicit "go to grid" affordance). The double-click handler lives on the node body so double-clicking the meta area / row-count opens the modal. + +**Commit:** `feat(centralui): ExecutionTree node double-click raises OnNodeActivated` + +--- + +## Task 3: `ExecutionDetailModal` component + +**What:** A custom Bootstrap modal that, given an `ExecutionId`, loads that execution's audit rows and shows a list → per-row detail. + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor` (+ `.razor.cs` + `.razor.css`). +- Parameters / DI: `[Parameter] Guid? ExecutionId`, `[Parameter] bool IsOpen`, `[Parameter] EventCallback OnClose`; `[Inject] IAuditLogQueryService`. +- Behaviour: when `IsOpen` flips true with a non-null `ExecutionId`, call `QueryAsync(new AuditLogQueryFilter(ExecutionId: ExecutionId.Value), new AuditLogPaging(PageSize: 100))`. Internal state: `_rows` (`IReadOnlyList`), `_selectedRow` (`AuditEvent?` — null = list view), `_loading`, `_error`. + - `_rows.Count >= 2` → list view: each row a ` - } - @if (Event.CorrelationId is not null) - { - - } - @if (Event.ExecutionId is not null) - { - - } - @if (Event.ParentExecutionId is not null) - { - - } - @if (Event.ExecutionId is not null) - { - - } + } + @if (Event.CorrelationId is not null) + { + + } + @if (Event.ExecutionId is not null) + { + + } + @if (Event.ParentExecutionId is not null) + { + + } + @if (Event.ExecutionId is not null) + { + + } + 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);
+    }
+}

From 603995d43a388517f759bc71b224540670467a2a Mon Sep 17 00:00:00 2001
From: Joseph Doherty 
Date: Fri, 22 May 2026 01:32:37 -0400
Subject: [PATCH 4/9] feat(centralui): ExecutionTree node double-click raises
 OnNodeActivated

---
 .../Components/Audit/ExecutionTree.razor      |  4 +-
 .../Components/Audit/ExecutionTree.razor.cs   |  9 +++
 .../Components/Audit/ExecutionTree.razor.css  |  6 +-
 .../Components/Audit/ExecutionTreeTests.cs    | 57 +++++++++++++++++++
 4 files changed, 74 insertions(+), 2 deletions(-)

diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
index eb017a3..cc0c365 100644
--- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
+++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
@@ -45,7 +45,8 @@
                     
                 }
 
-                
+
} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs index c415d48..fc773f7 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs @@ -78,6 +78,15 @@ public partial class ExecutionTree /// [Parameter] public int Depth { get; set; } + /// + /// Raised when a node is double-clicked, carrying that node's + /// . The same callback is + /// threaded unchanged into every recursive child instance, so a + /// double-click on a node at any depth invokes the root-supplied handler + /// (used to open the node detail modal). + /// + [Parameter] public EventCallback OnNodeActivated { get; set; } + // The subtrees this instance renders: assembled from Nodes on the root, // or taken straight from PreBuiltRoots on a nested instance. private IReadOnlyList _rootsToRender = Array.Empty(); diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css index 8f483a7..db6acab 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css @@ -25,7 +25,10 @@ position: relative; } -/* The node card: a flex row of [toggle][body]. */ +/* The node card: a flex row of [toggle][body]. + user-select: none — the body is double-clickable (opens the node detail + modal), so suppress the text selection a double-click would otherwise + leave behind. */ .execution-tree-node { display: flex; align-items: flex-start; @@ -35,6 +38,7 @@ border: 1px solid var(--bs-border-color); border-radius: 0.375rem; background-color: var(--bs-body-bg); + user-select: none; } /* The execution the user drilled in from — a left accent rule + tinted diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs index 43b5825..88d31a8 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -227,6 +227,63 @@ public class ExecutionTreeTests : BunitContext cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded")); } + [Fact] + public void DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId() + { + // Double-clicking a node's body raises OnNodeActivated carrying that + // node's ExecutionId — the affordance a later task uses to open the + // node detail modal. + var root = Guid.Parse("aaaaaaaa-4444-4444-4444-444444444444"); + var child = Guid.Parse("bbbbbbbb-4444-4444-4444-444444444444"); + var nodes = new List + { + Node(root, null), + Node(child, root), + }; + + Guid? activated = null; + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, root) + .Add(c => c.OnNodeActivated, (Guid id) => activated = id)); + + var rootBody = cut.Find($"[data-test=\"tree-node-{root}\"] .execution-tree-body"); + rootBody.DoubleClick(); + + Assert.Equal(root, activated); + } + + [Fact] + public void DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot() + { + // root → child → grandchild. Double-clicking a deeply nested node's + // body invokes the SAME root-supplied callback — the EventCallback is + // threaded unchanged down every recursive ExecutionTree instance. + var root = Guid.Parse("aaaaaaaa-5555-5555-5555-555555555555"); + var child = Guid.Parse("bbbbbbbb-5555-5555-5555-555555555555"); + var grandchild = Guid.Parse("cccccccc-5555-5555-5555-555555555555"); + var nodes = new List + { + Node(root, null), + Node(child, root), + Node(grandchild, child), + }; + + Guid? activated = null; + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, root) + .Add(c => c.OnNodeActivated, (Guid id) => activated = id)); + + // Double-click the grandchild (two recursion levels deep). + cut.Find($"[data-test=\"tree-node-{grandchild}\"] .execution-tree-body").DoubleClick(); + Assert.Equal(grandchild, activated); + + // And the child (one level deep) — both reach the root's callback. + cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick(); + Assert.Equal(child, activated); + } + private static int CountOccurrences(string haystack, string needle) { int count = 0, idx = 0; From 386cd0b95510b6d0defdc6d1fd18432437ea5452 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 01:39:04 -0400 Subject: [PATCH 5/9] =?UTF-8?q?feat(centralui):=20ExecutionDetailModal=20?= =?UTF-8?q?=E2=80=94=20execution=20rows=20with=20per-row=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Audit/ExecutionDetailModal.razor | 109 +++++++ .../Audit/ExecutionDetailModal.razor.cs | 178 +++++++++++ .../Audit/ExecutionDetailModal.razor.css | 40 +++ .../Audit/ExecutionDetailModalTests.cs | 282 ++++++++++++++++++ 4 files changed, 609 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor new file mode 100644 index 0000000..55a5f58 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor @@ -0,0 +1,109 @@ +@using ScadaLink.Commons.Entities.Audit + +@* Execution-Tree Node Detail Modal (Task 3). + Opened from an execution-tree node double-click. Given an ExecutionId it + loads that execution's audit rows and shows a list → per-row detail. + Hand-rolled Bootstrap modal — no bootstrap.bundle.js modal API; visibility + is pure Blazor state (the IsOpen bool) + the d-block/show CSS classes, + mirroring AuditDrilldownDrawer's hand-rolled offcanvas. The per-row detail + body is delegated to the shared . *@ + +@if (IsOpen) +{ + + +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs new file mode 100644 index 0000000..8fdd70c --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature, +/// Task 3). Opened from an execution-tree node double-click: given an +/// it loads that execution's audit rows via +/// and shows a list → per-row detail. +/// +/// +/// Chrome. A hand-rolled Bootstrap modal — visibility is pure Blazor +/// state () plus the d-block/show CSS classes +/// and a sibling modal-backdrop, mirroring how +/// hand-rolls its offcanvas. No +/// bootstrap.bundle.js modal API is used. +/// +/// +/// +/// Load timing. The modal queries only on the closed → open transition +/// (detected in ), never on every parameter +/// change, so re-renders while open do not re-hit the service. +/// +/// +/// +/// States. Two-or-more rows → list view (one button per row, click sets +/// the selected row); exactly one row → opens straight to the detail view; +/// zero rows → a friendly empty state. A query failure degrades to an inline +/// error banner — it is never rethrown, so a transient DB outage cannot kill +/// the SignalR circuit (the same posture as ExecutionTreePage.LoadChainAsync). +/// The per-row detail body is delegated to the shared . +/// +/// +public partial class ExecutionDetailModal +{ + [Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!; + + /// + /// The execution whose audit rows the modal loads. When null an open modal + /// loads nothing and shows the empty state — the host is expected to pair a + /// non-null id with . + /// + [Parameter] public Guid? ExecutionId { get; set; } + + /// + /// True when the host wants the modal visible. The closed → open transition + /// triggers the row load; see . + /// + [Parameter] public bool IsOpen { get; set; } + + /// + /// Fired when the user dismisses the modal (header X, backdrop click, or + /// footer Close). The host is expected to flip to false. + /// + [Parameter] public EventCallback OnClose { get; set; } + + // The loaded rows for the current execution; empty until a load completes. + private IReadOnlyList _rows = Array.Empty(); + + // The row whose detail is shown; null = list view. + private AuditEvent? _selectedRow; + + private bool _loading; + private string? _error; + + // Tracks the previous IsOpen so OnParametersSet can detect the open + // transition and load exactly once per open, not on every parameter change. + private bool _wasOpen; + + /// + /// Page size for the execution-row query. One execution's audit rows are + /// few (cached calls top out around 4–5 rows); 100 comfortably covers a + /// whole execution without paging. + /// + private const int RowPageSize = 100; + + protected override async Task OnParametersSetAsync() + { + // Load only on the closed → open transition. A re-render while already + // open (or while closed) must not re-hit the service. + if (IsOpen && !_wasOpen) + { + await LoadRowsAsync(); + } + _wasOpen = IsOpen; + } + + /// + /// Loads the current execution's audit rows. On success, a single-row + /// result opens straight to the detail view; otherwise the list view shows. + /// A query failure degrades to an inline error banner and is never + /// rethrown — audit drill-in is best-effort and must not kill the circuit. + /// + private async Task LoadRowsAsync() + { + _loading = true; + _error = null; + _selectedRow = null; + _rows = Array.Empty(); + + if (ExecutionId is null) + { + // Nothing to load — fall through to the empty state. + _loading = false; + return; + } + + try + { + _rows = await AuditLogQueryService.QueryAsync( + new AuditLogQueryFilter(ExecutionId: ExecutionId.Value), + new AuditLogPaging(PageSize: RowPageSize)); + + // A single-row execution opens straight to its detail — there is + // no list to choose from. + if (_rows.Count == 1) + { + _selectedRow = _rows[0]; + } + } + catch (Exception ex) + { + // Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage + // degrades the modal to an inline error banner rather than killing + // the SignalR circuit. Never rethrow. + _error = $"Could not load this execution's audit rows: {ex.Message}"; + _rows = Array.Empty(); + _selectedRow = null; + } + finally + { + _loading = false; + } + } + + private void SelectRow(AuditEvent row) => _selectedRow = row; + + private void BackToList() => _selectedRow = null; + + private async Task HandleClose() + { + if (OnClose.HasDelegate) + { + await OnClose.InvokeAsync(); + } + } + + /// First 8 hex digits of the execution id, mirroring the UI's short-id convention. + private string ShortExecutionId() + { + if (ExecutionId is null) + { + return "—"; + } + var n = ExecutionId.Value.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + + private static string FormatTime(DateTime occurredAtUtc) + => occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture); + + /// + /// Bootstrap badge class for a row's status — green for the success + /// terminal state, red for failure/discard, amber for in-flight. Mirrors + /// the status-badge colouring used by the Audit Log results grid. + /// + private static string StatusBadgeClass(AuditStatus status) => status switch + { + AuditStatus.Delivered => "text-bg-success", + AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger", + _ => "text-bg-warning", + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css new file mode 100644 index 0000000..6879eaa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.css @@ -0,0 +1,40 @@ +/* Execution-Tree Node Detail Modal (Task 3). + The modal/backdrop base classes come from Bootstrap; this is hand-rolled + (no bootstrap.bundle.js modal API), so the backdrop needs an explicit + stacking context and the dialog a comfortable max width. The per-row detail + body styles travel with AuditEventDetail.razor.css. */ + +/* Bootstrap's .modal-backdrop sits below .modal by default; with the hand- + rolled approach we render both as siblings, so pin the dialog above it. */ +.execution-detail-modal { + z-index: 1055; +} + +/* The audit detail body can carry larger JSON/SQL payloads — a slightly wider + dialog than the Bootstrap default keeps those readable. Clamp to the + viewport so narrow windows still get the close button on screen. */ +.execution-detail-modal .modal-dialog { + max-width: min(720px, 95vw); +} + +/* Row-list buttons: a calm hover lift and a fixed-width status badge so the + Kind / Target columns align down the list. */ +.execution-detail-row-list .list-group-item-action { + cursor: pointer; +} + +.execution-detail-status { + flex-shrink: 0; + min-width: 5.5rem; + text-align: center; +} + +/* Keep the back-to-list affordance quiet — it is navigation chrome, not a + primary action. */ +.execution-detail-back-link { + text-decoration: none; +} + +.execution-detail-back-link:hover { + text-decoration: underline; +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs new file mode 100644 index 0000000..e783d5e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs @@ -0,0 +1,282 @@ +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (Execution-Tree Node Detail +/// Modal, Task 3). The modal opens on an execution-tree node double-click: given +/// an ExecutionId it loads that execution's audit rows via +/// and shows a list → per-row detail. +/// +/// Tests pin the behaviours the spec cannot lose: load-on-open-transition, +/// the four data states (multi-row list, single-row straight-to-detail, +/// zero-row empty, query-failure error), and that closing raises OnClose. +/// +public class ExecutionDetailModalTests : BunitContext +{ + private readonly IAuditLogQueryService _service; + private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); + + public ExecutionDetailModalTests() + { + _service = Substitute.For(); + _service.DefaultPageSize.Returns(100); + Services.AddSingleton(_service); + + // AuditEventDetail (the per-row detail body) owns a clipboard interop + // call. Loose mode lets that no-op for tests that don't exercise it. + JSInterop.Mode = JSRuntimeMode.Loose; + } + + private static AuditEvent MakeEvent( + Guid executionId, + AuditStatus status = AuditStatus.Delivered, + AuditChannel channel = AuditChannel.ApiOutbound, + AuditKind kind = AuditKind.ApiCall, + string? target = "demo-target") + => new() + { + EventId = Guid.NewGuid(), + OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc), + Channel = channel, + Kind = kind, + Status = status, + ExecutionId = executionId, + SourceSiteId = "plant-a", + Target = target, + Actor = "tester", + DurationMs = 42, + HttpStatus = status == AuditStatus.Delivered ? 200 : 500, + }; + + private void StubRows(IReadOnlyList rows) + { + _service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + _calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1])); + return Task.FromResult(rows); + }); + } + + [Fact] + public void ClosedModal_RendersNothing_AndDoesNotQuery() + { + StubRows(new[] { MakeEvent(Guid.NewGuid()) }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, Guid.NewGuid()) + .Add(c => c.IsOpen, false)); + + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")); + Assert.Empty(_calls); + } + + [Fact] + public void OpenTransition_QueriesByExecutionId_WithPageSize100() + { + var executionId = Guid.Parse("11111111-2222-3333-4444-555555555555"); + StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, false)); + + // Closed on first render — no query yet. + Assert.Empty(_calls); + + // Flip open: the modal loads exactly once for the open transition. + cut.Render(p => p.Add(c => c.IsOpen, true)); + + Assert.Single(_calls); + Assert.Equal(executionId, _calls[0].Filter.ExecutionId); + Assert.NotNull(_calls[0].Paging); + Assert.Equal(100, _calls[0].Paging!.PageSize); + } + + [Fact] + public void StillOpen_NonOpenParameterChange_DoesNotRequery() + { + var executionId = Guid.NewGuid(); + StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + Assert.Single(_calls); + + // A parameter set that does NOT flip IsOpen must not re-query. + cut.Render(p => p.Add(c => c.IsOpen, true)); + + Assert.Single(_calls); + } + + [Fact] + public void MultiRow_RendersListView_WithOneButtonPerRow() + { + var executionId = Guid.NewGuid(); + var rowA = MakeEvent(executionId, AuditStatus.Delivered); + var rowB = MakeEvent(executionId, AuditStatus.Failed); + StubRows(new[] { rowA, rowB }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + // List view: a row button per audit row, keyed by EventId. + Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]")); + Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]")); + // Not in detail view yet — no shared detail body rendered. + Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]")); + } + + [Fact] + public void MultiRow_ClickRow_ShowsAuditEventDetail() + { + var executionId = Guid.NewGuid(); + var rowA = MakeEvent(executionId, AuditStatus.Delivered); + var rowB = MakeEvent(executionId, AuditStatus.Failed); + StubRows(new[] { rowA, rowB }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]").Click(); + + // The shared AuditEventDetail body is now rendered (its field list). + Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]")); + // And a Back control to return to the list. + Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]")); + } + + [Fact] + public void MultiRow_BackControl_ReturnsToList() + { + var executionId = Guid.NewGuid(); + var rowA = MakeEvent(executionId, AuditStatus.Delivered); + var rowB = MakeEvent(executionId, AuditStatus.Failed); + StubRows(new[] { rowA, rowB }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]").Click(); + Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]")); + + cut.Find("[data-test=\"execution-detail-back\"]").Click(); + + // Back in the list view: row buttons present, detail body gone. + Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]")); + Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]")); + } + + [Fact] + public void SingleRow_OpensStraightToDetail_NoBackControl() + { + var executionId = Guid.NewGuid(); + var only = MakeEvent(executionId); + StubRows(new[] { only }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + // Straight to detail — the shared body is rendered without a click. + Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]")); + // Nothing to go back to: the Back control is hidden for a single row. + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]")); + } + + [Fact] + public void ZeroRow_ShowsFriendlyEmptyState() + { + var executionId = Guid.NewGuid(); + StubRows(Array.Empty()); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + var empty = cut.Find("[data-test=\"execution-detail-empty\"]"); + Assert.Contains("This execution emitted no audit rows.", empty.TextContent); + } + + [Fact] + public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow() + { + var executionId = Guid.NewGuid(); + _service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => throw new InvalidOperationException("db is down")); + + // Rendering with IsOpen=true must not throw — the modal degrades to an + // inline error banner rather than killing the SignalR circuit. + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + var error = cut.Find("[data-test=\"execution-detail-error\"]"); + Assert.Contains("db is down", error.TextContent); + } + + [Fact] + public void CloseButton_RaisesOnClose() + { + var executionId = Guid.NewGuid(); + StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) }); + + var closed = false; + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true) + .Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true))); + + cut.Find("[data-test=\"execution-detail-close\"]").Click(); + + Assert.True(closed); + } + + [Fact] + public void BackdropClick_RaisesOnClose() + { + var executionId = Guid.NewGuid(); + StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) }); + + var closed = false; + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true) + .Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true))); + + cut.Find("[data-test=\"execution-detail-backdrop\"]").Click(); + + Assert.True(closed); + } + + [Fact] + public void Header_ShowsShortExecutionId_AndRowCount() + { + var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555"); + StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId), MakeEvent(executionId) }); + + var cut = Render(p => p + .Add(c => c.ExecutionId, executionId) + .Add(c => c.IsOpen, true)); + + var modal = cut.Find("[data-test=\"execution-detail-modal\"]"); + // Short id (first 8 hex of the "N" form) appears in the header. + Assert.Contains("abcdef01", modal.TextContent); + // Row count surfaces in the header chrome. + Assert.Contains("3", modal.TextContent); + } +} From 5c86983ef6680352da97d86a35ee4f2a903df4b4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 22 May 2026 01:43:41 -0400 Subject: [PATCH 6/9] fix(centralui): Esc-to-close and aria attributes on ExecutionDetailModal --- .../Audit/ExecutionDetailModal.razor | 7 +++++-- .../Audit/ExecutionDetailModal.razor.cs | 18 ++++++++++++++++++ .../Audit/ExecutionDetailModalTests.cs | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor index 55a5f58..75909e0 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor @@ -13,13 +13,16 @@