Merge branch 'feature/execution-tree-node-modal': execution-tree node detail modal
This commit is contained in:
91
docs/plans/2026-05-22-execution-tree-node-modal-design.md
Normal file
91
docs/plans/2026-05-22-execution-tree-node-modal-design.md
Normal 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.
|
||||||
110
docs/plans/2026-05-22-execution-tree-node-modal.md
Normal file
110
docs/plans/2026-05-22-execution-tree-node-modal.md
Normal 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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,
|
hosts the Audit Log page (filter bar, results grid, drilldown drawer,
|
||||||
server-side CSV export). Drill-in links appear on Notifications, Site Calls,
|
server-side CSV export). Drill-in links appear on Notifications, Site Calls,
|
||||||
External Systems, Inbound API key, Sites, and Instances detail pages.
|
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
|
- **[Health Monitoring (#11)](Component-HealthMonitoring.md)** — three new
|
||||||
tiles (Volume, Error rate, Backlog) plus new health metrics:
|
tiles (Volume, Error rate, Backlog) plus new health metrics:
|
||||||
`SiteAuditBacklog`, `SiteAuditWriteFailures`, `SiteAuditTelemetryStalled`,
|
`SiteAuditBacklog`, `SiteAuditWriteFailures`, `SiteAuditTelemetryStalled`,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
@* Audit Log drilldown drawer (#23 M7 Bundle C / M7-T4..T8).
|
||||||
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
|
Right-side Bootstrap offcanvas-style drawer hosted by the Audit Log page.
|
||||||
All form/field rendering follows the form-layout memory:
|
The drawer owns only the offcanvas chrome (backdrop, header, Close buttons);
|
||||||
read-only fields first (definition list), then subsections stacked,
|
the single-AuditEvent detail body is delegated to <AuditEventDetail>, which
|
||||||
action buttons at the bottom of the drawer. *@
|
is shared with the execution-tree node-detail modal. *@
|
||||||
|
|
||||||
@if (IsOpen && Event is not null)
|
@if (IsOpen && Event is not null)
|
||||||
{
|
{
|
||||||
@@ -26,161 +26,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-body small">
|
<div class="offcanvas-body small">
|
||||||
@* Read-only field list — primary identification + provenance. *@
|
@* Single-row detail body + action buttons — shared component. *@
|
||||||
<dl class="row mb-3" data-test="drawer-fields">
|
<AuditEventDetail Event="Event" />
|
||||||
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
|
|
||||||
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">Status</dt>
|
|
||||||
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">Target</dt>
|
|
||||||
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">Actor</dt>
|
|
||||||
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
|
|
||||||
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
|
|
||||||
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
|
|
||||||
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
|
|
||||||
|
|
||||||
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
|
|
||||||
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
@* Error subsection — only shown when there is something to report. *@
|
|
||||||
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
|
|
||||||
{
|
|
||||||
<section class="mb-3" data-test="section-error">
|
|
||||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
|
|
||||||
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
|
|
||||||
{
|
|
||||||
<p class="text-danger mb-1">@Event.ErrorMessage</p>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
|
|
||||||
{
|
|
||||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Request body (channel-aware renderer). *@
|
|
||||||
@if (!string.IsNullOrEmpty(Event.RequestSummary))
|
|
||||||
{
|
|
||||||
<section class="mb-3" data-test="section-request">
|
|
||||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
|
||||||
<span>Request</span>
|
|
||||||
@if (IsRedacted(Event.RequestSummary))
|
|
||||||
{
|
|
||||||
<span data-test="redaction-badge-request"
|
|
||||||
class="badge bg-warning text-dark"
|
|
||||||
title="Sensitive values redacted by audit pipeline">
|
|
||||||
Redacted
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</h6>
|
|
||||||
<div data-test="request-body">
|
|
||||||
@RenderBody(Event.RequestSummary!, Event.Channel)
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Response body (channel-aware renderer). *@
|
|
||||||
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
|
|
||||||
{
|
|
||||||
<section class="mb-3" data-test="section-response">
|
|
||||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
|
||||||
<span>Response</span>
|
|
||||||
@if (IsRedacted(Event.ResponseSummary))
|
|
||||||
{
|
|
||||||
<span data-test="redaction-badge-response"
|
|
||||||
class="badge bg-warning text-dark"
|
|
||||||
title="Sensitive values redacted by audit pipeline">
|
|
||||||
Redacted
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</h6>
|
|
||||||
<div data-test="response-body">
|
|
||||||
@RenderBody(Event.ResponseSummary!, Event.Channel)
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@* Extra is always JSON when present. *@
|
|
||||||
@if (!string.IsNullOrEmpty(Event.Extra))
|
|
||||||
{
|
|
||||||
<section class="mb-3" data-test="section-extra">
|
|
||||||
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
|
|
||||||
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Action buttons at the bottom per form-layout memory. *@
|
@* Close button kept at the bottom per form-layout memory. *@
|
||||||
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
|
<div class="border-top p-3 d-flex gap-2 flex-wrap drawer-footer">
|
||||||
@if (IsApiChannel(Event.Channel))
|
|
||||||
{
|
|
||||||
<button class="btn btn-outline-secondary btn-sm"
|
|
||||||
data-test="copy-as-curl"
|
|
||||||
@onclick="CopyCurl">
|
|
||||||
Copy as cURL
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (Event.CorrelationId is not null)
|
|
||||||
{
|
|
||||||
<button class="btn btn-outline-secondary btn-sm"
|
|
||||||
data-test="show-all-events"
|
|
||||||
@onclick="ShowAllForOperation">
|
|
||||||
Show all events for this operation
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (Event.ExecutionId is not null)
|
|
||||||
{
|
|
||||||
<button class="btn btn-outline-secondary btn-sm"
|
|
||||||
data-test="view-this-execution"
|
|
||||||
@onclick="ViewThisExecution">
|
|
||||||
View this execution
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (Event.ParentExecutionId is not null)
|
|
||||||
{
|
|
||||||
<button class="btn btn-outline-secondary btn-sm"
|
|
||||||
data-test="view-parent-execution"
|
|
||||||
@onclick="ViewParentExecution">
|
|
||||||
View parent execution
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (Event.ExecutionId is not null)
|
|
||||||
{
|
|
||||||
<button class="btn btn-outline-secondary btn-sm"
|
|
||||||
data-test="view-execution-chain"
|
|
||||||
@onclick="ViewExecutionChain">
|
|
||||||
View execution chain
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-primary btn-sm ms-auto"
|
<button class="btn btn-primary btn-sm ms-auto"
|
||||||
data-test="drawer-close-footer"
|
data-test="drawer-close-footer"
|
||||||
@onclick="HandleClose">
|
@onclick="HandleClose">
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.JSInterop;
|
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Components.Audit;
|
namespace ScadaLink.CentralUI.Components.Audit;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
/// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8).
|
||||||
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer:
|
/// Renders one <see cref="AuditEvent"/> in a right-side off-canvas drawer.
|
||||||
/// read-only fields, conditional Error/Request/Response/Extra subsections,
|
/// The drawer owns only the offcanvas chrome — backdrop, header, and the two
|
||||||
/// and action buttons (Copy as cURL, Show all events for this operation,
|
/// Close buttons; the single-row detail body (read-only fields, conditional
|
||||||
/// Close). The drawer is fully presentational — it has no DB or service
|
/// Error/Request/Response/Extra subsections, and action buttons) is delegated
|
||||||
/// dependencies; the host page owns the open/close state.
|
/// to <see cref="AuditEventDetail"/>, which is shared with the execution-tree
|
||||||
///
|
/// node-detail modal so a row's detail renders identically in either host.
|
||||||
/// <para>
|
/// The drawer is fully presentational — it has no DB or service dependencies;
|
||||||
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
|
/// the host page owns the open/close state.
|
||||||
/// The drawer pretty-prints JSON when it parses; falls back to verbatim
|
|
||||||
/// otherwise. DbOutbound payloads carry a <c>{sql, parameters}</c> JSON
|
|
||||||
/// shape and get a SQL code block plus a parameter definition list.
|
|
||||||
/// Syntax highlighting is CSS-class-only (<c>language-sql</c>); no JS
|
|
||||||
/// library is loaded — Blazor Server + Bootstrap only per the project's UI
|
|
||||||
/// rules.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
|
|
||||||
/// with the literal sentinels <c><redacted></c> or
|
|
||||||
/// <c><redacted: redactor error></c> (see Component-AuditLog.md
|
|
||||||
/// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body
|
|
||||||
/// section when its text contains either sentinel — it does not attempt
|
|
||||||
/// to un-redact or count occurrences.
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
|
|
||||||
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
|
|
||||||
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
|
|
||||||
/// command is written to the system clipboard via
|
|
||||||
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. We only
|
|
||||||
/// surface the button for API channels (ApiOutbound / ApiInbound).
|
|
||||||
/// </para>
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
|
|
||||||
/// the "Show all events" button navigates to
|
|
||||||
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
|
||||||
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
|
||||||
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
|
|
||||||
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
|
|
||||||
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
|
|
||||||
/// — the spawner's id used as the per-run drill-in target. All are deep
|
|
||||||
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditDrilldownDrawer
|
public partial class AuditDrilldownDrawer
|
||||||
{
|
{
|
||||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
|
||||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The row to render. When null the drawer renders nothing — the host
|
/// The row to render. When null the drawer renders nothing — the host
|
||||||
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
/// page uses this together with <see cref="IsOpen"/> to drive visibility.
|
||||||
@@ -81,12 +36,6 @@ public partial class AuditDrilldownDrawer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter] public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
private const string RedactionSentinel = "<redacted>";
|
|
||||||
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
|
||||||
|
|
||||||
private static bool IsApiChannel(AuditChannel channel)
|
|
||||||
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
|
|
||||||
|
|
||||||
private static string ShortEventId(Guid eventId)
|
private static string ShortEventId(Guid eventId)
|
||||||
{
|
{
|
||||||
// Mirror the "first 8 hex digits" presentation common across the UI.
|
// Mirror the "first 8 hex digits" presentation common across the UI.
|
||||||
@@ -94,159 +43,6 @@ public partial class AuditDrilldownDrawer
|
|||||||
return n.Length >= 8 ? n[..8] : n;
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatTimestamp(DateTime utc)
|
|
||||||
{
|
|
||||||
// Force UTC kind in case the row arrived as Unspecified, then emit
|
|
||||||
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
|
|
||||||
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
|
|
||||||
return kind.ToString("o", CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsRedacted(string? text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(text)) return false;
|
|
||||||
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|
|
||||||
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Channel-aware body renderer. DbOutbound bodies that parse as
|
|
||||||
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
|
|
||||||
/// else falls back to JSON-pretty-print, then plain-text verbatim.
|
|
||||||
/// </summary>
|
|
||||||
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
|
|
||||||
{
|
|
||||||
// DbOutbound special-case: try to extract {sql, parameters}.
|
|
||||||
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
|
|
||||||
{
|
|
||||||
builder.OpenElement(0, "pre");
|
|
||||||
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
|
|
||||||
builder.OpenElement(2, "code");
|
|
||||||
// Highlighting is CSS-class-only — no JS library is loaded.
|
|
||||||
builder.AddAttribute(3, "class", "language-sql");
|
|
||||||
builder.AddContent(4, sql);
|
|
||||||
builder.CloseElement();
|
|
||||||
builder.CloseElement();
|
|
||||||
|
|
||||||
if (parameters is not null && parameters.Count > 0)
|
|
||||||
{
|
|
||||||
builder.OpenElement(10, "dl");
|
|
||||||
builder.AddAttribute(11, "class", "row mb-0 small");
|
|
||||||
builder.AddAttribute(12, "data-test", "sql-parameters");
|
|
||||||
// The analyzer (ASP0006) requires literal sequence numbers
|
|
||||||
// inside a render fragment. We delegate parameter rendering
|
|
||||||
// to a helper fragment that uses a stable @key per entry,
|
|
||||||
// so per-row diffing stays correct even though the outer
|
|
||||||
// sequence number is fixed.
|
|
||||||
builder.AddContent(13, BuildSqlParameterRows(parameters));
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic JSON pretty-print path.
|
|
||||||
if (TryPrettyPrintJson(body, out var pretty))
|
|
||||||
{
|
|
||||||
builder.OpenElement(20, "pre");
|
|
||||||
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
|
|
||||||
builder.AddContent(22, pretty);
|
|
||||||
builder.CloseElement();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
|
|
||||||
// is useful when the body is multi-line plain text or a partial JSON.
|
|
||||||
builder.OpenElement(30, "pre");
|
|
||||||
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
|
|
||||||
builder.AddContent(32, body);
|
|
||||||
builder.CloseElement();
|
|
||||||
};
|
|
||||||
|
|
||||||
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
|
|
||||||
{
|
|
||||||
foreach (var kv in parameters)
|
|
||||||
{
|
|
||||||
// Literal sequence numbers (ASP0006) + per-element SetKey so
|
|
||||||
// Blazor's diff is still keyed on parameter name. The "0" base
|
|
||||||
// is fine here — each loop iteration produces a disjoint
|
|
||||||
// dt/dd pair, and the diff keys on @key, not sequence.
|
|
||||||
builder.OpenElement(0, "dt");
|
|
||||||
builder.SetKey($"dt-{kv.Key}");
|
|
||||||
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
|
|
||||||
builder.AddContent(2, kv.Key);
|
|
||||||
builder.CloseElement();
|
|
||||||
|
|
||||||
builder.OpenElement(3, "dd");
|
|
||||||
builder.SetKey($"dd-{kv.Key}");
|
|
||||||
builder.AddAttribute(4, "class", "col-8 font-monospace");
|
|
||||||
builder.AddContent(5, kv.Value);
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static bool TryPrettyPrintJson(string text, out string formatted)
|
|
||||||
{
|
|
||||||
formatted = text;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(text);
|
|
||||||
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string PrettyPrintJson(string text)
|
|
||||||
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
|
|
||||||
/// Returns true only when the JSON has a string <c>sql</c> property;
|
|
||||||
/// <c>parameters</c> is treated as an optional object whose values
|
|
||||||
/// stringify to scalar text.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
|
|
||||||
{
|
|
||||||
sql = string.Empty;
|
|
||||||
parameters = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(text);
|
|
||||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
|
|
||||||
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
|
|
||||||
return false;
|
|
||||||
sql = sqlProp.GetString() ?? string.Empty;
|
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
|
|
||||||
&& paramsProp.ValueKind == JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
parameters = new List<KeyValuePair<string, string>>();
|
|
||||||
foreach (var p in paramsProp.EnumerateObject())
|
|
||||||
{
|
|
||||||
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
|
|
||||||
{
|
|
||||||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
|
||||||
JsonValueKind.Null => "null",
|
|
||||||
JsonValueKind.True => "true",
|
|
||||||
JsonValueKind.False => "false",
|
|
||||||
JsonValueKind.Number => value.GetRawText(),
|
|
||||||
_ => value.GetRawText(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task HandleClose()
|
private async Task HandleClose()
|
||||||
{
|
{
|
||||||
if (OnClose.HasDelegate)
|
if (OnClose.HasDelegate)
|
||||||
@@ -254,170 +50,4 @@ public partial class AuditDrilldownDrawer
|
|||||||
await OnClose.InvokeAsync();
|
await OnClose.InvokeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CopyCurl()
|
|
||||||
{
|
|
||||||
if (Event is null) return;
|
|
||||||
|
|
||||||
var curl = BuildCurlCommand(Event);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Clipboard interop can fail (denied permission, prerender, etc.).
|
|
||||||
// The drawer stays open; the failure surfaces in the dev console
|
|
||||||
// only — we deliberately do not toast here because the parent
|
|
||||||
// page owns toast state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowAllForOperation()
|
|
||||||
{
|
|
||||||
if (Event?.CorrelationId is not { } corr) return;
|
|
||||||
var uri = $"/audit/log?correlationId={corr}";
|
|
||||||
Navigation.NavigateTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
|
|
||||||
/// — the universal per-run correlation value, distinct from the per-operation
|
|
||||||
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
|
|
||||||
/// which the page parses on init and auto-loads. The button is only rendered
|
|
||||||
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
|
|
||||||
/// </summary>
|
|
||||||
private void ViewThisExecution()
|
|
||||||
{
|
|
||||||
if (Event?.ExecutionId is not { } exec) return;
|
|
||||||
var uri = $"/audit/log?executionId={exec}";
|
|
||||||
Navigation.NavigateTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
|
|
||||||
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
|
|
||||||
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
|
|
||||||
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
|
|
||||||
/// drill-in target. The button is only rendered when
|
|
||||||
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
|
|
||||||
/// </summary>
|
|
||||||
private void ViewParentExecution()
|
|
||||||
{
|
|
||||||
if (Event?.ParentExecutionId is not { } parentExec) return;
|
|
||||||
var uri = $"/audit/log?executionId={parentExec}";
|
|
||||||
Navigation.NavigateTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
|
|
||||||
/// feature, Task 10). Navigates to
|
|
||||||
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
|
|
||||||
/// resolves the whole chain rooted at the topmost ancestor and renders it
|
|
||||||
/// expandably, with this row's execution highlighted. The button is only
|
|
||||||
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
|
|
||||||
/// is total.
|
|
||||||
/// </summary>
|
|
||||||
private void ViewExecutionChain()
|
|
||||||
{
|
|
||||||
if (Event?.ExecutionId is not { } exec) return;
|
|
||||||
var uri = $"/audit/execution-tree?executionId={exec}";
|
|
||||||
Navigation.NavigateTo(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build a cURL command from an audit event. The URL comes from
|
|
||||||
/// <c>Target</c>; when the RequestSummary parses as
|
|
||||||
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
|
|
||||||
/// and the body into <c>--data-raw</c>. Default method is POST for
|
|
||||||
/// outbound audit rows — the audit pipeline does not always capture
|
|
||||||
/// the verb explicitly.
|
|
||||||
/// </summary>
|
|
||||||
private static string BuildCurlCommand(AuditEvent ev)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append("curl");
|
|
||||||
|
|
||||||
string method = "POST";
|
|
||||||
List<KeyValuePair<string, string>>? headers = null;
|
|
||||||
string? body = null;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ev.RequestSummary))
|
|
||||||
{
|
|
||||||
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append(' ').Append("-X ").Append(method);
|
|
||||||
|
|
||||||
if (headers is not null)
|
|
||||||
{
|
|
||||||
foreach (var (name, value) in headers)
|
|
||||||
{
|
|
||||||
sb.Append(' ').Append("-H ");
|
|
||||||
sb.Append(QuoteShellArg($"{name}: {value}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(body))
|
|
||||||
{
|
|
||||||
sb.Append(' ').Append("--data-raw ");
|
|
||||||
sb.Append(QuoteShellArg(body!));
|
|
||||||
}
|
|
||||||
|
|
||||||
var url = ev.Target ?? string.Empty;
|
|
||||||
sb.Append(' ').Append(QuoteShellArg(url));
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryExtractCurlPartsFromJson(
|
|
||||||
string requestSummary,
|
|
||||||
ref string method,
|
|
||||||
ref List<KeyValuePair<string, string>>? headers,
|
|
||||||
ref string? body)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = JsonDocument.Parse(requestSummary);
|
|
||||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
|
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
|
|
||||||
{
|
|
||||||
method = m.GetString() ?? method;
|
|
||||||
}
|
|
||||||
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
|
|
||||||
{
|
|
||||||
headers = new List<KeyValuePair<string, string>>();
|
|
||||||
foreach (var h in hs.EnumerateObject())
|
|
||||||
{
|
|
||||||
var value = h.Value.ValueKind == JsonValueKind.String
|
|
||||||
? h.Value.GetString() ?? string.Empty
|
|
||||||
: h.Value.GetRawText();
|
|
||||||
headers.Add(new KeyValuePair<string, string>(h.Name, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (doc.RootElement.TryGetProperty("body", out var b))
|
|
||||||
{
|
|
||||||
body = b.ValueKind == JsonValueKind.String
|
|
||||||
? b.GetString()
|
|
||||||
: b.GetRawText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// RequestSummary wasn't the expected {headers, body} shape —
|
|
||||||
// just produce a bare cURL with no body/headers.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Quote a single shell argument with single quotes, escaping embedded
|
|
||||||
/// single quotes via the standard <c>'\''</c> idiom. This is the same
|
|
||||||
/// quoting strategy curl examples use across man pages.
|
|
||||||
/// </summary>
|
|
||||||
private static string QuoteShellArg(string value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return "''";
|
|
||||||
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
|
|
||||||
return $"'{escaped}'";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Audit Log drilldown drawer (#23 M7 Bundle C).
|
/* Audit Log drilldown drawer (#23 M7 Bundle C).
|
||||||
The base offcanvas + backdrop classes come from Bootstrap. The local
|
The base offcanvas + backdrop classes come from Bootstrap. The local
|
||||||
overrides below pin our preferred width and pre-block behaviour. */
|
overrides below pin our preferred width and the footer tint. The body
|
||||||
|
(pre-block) styles travel with the markup in AuditEventDetail.razor.css. */
|
||||||
|
|
||||||
.audit-drilldown-drawer {
|
.audit-drilldown-drawer {
|
||||||
/* Slightly wider than the parked-messages drawer because audit rows can
|
/* Slightly wider than the parked-messages drawer because audit rows can
|
||||||
@@ -9,32 +10,6 @@
|
|||||||
width: min(720px, 95vw);
|
width: min(720px, 95vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-pre {
|
|
||||||
/* Wrap long lines and bound the per-block height so the drawer body
|
|
||||||
stays scrollable end-to-end instead of pushing the action buttons
|
|
||||||
below the fold. */
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-pre.json {
|
|
||||||
/* JSON blocks get a faint left rule so they read as quoted material. */
|
|
||||||
border-left: 3px solid var(--bs-info-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-pre code.language-sql {
|
|
||||||
/* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
|
|
||||||
a slightly different background so the SQL block reads distinct from
|
|
||||||
generic JSON pretty-prints without loading a syntax-highlighter JS
|
|
||||||
library. */
|
|
||||||
font-family: var(--bs-font-monospace);
|
|
||||||
color: var(--bs-emphasis-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-drilldown-drawer .drawer-footer {
|
.audit-drilldown-drawer .drawer-footer {
|
||||||
background-color: var(--bs-tertiary-bg);
|
background-color: var(--bs-tertiary-bg);
|
||||||
}
|
}
|
||||||
|
|||||||
165
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
165
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
@using ScadaLink.Commons.Entities.Audit
|
||||||
|
@using ScadaLink.Commons.Types.Enums
|
||||||
|
|
||||||
|
@* Reusable single-AuditEvent detail body (#23 M7 Bundle C / M7-T4..T8).
|
||||||
|
Extracted from AuditDrilldownDrawer so the drawer and the execution-tree
|
||||||
|
node-detail modal share one rendering of a row's detail.
|
||||||
|
All form/field rendering follows the form-layout memory:
|
||||||
|
read-only fields first (definition list), then subsections stacked,
|
||||||
|
action buttons at the bottom. *@
|
||||||
|
|
||||||
|
@* Read-only field list — primary identification + provenance. *@
|
||||||
|
<dl class="row mb-3" data-test="drawer-fields">
|
||||||
|
<dt class="col-4 text-muted fw-normal">Channel / Kind</dt>
|
||||||
|
<dd class="col-8" data-test="field-Channel">@Event.Channel / @Event.Kind</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">Status</dt>
|
||||||
|
<dd class="col-8" data-test="field-Status">@Event.Status</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">HttpStatus</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-HttpStatus">@(Event.HttpStatus?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">Target</dt>
|
||||||
|
<dd class="col-8" data-test="field-Target">@(Event.Target ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">Actor</dt>
|
||||||
|
<dd class="col-8" data-test="field-Actor">@(Event.Actor ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
|
||||||
|
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
|
||||||
|
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">SourceScript</dt>
|
||||||
|
<dd class="col-8" data-test="field-SourceScript">@(Event.SourceScript ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">IngestedAtUtc</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-IngestedAtUtc">@(Event.IngestedAtUtc.HasValue ? FormatTimestamp(Event.IngestedAtUtc.Value) : "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-4 text-muted fw-normal">DurationMs</dt>
|
||||||
|
<dd class="col-8 font-monospace" data-test="field-DurationMs">@(Event.DurationMs?.ToString() ?? "—")</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@* Error subsection — only shown when there is something to report. *@
|
||||||
|
@if (!string.IsNullOrEmpty(Event.ErrorMessage) || !string.IsNullOrEmpty(Event.ErrorDetail))
|
||||||
|
{
|
||||||
|
<section class="mb-3" data-test="section-error">
|
||||||
|
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Error</h6>
|
||||||
|
@if (!string.IsNullOrEmpty(Event.ErrorMessage))
|
||||||
|
{
|
||||||
|
<p class="text-danger mb-1">@Event.ErrorMessage</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Event.ErrorDetail))
|
||||||
|
{
|
||||||
|
<pre class="bg-light border rounded p-2 mb-0 drawer-pre">@Event.ErrorDetail</pre>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Request body (channel-aware renderer). *@
|
||||||
|
@if (!string.IsNullOrEmpty(Event.RequestSummary))
|
||||||
|
{
|
||||||
|
<section class="mb-3" data-test="section-request">
|
||||||
|
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
||||||
|
<span>Request</span>
|
||||||
|
@if (IsRedacted(Event.RequestSummary))
|
||||||
|
{
|
||||||
|
<span data-test="redaction-badge-request"
|
||||||
|
class="badge bg-warning text-dark"
|
||||||
|
title="Sensitive values redacted by audit pipeline">
|
||||||
|
Redacted
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
<div data-test="request-body">
|
||||||
|
@RenderBody(Event.RequestSummary!, Event.Channel)
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Response body (channel-aware renderer). *@
|
||||||
|
@if (!string.IsNullOrEmpty(Event.ResponseSummary))
|
||||||
|
{
|
||||||
|
<section class="mb-3" data-test="section-response">
|
||||||
|
<h6 class="text-uppercase text-muted small fw-semibold mb-1 d-flex align-items-center gap-2">
|
||||||
|
<span>Response</span>
|
||||||
|
@if (IsRedacted(Event.ResponseSummary))
|
||||||
|
{
|
||||||
|
<span data-test="redaction-badge-response"
|
||||||
|
class="badge bg-warning text-dark"
|
||||||
|
title="Sensitive values redacted by audit pipeline">
|
||||||
|
Redacted
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</h6>
|
||||||
|
<div data-test="response-body">
|
||||||
|
@RenderBody(Event.ResponseSummary!, Event.Channel)
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Extra is always JSON when present. *@
|
||||||
|
@if (!string.IsNullOrEmpty(Event.Extra))
|
||||||
|
{
|
||||||
|
<section class="mb-3" data-test="section-extra">
|
||||||
|
<h6 class="text-uppercase text-muted small fw-semibold mb-1">Extra</h6>
|
||||||
|
<pre class="bg-light border rounded p-2 mb-0 drawer-pre json">@PrettyPrintJson(Event.Extra!)</pre>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Action buttons at the bottom per form-layout memory. *@
|
||||||
|
<div class="d-flex gap-2 flex-wrap" data-test="audit-event-detail-actions">
|
||||||
|
@if (IsApiChannel(Event.Channel))
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="copy-as-curl"
|
||||||
|
@onclick="CopyCurl">
|
||||||
|
Copy as cURL
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (Event.CorrelationId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="show-all-events"
|
||||||
|
@onclick="ShowAllForOperation">
|
||||||
|
Show all events for this operation
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (Event.ExecutionId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="view-this-execution"
|
||||||
|
@onclick="ViewThisExecution">
|
||||||
|
View this execution
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (Event.ParentExecutionId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="view-parent-execution"
|
||||||
|
@onclick="ViewParentExecution">
|
||||||
|
View parent execution
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (Event.ExecutionId is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="view-execution-chain"
|
||||||
|
@onclick="ViewExecutionChain">
|
||||||
|
View execution chain
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reusable single-<see cref="AuditEvent"/> detail body (#23 M7 Bundle C /
|
||||||
|
/// M7-T4..T8). Extracted verbatim from <see cref="AuditDrilldownDrawer"/> so
|
||||||
|
/// the drawer and the execution-tree node-detail modal render a row's detail
|
||||||
|
/// identically. Renders the read-only field list, the conditional
|
||||||
|
/// Error/Request/Response/Extra subsections, and the action buttons (Copy as
|
||||||
|
/// cURL, Show all events for this operation, View this/parent execution, View
|
||||||
|
/// execution chain). The component is fully presentational apart from the
|
||||||
|
/// clipboard interop and drill-back navigation it owns; the host owns its
|
||||||
|
/// surrounding chrome.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Body rendering.</b> Request/Response/Extra summaries are strings.
|
||||||
|
/// JSON is pretty-printed when it parses; falls back to verbatim otherwise.
|
||||||
|
/// DbOutbound payloads carry a <c>{sql, parameters}</c> JSON shape and get a
|
||||||
|
/// SQL code block plus a parameter definition list. Syntax highlighting is
|
||||||
|
/// CSS-class-only (<c>language-sql</c>); no JS library is loaded — Blazor
|
||||||
|
/// Server + Bootstrap only per the project's UI rules.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Redaction badges.</b> The audit pipeline replaces redacted values
|
||||||
|
/// with the literal sentinels <c><redacted></c> or
|
||||||
|
/// <c><redacted: redactor error></c> (see Component-AuditLog.md
|
||||||
|
/// §Redaction). A yellow "Redacted" badge surfaces on a body section when
|
||||||
|
/// its text contains either sentinel — no un-redaction or counting.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Copy as cURL.</b> Best-effort: the URL comes from <c>Target</c>;
|
||||||
|
/// when the RequestSummary parses as <c>{headers, body}</c>, headers are
|
||||||
|
/// folded into <c>-H</c> flags and the body into <c>--data-raw</c>. The
|
||||||
|
/// command is written to the system clipboard via
|
||||||
|
/// <see cref="IJSRuntime.InvokeVoidAsync(string, object?[])"/>. The button
|
||||||
|
/// is only surfaced for API channels (ApiOutbound / ApiInbound).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
|
||||||
|
/// the "Show all events" button navigates to
|
||||||
|
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
||||||
|
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
||||||
|
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
|
||||||
|
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
|
||||||
|
/// — the spawner's id used as the per-run drill-in target. All are deep
|
||||||
|
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class AuditEventDetail
|
||||||
|
{
|
||||||
|
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The row to render. Required and non-null — the host (drawer or modal)
|
||||||
|
/// only mounts this component once it has a row to show.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired] public AuditEvent Event { get; set; } = null!;
|
||||||
|
|
||||||
|
private const string RedactionSentinel = "<redacted>";
|
||||||
|
private const string RedactorErrorSentinel = "<redacted: redactor error>";
|
||||||
|
|
||||||
|
private static bool IsApiChannel(AuditChannel channel)
|
||||||
|
=> channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound;
|
||||||
|
|
||||||
|
private static string FormatTimestamp(DateTime utc)
|
||||||
|
{
|
||||||
|
// Force UTC kind in case the row arrived as Unspecified, then emit
|
||||||
|
// round-trip ISO-8601 so audit drilldowns are copy-paste safe.
|
||||||
|
var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc;
|
||||||
|
return kind.ToString("o", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRedacted(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return false;
|
||||||
|
return text.Contains(RedactionSentinel, StringComparison.Ordinal)
|
||||||
|
|| text.Contains(RedactorErrorSentinel, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel-aware body renderer. DbOutbound bodies that parse as
|
||||||
|
/// <c>{sql, parameters}</c> get a SQL block + parameter list; anything
|
||||||
|
/// else falls back to JSON-pretty-print, then plain-text verbatim.
|
||||||
|
/// </summary>
|
||||||
|
private RenderFragment RenderBody(string body, AuditChannel channel) => builder =>
|
||||||
|
{
|
||||||
|
// DbOutbound special-case: try to extract {sql, parameters}.
|
||||||
|
if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters))
|
||||||
|
{
|
||||||
|
builder.OpenElement(0, "pre");
|
||||||
|
builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre");
|
||||||
|
builder.OpenElement(2, "code");
|
||||||
|
// Highlighting is CSS-class-only — no JS library is loaded.
|
||||||
|
builder.AddAttribute(3, "class", "language-sql");
|
||||||
|
builder.AddContent(4, sql);
|
||||||
|
builder.CloseElement();
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
if (parameters is not null && parameters.Count > 0)
|
||||||
|
{
|
||||||
|
builder.OpenElement(10, "dl");
|
||||||
|
builder.AddAttribute(11, "class", "row mb-0 small");
|
||||||
|
builder.AddAttribute(12, "data-test", "sql-parameters");
|
||||||
|
// The analyzer (ASP0006) requires literal sequence numbers
|
||||||
|
// inside a render fragment. We delegate parameter rendering
|
||||||
|
// to a helper fragment that uses a stable @key per entry,
|
||||||
|
// so per-row diffing stays correct even though the outer
|
||||||
|
// sequence number is fixed.
|
||||||
|
builder.AddContent(13, BuildSqlParameterRows(parameters));
|
||||||
|
builder.CloseElement();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic JSON pretty-print path.
|
||||||
|
if (TryPrettyPrintJson(body, out var pretty))
|
||||||
|
{
|
||||||
|
builder.OpenElement(20, "pre");
|
||||||
|
builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json");
|
||||||
|
builder.AddContent(22, pretty);
|
||||||
|
builder.CloseElement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: verbatim. Wrapping in <pre> preserves whitespace, which
|
||||||
|
// is useful when the body is multi-line plain text or a partial JSON.
|
||||||
|
builder.OpenElement(30, "pre");
|
||||||
|
builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
|
||||||
|
builder.AddContent(32, body);
|
||||||
|
builder.CloseElement();
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RenderFragment BuildSqlParameterRows(List<KeyValuePair<string, string>> parameters) => builder =>
|
||||||
|
{
|
||||||
|
foreach (var kv in parameters)
|
||||||
|
{
|
||||||
|
// Literal sequence numbers (ASP0006) + per-element SetKey so
|
||||||
|
// Blazor's diff is still keyed on parameter name. The "0" base
|
||||||
|
// is fine here — each loop iteration produces a disjoint
|
||||||
|
// dt/dd pair, and the diff keys on @key, not sequence.
|
||||||
|
builder.OpenElement(0, "dt");
|
||||||
|
builder.SetKey($"dt-{kv.Key}");
|
||||||
|
builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
|
||||||
|
builder.AddContent(2, kv.Key);
|
||||||
|
builder.CloseElement();
|
||||||
|
|
||||||
|
builder.OpenElement(3, "dd");
|
||||||
|
builder.SetKey($"dd-{kv.Key}");
|
||||||
|
builder.AddAttribute(4, "class", "col-8 font-monospace");
|
||||||
|
builder.AddContent(5, kv.Value);
|
||||||
|
builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool TryPrettyPrintJson(string text, out string formatted)
|
||||||
|
{
|
||||||
|
formatted = text;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PrettyPrintJson(string text)
|
||||||
|
=> TryPrettyPrintJson(text, out var pretty) ? pretty : text;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort parse of a DbOutbound <c>{sql, parameters}</c> body.
|
||||||
|
/// Returns true only when the JSON has a string <c>sql</c> property;
|
||||||
|
/// <c>parameters</c> is treated as an optional object whose values
|
||||||
|
/// stringify to scalar text.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParseDbBody(string text, out string sql, out List<KeyValuePair<string, string>>? parameters)
|
||||||
|
{
|
||||||
|
sql = string.Empty;
|
||||||
|
parameters = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
|
||||||
|
if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
|
||||||
|
return false;
|
||||||
|
sql = sqlProp.GetString() ?? string.Empty;
|
||||||
|
|
||||||
|
if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
|
||||||
|
&& paramsProp.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
parameters = new List<KeyValuePair<string, string>>();
|
||||||
|
foreach (var p in paramsProp.EnumerateObject())
|
||||||
|
{
|
||||||
|
parameters.Add(new KeyValuePair<string, string>(p.Name, StringifyJsonValue(p.Value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||||
|
JsonValueKind.Null => "null",
|
||||||
|
JsonValueKind.True => "true",
|
||||||
|
JsonValueKind.False => "false",
|
||||||
|
JsonValueKind.Number => value.GetRawText(),
|
||||||
|
_ => value.GetRawText(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task CopyCurl()
|
||||||
|
{
|
||||||
|
var curl = BuildCurlCommand(Event);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Clipboard interop can fail (denied permission, prerender, etc.).
|
||||||
|
// The component stays mounted; the failure surfaces in the dev
|
||||||
|
// console only — we deliberately do not toast here because the
|
||||||
|
// parent page owns toast state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowAllForOperation()
|
||||||
|
{
|
||||||
|
if (Event.CorrelationId is not { } corr) return;
|
||||||
|
var uri = $"/audit/log?correlationId={corr}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
|
||||||
|
/// — the universal per-run correlation value, distinct from the per-operation
|
||||||
|
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
|
||||||
|
/// which the page parses on init and auto-loads. The button is only rendered
|
||||||
|
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
|
||||||
|
/// </summary>
|
||||||
|
private void ViewThisExecution()
|
||||||
|
{
|
||||||
|
if (Event.ExecutionId is not { } exec) return;
|
||||||
|
var uri = $"/audit/log?executionId={exec}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
|
||||||
|
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
|
||||||
|
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
|
||||||
|
/// drill-in target. The button is only rendered when
|
||||||
|
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
|
||||||
|
/// </summary>
|
||||||
|
private void ViewParentExecution()
|
||||||
|
{
|
||||||
|
if (Event.ParentExecutionId is not { } parentExec) return;
|
||||||
|
var uri = $"/audit/log?executionId={parentExec}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). Navigates to
|
||||||
|
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
|
||||||
|
/// resolves the whole chain rooted at the topmost ancestor and renders it
|
||||||
|
/// expandably, with this row's execution highlighted. The button is only
|
||||||
|
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
|
||||||
|
/// is total.
|
||||||
|
/// </summary>
|
||||||
|
private void ViewExecutionChain()
|
||||||
|
{
|
||||||
|
if (Event.ExecutionId is not { } exec) return;
|
||||||
|
var uri = $"/audit/execution-tree?executionId={exec}";
|
||||||
|
Navigation.NavigateTo(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a cURL command from an audit event. The URL comes from
|
||||||
|
/// <c>Target</c>; when the RequestSummary parses as
|
||||||
|
/// <c>{headers, body, method?}</c>, headers fold into <c>-H</c> flags
|
||||||
|
/// and the body into <c>--data-raw</c>. Default method is POST for
|
||||||
|
/// outbound audit rows — the audit pipeline does not always capture
|
||||||
|
/// the verb explicitly.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildCurlCommand(AuditEvent ev)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("curl");
|
||||||
|
|
||||||
|
string method = "POST";
|
||||||
|
List<KeyValuePair<string, string>>? headers = null;
|
||||||
|
string? body = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(ev.RequestSummary))
|
||||||
|
{
|
||||||
|
TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(' ').Append("-X ").Append(method);
|
||||||
|
|
||||||
|
if (headers is not null)
|
||||||
|
{
|
||||||
|
foreach (var (name, value) in headers)
|
||||||
|
{
|
||||||
|
sb.Append(' ').Append("-H ");
|
||||||
|
sb.Append(QuoteShellArg($"{name}: {value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(body))
|
||||||
|
{
|
||||||
|
sb.Append(' ').Append("--data-raw ");
|
||||||
|
sb.Append(QuoteShellArg(body!));
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = ev.Target ?? string.Empty;
|
||||||
|
sb.Append(' ').Append(QuoteShellArg(url));
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryExtractCurlPartsFromJson(
|
||||||
|
string requestSummary,
|
||||||
|
ref string method,
|
||||||
|
ref List<KeyValuePair<string, string>>? headers,
|
||||||
|
ref string? body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(requestSummary);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return;
|
||||||
|
|
||||||
|
if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
method = m.GetString() ?? method;
|
||||||
|
}
|
||||||
|
if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
headers = new List<KeyValuePair<string, string>>();
|
||||||
|
foreach (var h in hs.EnumerateObject())
|
||||||
|
{
|
||||||
|
var value = h.Value.ValueKind == JsonValueKind.String
|
||||||
|
? h.Value.GetString() ?? string.Empty
|
||||||
|
: h.Value.GetRawText();
|
||||||
|
headers.Add(new KeyValuePair<string, string>(h.Name, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (doc.RootElement.TryGetProperty("body", out var b))
|
||||||
|
{
|
||||||
|
body = b.ValueKind == JsonValueKind.String
|
||||||
|
? b.GetString()
|
||||||
|
: b.GetRawText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// RequestSummary wasn't the expected {headers, body} shape —
|
||||||
|
// just produce a bare cURL with no body/headers.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quote a single shell argument with single quotes, escaping embedded
|
||||||
|
/// single quotes via the standard <c>'\''</c> idiom. This is the same
|
||||||
|
/// quoting strategy curl examples use across man pages.
|
||||||
|
/// </summary>
|
||||||
|
private static string QuoteShellArg(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return "''";
|
||||||
|
var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
|
||||||
|
return $"'{escaped}'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* Body-specific styles for the shared single-AuditEvent detail
|
||||||
|
(#23 M7 Bundle C). Moved here from AuditDrilldownDrawer.razor.css so the
|
||||||
|
scoped CSS travels with the markup — these rules apply wherever the
|
||||||
|
detail body is hosted (drilldown drawer or execution-tree node modal). */
|
||||||
|
|
||||||
|
.drawer-pre {
|
||||||
|
/* Wrap long lines and bound the per-block height so the host body stays
|
||||||
|
scrollable end-to-end instead of pushing the action buttons below the
|
||||||
|
fold. */
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-pre.json {
|
||||||
|
/* JSON blocks get a faint left rule so they read as quoted material. */
|
||||||
|
border-left: 3px solid var(--bs-info-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-pre code.language-sql {
|
||||||
|
/* CSS-only highlight cue: SQL stays mono with a hint of bold weight on
|
||||||
|
a slightly different background so the SQL block reads distinct from
|
||||||
|
generic JSON pretty-prints without loading a syntax-highlighter JS
|
||||||
|
library. */
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
color: var(--bs-emphasis-color);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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">
|
||||||
|
← 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>
|
||||||
|
}
|
||||||
@@ -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 4–5 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@
|
|||||||
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
|
<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">
|
<div class="execution-tree-headline">
|
||||||
<a class="execution-tree-link font-monospace"
|
<a class="execution-tree-link font-monospace"
|
||||||
data-test="tree-node-link-@node.ExecutionId"
|
data-test="tree-node-link-@node.ExecutionId"
|
||||||
@@ -116,6 +117,7 @@
|
|||||||
the flat-list assembly entirely. *@
|
the flat-list assembly entirely. *@
|
||||||
<ExecutionTree PreBuiltRoots="subtree.Children"
|
<ExecutionTree PreBuiltRoots="subtree.Children"
|
||||||
ArrivedFromExecutionId="ArrivedFromExecutionId"
|
ArrivedFromExecutionId="ArrivedFromExecutionId"
|
||||||
|
OnNodeActivated="OnNodeActivated"
|
||||||
Depth="Depth + 1" />
|
Depth="Depth + 1" />
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ public partial class ExecutionTree
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public int Depth { get; set; }
|
[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,
|
// The subtrees this instance renders: assembled from Nodes on the root,
|
||||||
// or taken straight from PreBuiltRoots on a nested instance.
|
// or taken straight from PreBuiltRoots on a nested instance.
|
||||||
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
position: relative;
|
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 {
|
.execution-tree-node {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -35,6 +38,7 @@
|
|||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The execution the user drilled in from — a left accent rule + tinted
|
/* The execution the user drilled in from — a left accent rule + tinted
|
||||||
|
|||||||
@@ -52,7 +52,14 @@
|
|||||||
View this execution in the Audit Log
|
View this execution in the Audit Log
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ public partial class ExecutionTreePage
|
|||||||
private bool _loading;
|
private bool _loading;
|
||||||
private string? _error;
|
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()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_executionId = ParseExecutionId();
|
_executionId = ParseExecutionId();
|
||||||
@@ -90,4 +99,22 @@ public partial class ExecutionTreePage
|
|||||||
_loading = false;
|
_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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
|||||||
/// drawer's "View parent execution" action on a spawned (child) row drills in
|
/// drawer's "View parent execution" action on a spawned (child) row drills in
|
||||||
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
|
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
|
||||||
/// rows.</item>
|
/// 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> —
|
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||||
/// the report page wires drill-in links when notifications are present.</item>
|
/// the report page wires drill-in links when notifications are present.</item>
|
||||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
/// <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]
|
[Fact]
|
||||||
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
|||||||
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#23 M7 Bundle C / M7-T4..T8).
|
/// 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
|
/// 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
|
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
|
||||||
/// channel-aware bodies (JSON pretty-print, SQL block for DbOutbound),
|
/// the <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
|
||||||
/// redaction badges on Request/Response, and conditional action buttons:
|
/// component, which since the recent refactor owns the channel-aware bodies
|
||||||
/// "Copy as cURL" (API channels only) + "Show all events for this operation"
|
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
|
||||||
/// (when CorrelationId is set).
|
/// Request/Response, and conditional action buttons.
|
||||||
///
|
///
|
||||||
/// Tests pin the behaviours we cannot lose without breaking the spec:
|
/// Tests pin the behaviours we cannot lose without breaking the spec:
|
||||||
/// field rendering, JSON pretty-printing, SQL render block, conditional button
|
/// offcanvas open/close, header rendering, and that the event body is handed
|
||||||
/// visibility, navigation drill-back, redaction badges, and clipboard interop.
|
/// off to <see cref="AuditEventDetail"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuditDrilldownDrawerTests : BunitContext
|
public class AuditDrilldownDrawerTests : BunitContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
|||||||
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
|
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
|
||||||
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
|
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
|
||||||
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
|
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
|
||||||
/// presentation, the arrived-from highlight, node-click navigation, and
|
/// presentation, the arrived-from highlight, node-click navigation, node
|
||||||
/// cycle-safety (a corrupt flat list must not infinite-loop).
|
/// double-click raising/bubbling <see cref="ExecutionTreeNode.ExecutionId"/> via
|
||||||
|
/// <c>OnNodeActivated</c>, and cycle-safety (a corrupt flat list must not
|
||||||
|
/// infinite-loop).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExecutionTreeTests : BunitContext
|
public class ExecutionTreeTests : BunitContext
|
||||||
{
|
{
|
||||||
@@ -227,6 +229,63 @@ public class ExecutionTreeTests : BunitContext
|
|||||||
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
|
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)
|
private static int CountOccurrences(string haystack, string needle)
|
||||||
{
|
{
|
||||||
int count = 0, idx = 0;
|
int count = 0, idx = 0;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using Microsoft.AspNetCore.Components.Authorization;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using ScadaLink.CentralUI.Services;
|
using ScadaLink.CentralUI.Services;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage;
|
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>());
|
_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]
|
[Fact]
|
||||||
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user