Merge branch 'feature/execution-tree-node-modal': execution-tree node detail modal

This commit is contained in:
Joseph Doherty
2026-05-22 02:06:02 -04:00
24 changed files with 2040 additions and 572 deletions

View File

@@ -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 `<AuditEventDetail Event="Event" />`. 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<Guid>`; 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<AuditEvent>`.
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 `<AuditEventDetail>` 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.

View File

@@ -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<Guid>` 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 <path>` — 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 `<AuditEventDetail Event="Event" />`. 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<Guid>` 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<Guid> 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: `<ExecutionTree ... OnNodeActivated="OnNodeActivated" />` — 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 `<a>` 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<AuditEvent>`), `_selectedRow` (`AuditEvent?` — null = list view), `_loading`, `_error`.
- `_rows.Count >= 2` → list view: each row a `<button>` showing `Kind` / `Status` / `Target` / time; click → set `_selectedRow`.
- `_rows.Count == 1` → set `_selectedRow` to that row on load (opens straight to detail).
- `_rows.Count == 0` → friendly empty state ("This execution emitted no audit rows.").
- Detail view renders `<AuditEventDetail Event="_selectedRow" />` plus a "← Back to rows" control (hidden / disabled when there is only one row — nothing to go back to).
- Query failure → inline error state inside the modal; never rethrow (mirror `ExecutionTreePage.LoadChainAsync`'s try/catch).
- Markup: hand-rolled Bootstrap modal (`modal`, `modal-dialog`, `modal-content`, `modal-header`/`modal-body`/`modal-footer`, plus a `modal-backdrop`), shown via the `IsOpen` bool + `d-block`/`show` classes — the same hand-rolled approach `AuditDrilldownDrawer` uses for `offcanvas`, no JS framework. Header: `Execution {short-id}` + row count. Close via header X, backdrop click, footer Close. `data-test` hooks: `execution-detail-modal`, `execution-detail-backdrop`, `execution-detail-close`, `execution-detail-row-{EventId}`, `execution-detail-back`, `execution-detail-empty`, `execution-detail-error`.
- Test: create `tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionDetailModalTests.cs` — with a fake `IAuditLogQueryService`: multi-row → list renders, row click → `AuditEventDetail` shown; single-row → opens straight to detail; zero-row → empty state; query throws → error state; close raises `OnClose`.
Use the `frontend-design` skill for the modal markup/CSS — clean corporate aesthetic, consistent with the existing Audit UI.
**Commit:** `feat(centralui): ExecutionDetailModal — execution rows with per-row detail`
---
## Task 4: Wire the modal into `ExecutionTreePage`
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor` — pass `OnNodeActivated="HandleNodeActivated"` to `<ExecutionTree>`; add `<ExecutionDetailModal ExecutionId="_modalExecutionId" IsOpen="_modalOpen" OnClose="HandleModalClose" />`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs` — add `_modalExecutionId` (`Guid?`), `_modalOpen` (`bool`), `HandleNodeActivated(Guid executionId)` (sets both + opens), `HandleModalClose()` (clears `_modalOpen`).
- Test: extend `tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs` — double-clicking a rendered tree node opens the modal (the modal's `data-test="execution-detail-modal"` appears); closing it hides the modal.
**Commit:** `feat(centralui): open ExecutionDetailModal on tree-node double-click`
---
## Task 5: End-to-end Playwright test + docs
**Files:**
- Create/extend: `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs` (or a sibling Audit Playwright file) — `DoubleClickTreeNode_OpensExecutionRowModal`: seed a chain (reuse `AuditDataSeeder`), open `/audit/execution-tree?executionId=<id>`, double-click a multi-row node, assert the modal opens with the row list, click a row, assert the `AuditEventDetail` field block shows, close the modal. Build the Playwright project; run if the cluster is available (note if skipped).
- Modify: `docs/requirements/Component-AuditLog.md` — one sentence in the Central UI / Interactions section noting the execution-tree node opens a detail modal of the execution's rows. (Do NOT modify `alog.md`.)
**Commit:** `test(centralui): e2e execution-tree node detail modal + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build ScadaLink.slnx` (0 warnings) + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 1 ← 0. 2 ← 0. 3 ← 1. 4 ← 2, 3. 5 ← 4.
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → final review.

View File

@@ -0,0 +1,12 @@
{
"planPath": "docs/plans/2026-05-22-execution-tree-node-modal.md",
"tasks": [
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "completed"},
{"id": 1, "subject": "Task 1: Extract AuditEventDetail from AuditDrilldownDrawer", "status": "completed", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: ExecutionTree node double-click raises OnNodeActivated", "status": "completed", "blockedBy": [0]},
{"id": 3, "subject": "Task 3: ExecutionDetailModal component", "status": "completed", "blockedBy": [1]},
{"id": 4, "subject": "Task 4: Wire ExecutionDetailModal into ExecutionTreePage", "status": "completed", "blockedBy": [2, 3]},
{"id": 5, "subject": "Task 5: E2E Playwright test + docs", "status": "completed", "blockedBy": [4]}
],
"lastUpdated": "2026-05-22"
}

View File

@@ -428,6 +428,9 @@ global value in v1; per-channel overrides are deferred to v1.x.
hosts the Audit Log page (filter bar, results grid, drilldown drawer,
server-side CSV export). Drill-in links appear on Notifications, Site Calls,
External Systems, Inbound API key, Sites, and Instances detail pages.
Double-clicking a node on the execution-tree page opens a detail modal
listing that execution's audit rows, with click-through to each row's full
detail view.
- **[Health Monitoring (#11)](Component-HealthMonitoring.md)** — three new
tiles (Volume, Error rate, Backlog) plus new health metrics:
`SiteAuditBacklog`, `SiteAuditWriteFailures`, `SiteAuditTelemetryStalled`,

View File

@@ -3,9 +3,9 @@
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
All form/field rendering follows the form-layout memory:
read-only fields first (definition list), then subsections stacked,
action buttons at the bottom of the drawer. *@
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
is shared with the execution-tree node-detail modal. *@
@if (IsOpen && Event is not null)
{
@@ -26,161 +26,12 @@
</div>
<div class="offcanvas-body small">
@* Read-only field list — primary identification + provenance. *@
<dl class="row mb-3" data-test="drawer-fields">
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
<dt class="col-4 text-muted fw-normal">Status</dt>
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Target</dt>
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Actor</dt>
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
</dl>
@* Error subsection — only shown when there is something to report. *@
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
{
<section class="mb-3" data-test="section-error">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
{
<p class="text-danger mb-1">@Event.ErrorMessage</p>
}
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
{
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
}
</section>
}
@* Request body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.RequestSummary))
{
<section class="mb-3" data-test="section-request">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Request</span>
@if (IsRedacted(Event.RequestSummary))
{
<span data-test="redaction-badge-request"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="request-body">
@RenderBody(Event.RequestSummary!, Event.Channel)
</div>
</section>
}
@* Response body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
{
<section class="mb-3" data-test="section-response">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Response</span>
@if (IsRedacted(Event.ResponseSummary))
{
<span data-test="redaction-badge-response"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="response-body">
@RenderBody(Event.ResponseSummary!, Event.Channel)
</div>
</section>
}
@* Extra is always JSON when present. *@
@if (!string.IsNullOrEmpty(Event.Extra))
{
<section class="mb-3" data-test="section-extra">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
</section>
}
@* Single-row detail body + action buttons — shared component. *@
<AuditEventDetail Event="Event" />
</div>
@* Action buttons at the bottom per form-layout memory. *@
@* Close button kept at the bottom per form-layout memory. *@
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
@if (IsApiChannel(Event.Channel))
{
<button class="btn btn-outline-secondary btn-sm"
data-test="copy-as-curl"
@onclick="CopyCurl">
Copy as cURL
</button>
}
@if (Event.CorrelationId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="show-all-events"
@onclick="ShowAllForOperation">
Show all events for this operation
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
@if (Event.ParentExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-parent-execution"
@onclick="ViewParentExecution">
View parent execution
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-execution-chain"
@onclick="ViewExecutionChain">
View execution chain
</button>
}
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">

View File

@@ -1,66 +1,21 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer:
/// read-only fields, conditional Error/Request/Response/Extra subsections,
/// and action buttons (Copy as cURL, Show all events for this operation,
/// Close). The drawer is fully presentational — it has no DB or service
/// dependencies; the host page owns the open/close state.
///
/// <para>
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
/// The drawer pretty-prints JSON when it parses; falls back to verbatim
/// otherwise. DbOutbound payloads carry a <c>{sql, parameters}</c> JSON
/// shape and get a SQL code block plus a parameter definition list.
/// Syntax highlighting is CSS-class-only (<c>language-sql</c>); no JS
/// library is loaded — Blazor Server + Bootstrap only per the project's UI
/// rules.
/// </para>
///
/// <para>
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
/// with the literal sentinels <c>&lt;redacted&gt;</c> or
/// <c>&lt;redacted: redactor error&gt;</c> (see Component-AuditLog.md
/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body
/// section when its text contains either sentinel — it does not attempt
/// to un-redact or count occurrences.
/// </para>
///
/// <para>
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
/// command is written to the system clipboard via
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. We only
/// surface the button for API channels (ApiOutbound / ApiInbound).
/// </para>
///
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
/// Close buttons; the single-row detail body (read-only fields, conditional
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
/// to <see cref="AuditEventDetail"/>, which is shared with the execution-tree
/// node-detail modal so a row's detail renders identically in either host.
/// The drawer is fully presentational — it has no DB or service dependencies;
/// the host page owns the open/close state.
/// </summary>
public partial class AuditDrilldownDrawer
{
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary>
/// The row to render. When null the drawer renders nothing — the host
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
@@ -81,12 +36,6 @@ public partial class AuditDrilldownDrawer
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
private static bool IsApiChannel(AuditChannel channel)
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
private static string ShortEventId(Guid eventId)
{
// Mirror the "first 8 hex digits" presentation common across the UI.
@@ -94,159 +43,6 @@ public partial class AuditDrilldownDrawer
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTimestamp(DateTime utc)
{
// Force UTC kind in case the row arrived as Unspecified, then emit
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
return kind.ToString("o", CultureInfo.InvariantCulture);
}
private static bool IsRedacted(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
}
/// <summary>
/// Channel-aware body renderer. DbOutbound bodies that parse as
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
/// else falls back to JSON-pretty-print, then plain-text verbatim.
/// </summary>
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
{
// DbOutbound special-case: try to extract {sql, parameters}.
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
{
builder.OpenElement(0, "pre");
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
builder.OpenElement(2, "code");
// Highlighting is CSS-class-only — no JS library is loaded.
builder.AddAttribute(3, "class", "language-sql");
builder.AddContent(4, sql);
builder.CloseElement();
builder.CloseElement();
if (parameters is not null && parameters.Count > 0)
{
builder.OpenElement(10, "dl");
builder.AddAttribute(11, "class", "row mb-0 small");
builder.AddAttribute(12, "data-test", "sql-parameters");
// The analyzer (ASP0006) requires literal sequence numbers
// inside a render fragment. We delegate parameter rendering
// to a helper fragment that uses a stable @key per entry,
// so per-row diffing stays correct even though the outer
// sequence number is fixed.
builder.AddContent(13, BuildSqlParameterRows(parameters));
builder.CloseElement();
}
return;
}
// Generic JSON pretty-print path.
if (TryPrettyPrintJson(body, out var pretty))
{
builder.OpenElement(20, "pre");
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
builder.AddContent(22, pretty);
builder.CloseElement();
return;
}
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
// is useful when the body is multi-line plain text or a partial JSON.
builder.OpenElement(30, "pre");
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
builder.AddContent(32, body);
builder.CloseElement();
};
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
{
foreach (var kv in parameters)
{
// Literal sequence numbers (ASP0006) + per-element SetKey so
// Blazor's diff is still keyed on parameter name. The "0" base
// is fine here — each loop iteration produces a disjoint
// dt/dd pair, and the diff keys on @key, not sequence.
builder.OpenElement(0, "dt");
builder.SetKey($"dt-{kv.Key}");
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
builder.AddContent(2, kv.Key);
builder.CloseElement();
builder.OpenElement(3, "dd");
builder.SetKey($"dd-{kv.Key}");
builder.AddAttribute(4, "class", "col-8 font-monospace");
builder.AddContent(5, kv.Value);
builder.CloseElement();
}
};
private static bool TryPrettyPrintJson(string text, out string formatted)
{
formatted = text;
try
{
using var doc = JsonDocument.Parse(text);
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
return true;
}
catch (JsonException)
{
return false;
}
}
private static string PrettyPrintJson(string text)
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
/// <summary>
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
/// Returns true only when the JSON has a string <c>sql</c> property;
/// <c>parameters</c> is treated as an optional object whose values
/// stringify to scalar text.
/// </summary>
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
{
sql = string.Empty;
parameters = null;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
return false;
sql = sqlProp.GetString() ?? string.Empty;
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
&& paramsProp.ValueKind == JsonValueKind.Object)
{
parameters = new List<KeyValuePair<string, string>>();
foreach (var p in paramsProp.EnumerateObject())
{
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => value.GetRawText(),
_ => value.GetRawText(),
};
private async Task HandleClose()
{
if (OnClose.HasDelegate)
@@ -254,170 +50,4 @@ public partial class AuditDrilldownDrawer
await OnClose.InvokeAsync();
}
}
private async Task CopyCurl()
{
if (Event is null) return;
var curl = BuildCurlCommand(Event);
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
}
catch
{
// Clipboard interop can fail (denied permission, prerender, etc.).
// The drawer stays open; the failure surfaces in the dev console
// only — we deliberately do not toast here because the parent
// page owns toast state.
}
}
private void ShowAllForOperation()
{
if (Event?.CorrelationId is not { } corr) return;
var uri = $"/audit/log?correlationId={corr}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
/// drill-in target. The button is only rendered when
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewParentExecution()
{
if (Event?.ParentExecutionId is not { } parentExec) return;
var uri = $"/audit/log?executionId={parentExec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
/// is total.
/// </summary>
private void ViewExecutionChain()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/execution-tree?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
/// and the body into <c>--data-raw</c>. Default method is POST for
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
{
var sb = new StringBuilder();
sb.Append("curl");
string method = "POST";
List<KeyValuePair<string, string>>? headers = null;
string? body = null;
if (!string.IsNullOrEmpty(ev.RequestSummary))
{
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
}
sb.Append(' ').Append("-X ").Append(method);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
sb.Append(' ').Append("-H ");
sb.Append(QuoteShellArg($"{name}: {value}"));
}
}
if (!string.IsNullOrEmpty(body))
{
sb.Append(' ').Append("--data-raw ");
sb.Append(QuoteShellArg(body!));
}
var url = ev.Target ?? string.Empty;
sb.Append(' ').Append(QuoteShellArg(url));
return sb.ToString();
}
private static void TryExtractCurlPartsFromJson(
string requestSummary,
ref string method,
ref List<KeyValuePair<string, string>>? headers,
ref string? body)
{
try
{
using var doc = JsonDocument.Parse(requestSummary);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
{
method = m.GetString() ?? method;
}
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
{
headers = new List<KeyValuePair<string, string>>();
foreach (var h in hs.EnumerateObject())
{
var value = h.Value.ValueKind == JsonValueKind.String
? h.Value.GetString() ?? string.Empty
: h.Value.GetRawText();
headers.Add(new KeyValuePair<string, string>(h.Name, value));
}
}
if (doc.RootElement.TryGetProperty("body", out var b))
{
body = b.ValueKind == JsonValueKind.String
? b.GetString()
: b.GetRawText();
}
}
catch (JsonException)
{
// RequestSummary wasn't the expected {headers, body} shape —
// just produce a bare cURL with no body/headers.
}
}
/// <summary>
/// Quote a single shell argument with single quotes, escaping embedded
/// single quotes via the standard <c>'\''</c> idiom. This is the same
/// quoting strategy curl examples use across man pages.
/// </summary>
private static string QuoteShellArg(string value)
{
if (string.IsNullOrEmpty(value)) return "''";
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
return $"'{escaped}'";
}
}

View File

@@ -1,6 +1,7 @@
/* Audit Log drilldown drawer (#23 M7 Bundle C).
The base offcanvas + backdrop classes come from Bootstrap. The local
overrides below pin our preferred width and pre-block behaviour. */
overrides below pin our preferred width and the footer tint. The body
(pre-block) styles travel with the markup in AuditEventDetail.razor.css. */
.audit-drilldown-drawer {
/* Slightly wider than the parked-messages drawer because audit rows can
@@ -9,32 +10,6 @@
width: min(720px, 95vw);
}
.audit-drilldown-drawer .drawer-pre {
/* Wrap long lines and bound the per-block height so the drawer body
stays scrollable end-to-end instead of pushing the action buttons
below the fold. */
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow-y: auto;
margin: 0;
font-size: 0.8125rem;
}
.audit-drilldown-drawer .drawer-pre.json {
/* JSON blocks get a faint left rule so they read as quoted material. */
border-left: 3px solid var(--bs-info-border-subtle);
}
.audit-drilldown-drawer .drawer-pre code.language-sql {
/* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
a slightly different background so the SQL block reads distinct from
generic JSON pretty-prints without loading a syntax-highlighter JS
library. */
font-family: var(--bs-font-monospace);
color: var(--bs-emphasis-color);
}
.audit-drilldown-drawer .drawer-footer {
background-color: var(--bs-tertiary-bg);
}

View File

@@ -0,0 +1,165 @@
@using ScadaLink.Commons.Entities.Audit
@using ScadaLink.Commons.Types.Enums
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
Extracted from AuditDrilldownDrawer so the drawer and the execution-tree
node-detail modal share one rendering of a row's detail.
All form/field rendering follows the form-layout memory:
read-only fields first (definition list), then subsections stacked,
action buttons at the bottom. *@
@* Read-only field list — primary identification + provenance. *@
<dl class="row mb-3" data-test="drawer-fields">
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
<dt class="col-4 text-muted fw-normal">Status</dt>
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Target</dt>
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">Actor</dt>
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
</dl>
@* Error subsection — only shown when there is something to report. *@
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
{
<section class="mb-3" data-test="section-error">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
{
<p class="text-danger mb-1">@Event.ErrorMessage</p>
}
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
{
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
}
</section>
}
@* Request body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.RequestSummary))
{
<section class="mb-3" data-test="section-request">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Request</span>
@if (IsRedacted(Event.RequestSummary))
{
<span data-test="redaction-badge-request"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="request-body">
@RenderBody(Event.RequestSummary!, Event.Channel)
</div>
</section>
}
@* Response body (channel-aware renderer). *@
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
{
<section class="mb-3" data-test="section-response">
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
<span>Response</span>
@if (IsRedacted(Event.ResponseSummary))
{
<span data-test="redaction-badge-response"
class="badge bg-warning text-dark"
title="Sensitive values redacted by audit pipeline">
Redacted
</span>
}
</h6>
<div data-test="response-body">
@RenderBody(Event.ResponseSummary!, Event.Channel)
</div>
</section>
}
@* Extra is always JSON when present. *@
@if (!string.IsNullOrEmpty(Event.Extra))
{
<section class="mb-3" data-test="section-extra">
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
</section>
}
@* Action buttons at the bottom per form-layout memory. *@
<div class="d-flex gap-2 flex-wrap" data-test="audit-event-detail-actions">
@if (IsApiChannel(Event.Channel))
{
<button class="btn btn-outline-secondary btn-sm"
data-test="copy-as-curl"
@onclick="CopyCurl">
Copy as cURL
</button>
}
@if (Event.CorrelationId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="show-all-events"
@onclick="ShowAllForOperation">
Show all events for this operation
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
@if (Event.ParentExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-parent-execution"
@onclick="ViewParentExecution">
View parent execution
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-execution-chain"
@onclick="ViewExecutionChain">
View execution chain
</button>
}
</div>

View File

@@ -0,0 +1,393 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Reusable single-<see cref="AuditEvent"/> detail body (#23 M7 Bundle C /
/// M7-T4..T8). Extracted verbatim from <see cref="AuditDrilldownDrawer"/> so
/// the drawer and the execution-tree node-detail modal render a row's detail
/// identically. Renders the read-only field list, the conditional
/// Error/Request/Response/Extra subsections, and the action buttons (Copy as
/// cURL, Show all events for this operation, View this/parent execution, View
/// execution chain). The component is fully presentational apart from the
/// clipboard interop and drill-back navigation it owns; the host owns its
/// surrounding chrome.
///
/// <para>
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
/// JSON is pretty-printed when it parses; falls back to verbatim otherwise.
/// DbOutbound payloads carry a <c>{sql, parameters}</c> JSON shape and get a
/// SQL code block plus a parameter definition list. Syntax highlighting is
/// CSS-class-only (<c>language-sql</c>); no JS library is loaded — Blazor
/// Server + Bootstrap only per the project's UI rules.
/// </para>
///
/// <para>
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
/// with the literal sentinels <c>&lt;redacted&gt;</c> or
/// <c>&lt;redacted: redactor error&gt;</c> (see Component-AuditLog.md
/// §Redaction). A yellow "Redacted" badge surfaces on a body section when
/// its text contains either sentinel — no un-redaction or counting.
/// </para>
///
/// <para>
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
/// command is written to the system clipboard via
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. The button
/// is only surfaced for API channels (ApiOutbound / ApiInbound).
/// </para>
///
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
public partial class AuditEventDetail
{
[Inject] private IJSRuntime JS { get; set; } = null!;
[Inject] private NavigationManager Navigation { get; set; } = null!;
/// <summary>
/// The row to render. Required and non-null — the host (drawer or modal)
/// only mounts this component once it has a row to show.
/// </summary>
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
private const string RedactionSentinel = "<redacted>";
private const string RedactorErrorSentinel = "<redacted: redactor error>";
private static bool IsApiChannel(AuditChannel channel)
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
private static string FormatTimestamp(DateTime utc)
{
// Force UTC kind in case the row arrived as Unspecified, then emit
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
return kind.ToString("o", CultureInfo.InvariantCulture);
}
private static bool IsRedacted(string? text)
{
if (string.IsNullOrEmpty(text)) return false;
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
}
/// <summary>
/// Channel-aware body renderer. DbOutbound bodies that parse as
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
/// else falls back to JSON-pretty-print, then plain-text verbatim.
/// </summary>
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
{
// DbOutbound special-case: try to extract {sql, parameters}.
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
{
builder.OpenElement(0, "pre");
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
builder.OpenElement(2, "code");
// Highlighting is CSS-class-only — no JS library is loaded.
builder.AddAttribute(3, "class", "language-sql");
builder.AddContent(4, sql);
builder.CloseElement();
builder.CloseElement();
if (parameters is not null && parameters.Count > 0)
{
builder.OpenElement(10, "dl");
builder.AddAttribute(11, "class", "row mb-0 small");
builder.AddAttribute(12, "data-test", "sql-parameters");
// The analyzer (ASP0006) requires literal sequence numbers
// inside a render fragment. We delegate parameter rendering
// to a helper fragment that uses a stable @key per entry,
// so per-row diffing stays correct even though the outer
// sequence number is fixed.
builder.AddContent(13, BuildSqlParameterRows(parameters));
builder.CloseElement();
}
return;
}
// Generic JSON pretty-print path.
if (TryPrettyPrintJson(body, out var pretty))
{
builder.OpenElement(20, "pre");
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
builder.AddContent(22, pretty);
builder.CloseElement();
return;
}
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
// is useful when the body is multi-line plain text or a partial JSON.
builder.OpenElement(30, "pre");
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
builder.AddContent(32, body);
builder.CloseElement();
};
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
{
foreach (var kv in parameters)
{
// Literal sequence numbers (ASP0006) + per-element SetKey so
// Blazor's diff is still keyed on parameter name. The "0" base
// is fine here — each loop iteration produces a disjoint
// dt/dd pair, and the diff keys on @key, not sequence.
builder.OpenElement(0, "dt");
builder.SetKey($"dt-{kv.Key}");
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
builder.AddContent(2, kv.Key);
builder.CloseElement();
builder.OpenElement(3, "dd");
builder.SetKey($"dd-{kv.Key}");
builder.AddAttribute(4, "class", "col-8 font-monospace");
builder.AddContent(5, kv.Value);
builder.CloseElement();
}
};
private static bool TryPrettyPrintJson(string text, out string formatted)
{
formatted = text;
try
{
using var doc = JsonDocument.Parse(text);
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
return true;
}
catch (JsonException)
{
return false;
}
}
private static string PrettyPrintJson(string text)
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
/// <summary>
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
/// Returns true only when the JSON has a string <c>sql</c> property;
/// <c>parameters</c> is treated as an optional object whose values
/// stringify to scalar text.
/// </summary>
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
{
sql = string.Empty;
parameters = null;
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
return false;
sql = sqlProp.GetString() ?? string.Empty;
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
&& paramsProp.ValueKind == JsonValueKind.Object)
{
parameters = new List<KeyValuePair<string, string>>();
foreach (var p in paramsProp.EnumerateObject())
{
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
}
}
return true;
}
catch (JsonException)
{
return false;
}
}
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
{
JsonValueKind.String => value.GetString() ?? string.Empty,
JsonValueKind.Null => "null",
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => value.GetRawText(),
_ => value.GetRawText(),
};
private async Task CopyCurl()
{
var curl = BuildCurlCommand(Event);
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
}
catch
{
// Clipboard interop can fail (denied permission, prerender, etc.).
// The component stays mounted; the failure surfaces in the dev
// console only — we deliberately do not toast here because the
// parent page owns toast state.
}
}
private void ShowAllForOperation()
{
if (Event.CorrelationId is not { } corr) return;
var uri = $"/audit/log?correlationId={corr}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
/// drill-in target. The button is only rendered when
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewParentExecution()
{
if (Event.ParentExecutionId is not { } parentExec) return;
var uri = $"/audit/log?executionId={parentExec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
/// is total.
/// </summary>
private void ViewExecutionChain()
{
if (Event.ExecutionId is not { } exec) return;
var uri = $"/audit/execution-tree?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
/// and the body into <c>--data-raw</c>. Default method is POST for
/// outbound audit rows — the audit pipeline does not always capture
/// the verb explicitly.
/// </summary>
private static string BuildCurlCommand(AuditEvent ev)
{
var sb = new StringBuilder();
sb.Append("curl");
string method = "POST";
List<KeyValuePair<string, string>>? headers = null;
string? body = null;
if (!string.IsNullOrEmpty(ev.RequestSummary))
{
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
}
sb.Append(' ').Append("-X ").Append(method);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
sb.Append(' ').Append("-H ");
sb.Append(QuoteShellArg($"{name}: {value}"));
}
}
if (!string.IsNullOrEmpty(body))
{
sb.Append(' ').Append("--data-raw ");
sb.Append(QuoteShellArg(body!));
}
var url = ev.Target ?? string.Empty;
sb.Append(' ').Append(QuoteShellArg(url));
return sb.ToString();
}
private static void TryExtractCurlPartsFromJson(
string requestSummary,
ref string method,
ref List<KeyValuePair<string, string>>? headers,
ref string? body)
{
try
{
using var doc = JsonDocument.Parse(requestSummary);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
{
method = m.GetString() ?? method;
}
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
{
headers = new List<KeyValuePair<string, string>>();
foreach (var h in hs.EnumerateObject())
{
var value = h.Value.ValueKind == JsonValueKind.String
? h.Value.GetString() ?? string.Empty
: h.Value.GetRawText();
headers.Add(new KeyValuePair<string, string>(h.Name, value));
}
}
if (doc.RootElement.TryGetProperty("body", out var b))
{
body = b.ValueKind == JsonValueKind.String
? b.GetString()
: b.GetRawText();
}
}
catch (JsonException)
{
// RequestSummary wasn't the expected {headers, body} shape —
// just produce a bare cURL with no body/headers.
}
}
/// <summary>
/// Quote a single shell argument with single quotes, escaping embedded
/// single quotes via the standard <c>'\''</c> idiom. This is the same
/// quoting strategy curl examples use across man pages.
/// </summary>
private static string QuoteShellArg(string value)
{
if (string.IsNullOrEmpty(value)) return "''";
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
return $"'{escaped}'";
}
}

View File

@@ -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);
}

View File

@@ -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 <AuditEventDetail>. *@
@if (IsOpen)
{
<div class="modal-backdrop fade show" data-test="execution-detail-backdrop"
@onclick="HandleClose"></div>
<div class="modal fade show d-block execution-detail-modal" tabindex="-1"
data-test="execution-detail-modal" role="dialog"
aria-modal="true" aria-labelledby="execution-detail-modal-title"
@onkeydown="HandleKeyDown">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<div>
<div class="text-muted small text-uppercase">Execution</div>
<h5 id="execution-detail-modal-title"
class="modal-title mb-0 d-flex align-items-baseline gap-2">
<span class="font-monospace">Execution @ShortExecutionId()</span>
@if (!_loading && _error is null)
{
<span class="badge rounded-pill text-bg-secondary fw-normal"
data-test="execution-detail-row-count">
@_rows.Count @(_rows.Count == 1 ? "row" : "rows")
</span>
}
</h5>
</div>
<button type="button" class="btn-close" aria-label="Close"
data-test="execution-detail-close"
@onclick="HandleClose"></button>
</div>
<div class="modal-body small">
@if (_loading)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-loading">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Loading execution rows…
</div>
}
else if (_error is not null)
{
<div class="alert alert-danger mb-0" role="alert"
data-test="execution-detail-error">
@_error
</div>
}
else if (_rows.Count == 0)
{
<div class="text-muted py-4 text-center" data-test="execution-detail-empty">
This execution emitted no audit rows.
</div>
}
else if (_selectedRow is not null)
{
@* Detail view — shared single-row body. *@
@if (_rows.Count > 1)
{
<button type="button"
class="btn btn-link btn-sm px-0 mb-2 execution-detail-back-link"
data-test="execution-detail-back"
@onclick="BackToList">
&larr; Back to rows
</button>
}
<AuditEventDetail Event="_selectedRow" />
}
else
{
@* List view — one button per audit row. *@
<div class="list-group execution-detail-row-list">
@foreach (var row in _rows)
{
<button type="button"
class="list-group-item list-group-item-action d-flex align-items-center gap-3"
data-test="execution-detail-row-@row.EventId"
@onclick="() => SelectRow(row)">
<span class="badge @StatusBadgeClass(row.Status) execution-detail-status">
@row.Status
</span>
<span class="execution-detail-kind fw-semibold">@row.Kind</span>
<span class="text-muted text-truncate flex-grow-1">
@(row.Target ?? "—")
</span>
<span class="text-muted font-monospace small flex-shrink-0">
@FormatTime(row.OccurredAtUtc)
</span>
</button>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm"
data-test="execution-detail-close-footer"
@onclick="HandleClose">
Close
</button>
</div>
</div>
</div>
</div>
}

View File

@@ -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;
/// <summary>
/// Execution-Tree Node Detail Modal (Execution-Tree Node Detail Modal feature,
/// Task 3). Opened from an execution-tree node double-click: given an
/// <see cref="ExecutionId"/> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// <para>
/// <b>Chrome.</b> A hand-rolled Bootstrap modal — visibility is pure Blazor
/// state (<see cref="IsOpen"/>) plus the <c>d-block</c>/<c>show</c> CSS classes
/// and a sibling <c>modal-backdrop</c>, mirroring how
/// <see cref="AuditDrilldownDrawer"/> hand-rolls its offcanvas. No
/// <c>bootstrap.bundle.js</c> modal API is used.
/// </para>
///
/// <para>
/// <b>Load timing.</b> The modal queries only on the closed → open transition
/// (detected in <see cref="OnParametersSetAsync"/>), never on every parameter
/// change, so re-renders while open do not re-hit the service.
/// </para>
///
/// <para>
/// <b>States.</b> Two-or-more rows → list view (one button per row, click sets
/// the selected row); exactly one row → opens straight to the detail view;
/// zero rows → a friendly empty state. A query failure degrades to an inline
/// error banner — it is never rethrown, so a transient DB outage cannot kill
/// the SignalR circuit (the same posture as <c>ExecutionTreePage.LoadChainAsync</c>).
/// The per-row detail body is delegated to the shared <see cref="AuditEventDetail"/>.
/// </para>
/// </summary>
public partial class ExecutionDetailModal
{
[Inject] private IAuditLogQueryService AuditLogQueryService { get; set; } = null!;
/// <summary>
/// The execution whose audit rows the modal loads. When null an open modal
/// loads nothing and shows the empty state — the host is expected to pair a
/// non-null id with <see cref="IsOpen"/>.
/// </summary>
[Parameter] public Guid? ExecutionId { get; set; }
/// <summary>
/// True when the host wants the modal visible. The closed → open transition
/// triggers the row load; see <see cref="OnParametersSetAsync"/>.
/// </summary>
[Parameter] public bool IsOpen { get; set; }
/// <summary>
/// Fired when the user dismisses the modal (header X, backdrop click, or
/// footer Close). The host is expected to flip <see cref="IsOpen"/> to false.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
// The loaded rows for the current execution; empty until a load completes.
private IReadOnlyList<AuditEvent> _rows = Array.Empty<AuditEvent>();
// The row whose detail is shown; null = list view.
private AuditEvent? _selectedRow;
private bool _loading;
private string? _error;
// Tracks the previous IsOpen so OnParametersSet can detect the open
// transition and load exactly once per open, not on every parameter change.
private bool _wasOpen;
/// <summary>
/// Page size for the execution-row query. One execution's audit rows are
/// few (cached calls top out around 45 rows); 100 comfortably covers a
/// whole execution without paging.
/// </summary>
private const int RowPageSize = 100;
protected override async Task OnParametersSetAsync()
{
// Load only on the closed → open transition. A re-render while already
// open (or while closed) must not re-hit the service.
if (IsOpen && !_wasOpen)
{
await LoadRowsAsync();
}
_wasOpen = IsOpen;
}
/// <summary>
/// Loads the current execution's audit rows. On success, a single-row
/// result opens straight to the detail view; otherwise the list view shows.
/// A query failure degrades to an inline error banner and is never
/// rethrown — audit drill-in is best-effort and must not kill the circuit.
/// </summary>
private async Task LoadRowsAsync()
{
_loading = true;
_error = null;
_selectedRow = null;
_rows = Array.Empty<AuditEvent>();
if (ExecutionId is null)
{
// Nothing to load — fall through to the empty state.
_loading = false;
return;
}
try
{
// No CancellationToken is passed deliberately: this is a bounded,
// small (~100-row) query for one execution, so the IDisposable/CTS
// machinery is not worth it for a modal. The closed → open guard in
// OnParametersSetAsync cleanly re-loads on the next open if needed.
_rows = await AuditLogQueryService.QueryAsync(
new AuditLogQueryFilter(ExecutionId: ExecutionId.Value),
new AuditLogPaging(PageSize: RowPageSize));
// A single-row execution opens straight to its detail — there is
// no list to choose from.
if (_rows.Count == 1)
{
_selectedRow = _rows[0];
}
}
catch (Exception ex)
{
// Mirror ExecutionTreePage.LoadChainAsync: a transient DB outage
// degrades the modal to an inline error banner rather than killing
// the SignalR circuit. Never rethrow.
_error = $"Could not load this execution's audit rows: {ex.Message}";
_rows = Array.Empty<AuditEvent>();
_selectedRow = null;
}
finally
{
_loading = false;
}
}
private void SelectRow(AuditEvent row) => _selectedRow = row;
private void BackToList() => _selectedRow = null;
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
/// <summary>
/// Closes the modal when Escape is pressed, matching the header X, backdrop
/// click, and footer Close affordances. The root <c>.modal</c> div carries
/// <c>tabindex="-1"</c> so it can receive the keydown.
/// </summary>
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
await HandleClose();
}
}
/// <summary>First 8 hex digits of the execution id, mirroring the UI's short-id convention.</summary>
private string ShortExecutionId()
{
if (ExecutionId is null)
{
return "—";
}
var n = ExecutionId.Value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private static string FormatTime(DateTime occurredAtUtc)
=> occurredAtUtc.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
/// <summary>
/// Bootstrap badge class for a row's status — green for the success
/// terminal state, red for failure/discard, amber for in-flight. Mirrors
/// the status-badge colouring used by the Audit Log results grid.
/// </summary>
private static string StatusBadgeClass(AuditStatus status) => status switch
{
AuditStatus.Delivered => "text-bg-success",
AuditStatus.Failed or AuditStatus.Discarded or AuditStatus.Parked => "text-bg-danger",
_ => "text-bg-warning",
};
}

View File

@@ -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;
}

View File

@@ -45,7 +45,8 @@
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
}
<div class="execution-tree-body">
<div class="execution-tree-body"
@ondblclick="() => OnNodeActivated.InvokeAsync(node.ExecutionId)">
<div class="execution-tree-headline">
<a class="execution-tree-link font-monospace"
data-test="tree-node-link-@node.ExecutionId"
@@ -116,6 +117,7 @@
the flat-list assembly entirely. *@
<ExecutionTree PreBuiltRoots="subtree.Children"
ArrivedFromExecutionId="ArrivedFromExecutionId"
OnNodeActivated="OnNodeActivated"
Depth="Depth + 1" />
}
</li>

