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. 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/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor
new file mode 100644
index 0000000..f2005b1
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor
@@ -0,0 +1,112 @@
+@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..43d1094
--- /dev/null
+++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionDetailModal.razor.cs
@@ -0,0 +1,196 @@
+using System.Globalization;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Web;
+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
+        {
+            // No CancellationToken is passed deliberately: this is a bounded,
+            // small (~100-row) query for one execution, so the IDisposable/CTS
+            // machinery is not worth it for a modal. The closed → open guard in
+            // OnParametersSetAsync cleanly re-loads on the next open if needed.
+            _rows = await AuditLogQueryService.QueryAsync(
+                new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
+                new AuditLogPaging(PageSize: RowPageSize));
+
+            // A single-row execution opens straight to its detail — there is
+            // no list to choose from.
+            if (_rows.Count == 1)
+            {
+                _selectedRow = _rows[0];
+            }
+        }
+        catch (Exception ex)
+        {
+            // Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
+            // degrades the modal to an inline error banner rather than killing
+            // the SignalR circuit. Never rethrow.
+            _error = $"Could not load this execution's audit rows: {ex.Message}";
+            _rows = Array.Empty();
+            _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();
+        }
+    }
+
+    /// 
+    /// Closes the modal when Escape is pressed, matching the header X, backdrop
+    /// click, and footer Close affordances. The root .modal div carries
+    /// tabindex="-1" so it can receive the keydown.
+    /// 
+    private async Task HandleKeyDown(KeyboardEventArgs e)
+    {
+        if (e.Key == "Escape")
+        {
+            await HandleClose();
+        }
+    }
+
+    /// 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/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/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor index 1883faa..8bedc93 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor @@ -52,7 +52,14 @@ View this execution in the Audit Log
- + + + @* Double-clicking a tree node raises OnNodeActivated, which opens this + modal for that execution. The modal renders nothing while IsOpen is + false, so it is safe to place unconditionally here. *@ + } else { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs index 84ac269..b760939 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs @@ -40,6 +40,15 @@ public partial class ExecutionTreePage private bool _loading; private string? _error; + // Execution-Tree Node Detail Modal feature (Task 4) — state backing the + // . A double-click on a tree node sets + // _modalExecutionId + flips _modalOpen true; the modal loads that + // execution's audit rows on the closed → open transition. _modalOpen is the + // visibility gate — _modalExecutionId is left intact across a close (it is + // harmless while the modal is hidden and avoids a flicker if reopened). + private Guid? _modalExecutionId; + private bool _modalOpen; + protected override async Task OnInitializedAsync() { _executionId = ParseExecutionId(); @@ -90,4 +99,22 @@ public partial class ExecutionTreePage _loading = false; } } + + /// + /// Raised by ExecutionTree (bubbled up from a node double-click) with + /// the activated node's ExecutionId. Opens the + /// ExecutionDetailModal for that execution — the modal loads its + /// audit rows on the closed → open transition. + /// + private void HandleNodeActivated(Guid executionId) + { + _modalExecutionId = executionId; + _modalOpen = true; + } + + /// + /// Raised by ExecutionDetailModal when the user dismisses it. Flips + /// the visibility gate closed; is left as-is. + /// + private void HandleModalClose() => _modalOpen = false; } diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index d3e77c8..2f511bd 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -31,6 +31,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit; /// drawer's "View parent execution" action on a spawned (child) row drills in /// to ?executionId={ParentExecutionId}, auto-loading the spawner's /// rows. +/// DoubleClickTreeNode_OpensExecutionRowModal — double-clicking a +/// node on the execution-tree page opens ExecutionDetailModal, walking +/// list → row → detail before closing. /// NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist — /// the report page wires drill-in links when notifications are present. /// ExportCsv_LinkIsVisibleAndDownloads — Export CSV button gated on @@ -516,6 +519,110 @@ public class AuditLogPageTests } } + [Fact] + public async Task DoubleClickTreeNode_OpensExecutionRowModal() + { + // Execution-Tree Node Detail Modal feature, Task 5: double-clicking a + // node on the /audit/execution-tree page opens ExecutionDetailModal — + // a modal listing that execution's audit rows, with click-through to + // each row's full view. We seed ONE execution with + // TWO audit rows (so the modal opens to the list view, not straight to + // a single-row detail), open the tree, double-click the node, walk + // list → row → detail, then close the modal. + if (!await AuditDataSeeder.IsAvailableAsync()) + { + throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions."); + } + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/exec-node-modal/{runId}/"; + var executionId = Guid.NewGuid(); + var inboundEventId = Guid.NewGuid(); + var outboundEventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // Two rows sharing the same ExecutionId — an inbound request and an + // outbound call it made. The shared ExecutionId makes the tree node + // multi-row, so the modal lands on the list view. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: inboundEventId, + occurredAtUtc: now, + channel: "ApiInbound", + kind: "InboundRequest", + status: "Delivered", + target: targetPrefix + "inbound", + executionId: executionId, + httpStatus: 200, + durationMs: 9); + + await AuditDataSeeder.InsertAuditEventAsync( + eventId: outboundEventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "outbound", + executionId: executionId, + httpStatus: 200, + durationMs: 21); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // Open the execution tree directly for the seeded execution. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/execution-tree?executionId={executionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The seeded execution renders as a tree node. + var nodeBody = page.Locator($"[data-test='tree-node-{executionId}'] .execution-tree-body"); + await Assertions.Expect(nodeBody).ToBeVisibleAsync(); + + // Double-clicking the node body raises ExecutionTree's @ondblclick, + // which is a Blazor Server (InteractiveServer) handler — it only + // fires once the SignalR circuit is live. NetworkIdle can settle + // before the circuit connects, so a single early DblClick can be + // dropped. Retry the double-click until the modal appears. + var modal = page.Locator("[data-test='execution-detail-modal']"); + for (var attempt = 0; attempt < 10 && await modal.CountAsync() == 0; attempt++) + { + await nodeBody.DblClickAsync(); + try + { + await modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 1000 }); + } + catch (TimeoutException) + { + // Circuit not connected yet — loop and re-issue the dblclick. + } + } + + await Assertions.Expect(modal).ToBeVisibleAsync(); + + // The modal opens on the list view — one button per audit row. + var inboundRow = page.Locator($"[data-test='execution-detail-row-{inboundEventId}']"); + var outboundRow = page.Locator($"[data-test='execution-detail-row-{outboundEventId}']"); + await Assertions.Expect(inboundRow).ToBeVisibleAsync(); + await Assertions.Expect(outboundRow).ToBeVisibleAsync(); + + // Clicking a row switches the modal to that row's full detail — + // the shared field block renders. + await outboundRow.ClickAsync(); + await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync(); + + // Closing the modal tears it down. The close click round-trips + // over the SignalR circuit before the @if(IsOpen) block re-renders + // away, so use the auto-retrying assertion rather than a bare + // CountAsync. + await page.Locator("[data-test='execution-detail-close']").ClickAsync(); + await Assertions.Expect(modal).ToHaveCountAsync(0); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 78608cd..b741d90 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -12,15 +12,15 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit; /// bUnit tests for (#23 M7 Bundle C / M7-T4..T8). /// /// The drawer is a child component opened from the Audit Log page when a grid row -/// is clicked. It renders the full read-only, with -/// channel-aware bodies (JSON pretty-print, SQL block for DbOutbound), -/// redaction badges on Request/Response, and conditional action buttons: -/// "Copy as cURL" (API channels only) + "Show all events for this operation" -/// (when CorrelationId is set). +/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates +/// the body to the shared +/// component, which since the recent refactor owns the channel-aware bodies +/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on +/// Request/Response, and conditional action buttons. /// /// Tests pin the behaviours we cannot lose without breaking the spec: -/// field rendering, JSON pretty-printing, SQL render block, conditional button -/// visibility, navigation drill-back, redaction badges, and clipboard interop. +/// offcanvas open/close, header rendering, and that the event body is handed +/// off to . /// public class AuditDrilldownDrawerTests : BunitContext { 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); + } +} 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..3bf96fa --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs @@ -0,0 +1,299 @@ +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 EscapeKey_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-modal\"]").KeyDown("Escape"); + + 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); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs index 43b5825..cbd1167 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -11,8 +11,10 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit; /// into a tree by joining to a /// parent node's , and renders it /// recursively. Tests pin: single-node tree, multi-level assembly, stub-node -/// presentation, the arrived-from highlight, node-click navigation, and -/// cycle-safety (a corrupt flat list must not infinite-loop). +/// presentation, the arrived-from highlight, node-click navigation, node +/// double-click raising/bubbling via +/// OnNodeActivated, and cycle-safety (a corrupt flat list must not +/// infinite-loop). /// public class ExecutionTreeTests : BunitContext { @@ -227,6 +229,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; diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs index c69d10b..16b29c3 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -7,7 +7,9 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; using ScadaLink.Security; using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage; @@ -111,6 +113,68 @@ public class ExecutionTreePageTests : BunitContext _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); } + [Fact] + public void DoubleClickTreeNode_OpensExecutionDetailModal() + { + var root = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var child = Guid.Parse("44444444-4444-4444-4444-444444444444"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + // The modal loads the double-clicked execution's audit rows on open. + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + // AuditEventDetail (reachable from the modal) owns a clipboard interop call. + JSInterop.Mode = JSRuntimeMode.Loose; + + var cut = RenderPage($"executionId={child}", "Admin"); + + // The modal is absent until a node is activated. + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")); + + var body = cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body"); + body.DoubleClick(); + + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + _queryService.Received().QueryAsync( + Arg.Is(f => f.ExecutionId == child), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void ClosingExecutionDetailModal_HidesIt() + { + var root = Guid.Parse("55555555-5555-5555-5555-555555555555"); + var child = Guid.Parse("66666666-6666-6666-6666-666666666666"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + JSInterop.Mode = JSRuntimeMode.Loose; + + var cut = RenderPage($"executionId={child}", "Admin"); + + cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick(); + cut.WaitForAssertion(() => + Assert.NotEmpty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + + cut.Find("[data-test=\"execution-detail-close\"]").Click(); + + cut.WaitForAssertion(() => + Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"))); + } + [Fact] public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute() {