View File

@@ -78,6 +78,15 @@ public partial class ExecutionTree
/// </summary>
[Parameter] public int Depth { get; set; }
/// <summary>
/// Raised when a node is double-clicked, carrying that node's
/// <see cref="ExecutionTreeNode.ExecutionId"/>. The same callback is
/// threaded unchanged into every recursive child instance, so a
/// double-click on a node at any depth invokes the root-supplied handler
/// (used to open the node detail modal).
/// </summary>
[Parameter] public EventCallback<Guid> OnNodeActivated { get; set; }
// The subtrees this instance renders: assembled from Nodes on the root,
// or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();

View File

@@ -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

View File

@@ -52,7 +52,14 @@
View this execution in the Audit Log
</a>
</div>
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value" />
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value"
OnNodeActivated="HandleNodeActivated" />
@* 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. *@
<ExecutionDetailModal ExecutionId="_modalExecutionId" IsOpen="_modalOpen"
OnClose="HandleModalClose" />
}
else
{

View File

@@ -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
// <ExecutionDetailModal>. 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;
}
}
/// <summary>
/// Raised by <c>ExecutionTree</c> (bubbled up from a node double-click) with
/// the activated node's <c>ExecutionId</c>. Opens the
/// <c>ExecutionDetailModal</c> for that execution — the modal loads its
/// audit rows on the closed → open transition.
/// </summary>
private void HandleNodeActivated(Guid executionId)
{
_modalExecutionId = executionId;
_modalOpen = true;
}
/// <summary>
/// Raised by <c>ExecutionDetailModal</c> when the user dismisses it. Flips
/// the visibility gate closed; <see cref="_modalExecutionId"/> is left as-is.
/// </summary>
private void HandleModalClose() => _modalOpen = false;
}

View File

@@ -31,6 +31,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// drawer's "View parent execution" action on a spawned (child) row drills in
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
/// rows.</item>
/// <item><c>DoubleClickTreeNode_OpensExecutionRowModal</c> — double-clicking a
/// node on the execution-tree page opens <c>ExecutionDetailModal</c>, walking
/// list → row → detail before closing.</item>
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — 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 <AuditEventDetail> 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 <AuditEventDetail> 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()
{

View File

@@ -12,15 +12,15 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#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 <see cref="AuditEvent"/> 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 <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
/// 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 <see cref="AuditEventDetail"/>.
/// </summary>
public class AuditDrilldownDrawerTests : BunitContext
{

View File

@@ -0,0 +1,282 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditEventDetail"/> — the reusable single-row
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> (Task 1 of the
/// Execution-Tree Node Detail Modal feature).
///
/// These tests render the detail component directly (not via the drawer) and
/// pin the contract the drawer — and any future modal host — relies on:
/// the read-only field block, the conditional Error/Request/Response/Extra
/// sections, the redaction badge, channel-aware body rendering, and the
/// action buttons. All <c>data-test</c> values must match the originals so the
/// existing <see cref="AuditDrilldownDrawer"/> selectors keep resolving.
/// </summary>
public class AuditEventDetailTests : BunitContext
{
public AuditEventDetailTests()
{
// Loose so the cURL clipboard call does not blow up tests that do not
// exercise it. The clipboard test flips to Strict itself.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
=> new()
{
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
Actor = "tester",
Target = target,
Status = status,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
DurationMs = 42,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
Extra = extra,
};
[Fact]
public void RendersFieldBlock()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"drawer-fields\"", cut.Markup);
Assert.Contains("data-test=\"field-Channel\"", cut.Markup);
Assert.Contains("data-test=\"field-Status\"", cut.Markup);
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
[Fact]
public void ErrorSection_RendersWhenErrorPresent()
{
var ev = MakeEvent(
status: AuditStatus.Parked,
errorMessage: "boom",
errorDetail: "stack trace here");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-error\"", cut.Markup);
Assert.Contains("boom", cut.Markup);
Assert.Contains("stack trace here", cut.Markup);
}
[Fact]
public void ErrorSection_HiddenWhenNoError()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup);
}
[Fact]
public void RequestSection_PrettyPrintsJson()
{
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-request\"", cut.Markup);
Assert.Contains("data-test=\"request-body\"", cut.Markup);
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
}
[Fact]
public void ResponseSection_RendersWhenPresent()
{
var ev = MakeEvent(responseSummary: "{\"ok\":true}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-response\"", cut.Markup);
Assert.Contains("data-test=\"response-body\"", cut.Markup);
}
[Fact]
public void ExtraSection_RendersWhenPresent()
{
var ev = MakeEvent(extra: "{\"note\":\"hi\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-extra\"", cut.Markup);
}
[Fact]
public void RedactedBody_ShowsRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void NonRedactedBody_HidesRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void DbOutboundChannel_RendersSqlBlock()
{
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("language-sql", cut.Markup);
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
Assert.Contains("data-test=\"sql-parameters\"", cut.Markup);
}
[Fact]
public void ApiChannel_ShowsCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NonApiChannel_HidesCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NullCorrelationId_HidesShowAllButton()
{
var ev = MakeEvent(correlationId: null);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void NonNullCorrelationId_ShowsShowAllButton()
{
var ev = MakeEvent(correlationId: Guid.NewGuid());
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void ExecutionButtons_ConditionalOnExecutionIds()
{
var ev = MakeEvent(
executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"),
parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var ev = MakeEvent(correlationId: corr);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"show-all-events\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
JSInterop.Mode = JSRuntimeMode.Strict;
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
target: "https://example.test/api/v1/widgets",
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
var calls = clipboardCall.Invocations.ToList();
Assert.NotEmpty(calls);
var argString = (string)calls[0].Arguments[0]!;
Assert.StartsWith("curl ", argString);
Assert.Contains("https://example.test/api/v1/widgets", argString);
}
}

View File

@@ -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;
/// <summary>
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// an <c>ExecutionId</c> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> 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.
/// </summary>
public class ExecutionDetailModalTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
public ExecutionDetailModalTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_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<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<AuditEvent>());
var cut = Render<ExecutionDetailModal>(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<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => 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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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<ExecutionDetailModal>(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);
}
}

View File

@@ -11,8 +11,10 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, 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 <see cref="ExecutionTreeNode.ExecutionId"/> via
/// <c>OnNodeActivated</c>, and cycle-safety (a corrupt flat list must not
/// infinite-loop).
/// </summary>
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<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(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<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(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;

View File

@@ -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<Guid>(), Arg.Any<CancellationToken>());
}
[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<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
// The modal loads the double-clicked execution's audit rows on open.
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
// 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<AuditLogQueryFilter>(f => f.ExecutionId == child),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[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<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
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()
{