Compare commits
77 Commits
feature/au
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3345a0fc1 | ||
|
|
e6ccee1a16 | ||
|
|
e567eb334c | ||
|
|
7d87994ac0 | ||
|
|
651c4b6833 | ||
|
|
7efb004a02 | ||
|
|
a8d2e13d4e | ||
|
|
7b619d711d | ||
|
|
c5b27361c0 | ||
|
|
441ec087a7 | ||
|
|
0670864160 | ||
|
|
f8127d5754 | ||
|
|
bb6f6aaa54 | ||
|
|
c07cc379e6 | ||
|
|
86ee7bd1a8 | ||
|
|
d4abacc0d8 | ||
|
|
b07f43a308 | ||
|
|
b628b869fa | ||
|
|
d4a7344f89 | ||
|
|
35cef4ad1b | ||
|
|
3f1ad08f42 | ||
|
|
5c86983ef6 | ||
|
|
386cd0b955 | ||
|
|
603995d43a | ||
|
|
6a6d0e88a7 | ||
|
|
fd07654c68 | ||
|
|
d5623e98bd | ||
|
|
afd81c32ef | ||
|
|
3f1c0e5018 | ||
|
|
16f800b76a | ||
|
|
9ec83d5070 | ||
|
|
933f0484ba | ||
|
|
fb1312d0bf | ||
|
|
592cbd028e | ||
|
|
9b1f78638b | ||
|
|
34a4356625 | ||
|
|
0b5723b777 | ||
|
|
252bf0a970 | ||
|
|
255dd95cd9 | ||
|
|
d35551efc2 | ||
|
|
c00603e2a4 | ||
|
|
150ba5e63f | ||
|
|
6af2607a50 | ||
|
|
dc2c73b07d | ||
|
|
d8453bfba2 | ||
|
|
50430b9daa | ||
|
|
0a8709e5c5 | ||
|
|
e4b37e2798 | ||
|
|
6be26e2813 | ||
|
|
156e560171 | ||
|
|
5198b114b4 | ||
|
|
fd76c19007 | ||
|
|
24cdfe373c | ||
|
|
1ba62052d6 | ||
|
|
cfd8f1ecf4 | ||
|
|
6aac4c8ed7 | ||
|
|
85bb61a1f3 | ||
|
|
705ae95404 | ||
|
|
6f5a35f222 | ||
|
|
0149ce6180 | ||
|
|
6b16a48886 | ||
|
|
990731d12f | ||
|
|
fd12021984 | ||
|
|
4002f4197b | ||
|
|
6ffa47f258 | ||
|
|
c9229c35fc | ||
|
|
aadb1fd72a | ||
|
|
8243f61e96 | ||
|
|
53508c79b2 | ||
|
|
849a011400 | ||
|
|
405de525ca | ||
|
|
77922abb33 | ||
|
|
5f544bfe1e | ||
|
|
aaa6df24cf | ||
|
|
ae7329034f | ||
|
|
e36f0bf9c8 | ||
|
|
a3eb659b75 |
@@ -132,6 +132,8 @@ This project contains design documentation for a distributed SCADA system built
|
|||||||
- Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them.
|
- Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them.
|
||||||
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
||||||
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
||||||
|
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
||||||
|
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; first cut bridges the inbound API → routed-site-script case (the routed run records the inbound request's `ExecutionId`; the inbound row stays top-level / NULL); `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk; tag cascade deferred.
|
||||||
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
||||||
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
||||||
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in.
|
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in.
|
||||||
|
|||||||
115
docs/plans/2026-05-21-audit-executionid-design.md
Normal file
115
docs/plans/2026-05-21-audit-executionid-design.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Audit Log — ExecutionId Universal Correlation (Design)
|
||||||
|
|
||||||
|
**Date:** 2026-05-21
|
||||||
|
**Status:** Validated — ready for implementation planning.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The audit `CorrelationId` column is overloaded with three incompatible meanings —
|
||||||
|
`TrackedOperationId` for cached calls, `NotificationId` for notifications, the
|
||||||
|
script-execution id for sync calls (added 2026-05-21), and request-local ids for
|
||||||
|
inbound. It is `NULL` for sync one-shot calls. There is no single value that ties
|
||||||
|
together *everything one script run (or inbound request) did*: a run that makes a
|
||||||
|
sync API call, a cached call and a notification produces three unrelated
|
||||||
|
correlation ids, and nothing links the cached call's lifecycle rows back to the
|
||||||
|
run that launched them.
|
||||||
|
|
||||||
|
A single `CorrelationId` column cannot serve both scopes — the **operation
|
||||||
|
lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's
|
||||||
|
`Send→Deliver`, which the Site Calls / Notifications "View audit history"
|
||||||
|
drill-ins depend on) and the **execution trace** (all operations of one run).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies
|
||||||
|
the originating **script execution** or **inbound API request**. Every audit row
|
||||||
|
that execution produces carries the same `ExecutionId`. `CorrelationId` is left
|
||||||
|
exactly as it is — it keeps the per-operation lifecycle meaning, so the existing
|
||||||
|
operation drill-ins are unaffected.
|
||||||
|
|
||||||
|
Result: `WHERE ExecutionId = X` returns every audit row of one run — sync
|
||||||
|
`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`,
|
||||||
|
`NotifyDeliver`, and the inbound row — across both the site and central tables.
|
||||||
|
|
||||||
|
`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`,
|
||||||
|
added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the
|
||||||
|
new column from every emitter and threads it to the two paths where the script
|
||||||
|
context is not in scope.
|
||||||
|
|
||||||
|
### Considered and rejected
|
||||||
|
|
||||||
|
- **Overload `CorrelationId`** with the execution id everywhere — breaks the
|
||||||
|
cached-call / notification "View audit history" drill-ins (they filter
|
||||||
|
`CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to
|
||||||
|
show the whole run instead of the one operation.
|
||||||
|
- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is
|
||||||
|
unindexed; filtering an audit table of this volume by it is unworkable.
|
||||||
|
|
||||||
|
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. |
|
||||||
|
| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. |
|
||||||
|
| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). |
|
||||||
|
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. |
|
||||||
|
| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. |
|
||||||
|
|
||||||
|
`SiteCalls` needs no new column — the cached telemetry packet already carries the
|
||||||
|
audit half, which now has `ExecutionId` directly.
|
||||||
|
|
||||||
|
## Emitter coverage — every audit row carries `ExecutionId`
|
||||||
|
|
||||||
|
| Emitter | `ExecutionId` source |
|
||||||
|
|---|---|
|
||||||
|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) |
|
||||||
|
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id |
|
||||||
|
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). |
|
||||||
|
| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id |
|
||||||
|
| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row |
|
||||||
|
| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` |
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
- **Site script run** — `ScriptRuntimeContext` generates the execution id (or is
|
||||||
|
given one); every emitter it owns stamps `ExecutionId`.
|
||||||
|
- **Buffered cached call** — the execution id rides on the S&F buffered message;
|
||||||
|
the retry loop reconstructs it into `CachedCallAttemptContext`;
|
||||||
|
`CachedCallLifecycleBridge` stamps it on the retry-loop audit rows.
|
||||||
|
- **Notification** — the `NotifySend` row stamps it site-side; the id travels on
|
||||||
|
`NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the
|
||||||
|
dispatcher stamps every `NotifyDeliver` row it emits.
|
||||||
|
- **Inbound API request** — `AuditWriteMiddleware` mints a request id and stamps
|
||||||
|
the inbound audit row.
|
||||||
|
|
||||||
|
## UI / CLI surface
|
||||||
|
|
||||||
|
- **Central UI Audit Log page** — `ExecutionId` added as a results-grid column
|
||||||
|
(the grid already supports resize/reorder); an `ExecutionId` paste-filter in
|
||||||
|
the filter bar; the page accepts `?executionId=<guid>`; a row drill-in
|
||||||
|
"View this execution" → `/audit/log?executionId=<guid>`.
|
||||||
|
- **CLI** — `scadalink audit query --execution-id <guid>`.
|
||||||
|
- **ManagementService** — `/api/audit/query` and the export endpoint accept an
|
||||||
|
`executionId` filter parameter.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Two additive nullable columns; additive proto field; additive message-contract
|
||||||
|
fields — all version-compatible. No data backfill; historical rows keep
|
||||||
|
`ExecutionId = NULL`.
|
||||||
|
- `CorrelationId` semantics unchanged — every existing drill-in keeps working.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Repository: query-by-`ExecutionId`; migration smoke test.
|
||||||
|
- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle
|
||||||
|
rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`.
|
||||||
|
- Integration: a script run that does a sync call + a cached call + a notification
|
||||||
|
→ all resulting audit rows share one `ExecutionId` end-to-end.
|
||||||
|
- Central UI: bUnit (grid column, filter, drill-in) + Playwright.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Bridging the inbound request id into the routed site script's execution
|
||||||
|
(cross-cluster threading) — a separate future change.
|
||||||
|
- Backfilling `ExecutionId` on historical audit rows.
|
||||||
155
docs/plans/2026-05-21-audit-executionid.md
Normal file
155
docs/plans/2026-05-21-audit-executionid.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Audit Log ExecutionId — 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:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
|
||||||
|
|
||||||
|
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
|
||||||
|
|
||||||
|
**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Prep — verify branch + baseline
|
||||||
|
|
||||||
|
**Files:** none.
|
||||||
|
|
||||||
|
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
|
||||||
|
|
||||||
|
**Acceptance:** on the branch, solution builds clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
|
||||||
|
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogExecutionId` — `ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
|
||||||
|
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
|
||||||
|
|
||||||
|
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Foundation — site SQLite + gRPC DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
|
||||||
|
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
|
||||||
|
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ExecutionId` ↔ `execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Site script-side emitters stamp `ExecutionId`
|
||||||
|
|
||||||
|
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
|
||||||
|
- Rename the field `_auditCorrelationId` → `_executionId` (and the ctor param `auditCorrelationId` → `executionId`) for clarity; update XML docs. Thread it to the helpers as today.
|
||||||
|
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
|
||||||
|
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
|
||||||
|
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
|
||||||
|
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
|
||||||
|
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
|
||||||
|
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
|
||||||
|
|
||||||
|
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/Notification/` — `NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
|
||||||
|
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
|
||||||
|
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
|
||||||
|
- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Inbound rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` — `EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
|
||||||
|
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Central UI — ExecutionId column, filter, drill-in
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
|
||||||
|
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
|
||||||
|
|
||||||
|
Use the `frontend-design` skill for the column/filter styling.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: CLI + ManagementService — ExecutionId filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
|
||||||
|
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
|
||||||
|
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
|
||||||
|
|
||||||
|
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: End-to-end integration test + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
|
||||||
|
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
|
||||||
|
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
|
||||||
|
|
||||||
|
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final review
|
||||||
|
|
||||||
|
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
|
||||||
|
|
||||||
|
## Dependency summary
|
||||||
|
|
||||||
|
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.
|
||||||
16
docs/plans/2026-05-21-audit-executionid.md.tasks.json
Normal file
16
docs/plans/2026-05-21-audit-executionid.md.tasks.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||||
|
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
|
||||||
|
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
|
||||||
|
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-21T00:00:00Z"
|
||||||
|
}
|
||||||
222
docs/plans/2026-05-21-audit-parent-executionid-design.md
Normal file
222
docs/plans/2026-05-21-audit-parent-executionid-design.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Audit Log — Cross-Execution Correlation (`ParentExecutionId`) Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-21
|
||||||
|
**Status:** Validated — ready for implementation planning.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Audit Log carries `ExecutionId` (`Guid?`) — a universal per-run correlation
|
||||||
|
value stamped on every audit row, identifying the originating script execution
|
||||||
|
or inbound API request. It is **per-execution and flat**: `WHERE ExecutionId = X`
|
||||||
|
returns everything *one* run did, but nothing links an execution to the
|
||||||
|
execution that *spawned* it. A call chain cannot be traced across the execution
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
Two cross-execution cases exist:
|
||||||
|
|
||||||
|
1. **Inbound API request → routed site script.** An inbound HTTP request runs an
|
||||||
|
inbound method script (`InboundScriptExecutor`, central) which calls
|
||||||
|
`Route.Call(scriptName, params)`; that sends a `RouteToCallRequest` to a site
|
||||||
|
instance, which runs `scriptName` as a fresh site-side execution. The inbound
|
||||||
|
request and the routed site script get two unrelated `ExecutionId`s.
|
||||||
|
2. **Tag cascade.** Script A writes an attribute; the attribute change triggers
|
||||||
|
script B as a separate execution. A and B are unrelated.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a dedicated, nullable **`ParentExecutionId`** (`Guid?`) column to the audit
|
||||||
|
row. Every execution still gets its own fresh `ExecutionId` (unchanged). An
|
||||||
|
execution *spawned by* another carries the spawner's `ExecutionId` in its
|
||||||
|
`ParentExecutionId`; a top-level (tag/timer/inbound/un-bridged) execution leaves
|
||||||
|
it null. Walking `ParentExecutionId → ExecutionId` recursively reconstructs the
|
||||||
|
chain as a tree.
|
||||||
|
|
||||||
|
**First cut — in scope:** case 1 only, the **inbound → routed-site-script
|
||||||
|
bridge**. It is the most concrete case and the spawn point is an explicit,
|
||||||
|
threadable RPC (`RouteToCallRequest`).
|
||||||
|
|
||||||
|
**Out of scope:** case 2 (tag cascade) — the trigger is data-driven and
|
||||||
|
decoupled; "which execution wrote the tag that triggered me" is not tracked
|
||||||
|
anywhere today. Deferred as a follow-up. The `ParentExecutionId` model
|
||||||
|
generalises to it with no schema change if that data is ever threaded.
|
||||||
|
|
||||||
|
### Considered and rejected
|
||||||
|
|
||||||
|
- **Reuse `ExecutionId`** — the routed script *adopts* the inbound request's
|
||||||
|
`ExecutionId` instead of generating its own. Cheaper (no new column) but
|
||||||
|
conflates two genuinely separate executions on two clusters, breaks the
|
||||||
|
invariant "one `ExecutionId` = one `ScriptRuntimeContext` run", and does not
|
||||||
|
generalise to tag cascade.
|
||||||
|
- **Point `ParentExecutionId` at the root** (flatten the chain to two levels)
|
||||||
|
instead of the immediate spawner — simpler queries but loses intermediate
|
||||||
|
hops, needs a separately threaded root id, and does not generalise. Rejected
|
||||||
|
in favour of the immediate-spawner tree.
|
||||||
|
|
||||||
|
## Architecture & data flow
|
||||||
|
|
||||||
|
The id propagated is the **inbound API request's `ExecutionId`**. The chain:
|
||||||
|
|
||||||
|
1. **Mint the inbound request id once, early.** Today `AuditWriteMiddleware`
|
||||||
|
mints a `Guid.NewGuid()` late, only for the inbound row's `ExecutionId`. Move
|
||||||
|
the mint to the HTTP entry and stash it on `HttpContext.Items`, so both the
|
||||||
|
middleware (writes the `InboundRequest` row at request end) and
|
||||||
|
`InboundScriptExecutor` (needs it *before* the script runs) read the same id.
|
||||||
|
2. **Carry it on the routing RPC.** `RouteHelper.Call` builds a
|
||||||
|
`RouteToCallRequest`; an additive `ParentExecutionId` field is set from the
|
||||||
|
stashed inbound id. (`RouteHelper`'s own per-op GUID is a separate concern —
|
||||||
|
left alone.)
|
||||||
|
3. **Site side: thread it into the routed script's context.** The site handler
|
||||||
|
for `RouteToCallRequest` passes it to a new optional `parentExecutionId` ctor
|
||||||
|
param on `ScriptRuntimeContext` (sibling to the existing `executionId`
|
||||||
|
param). The routed script still generates its **own** fresh `ExecutionId`.
|
||||||
|
4. **Every emitter stamps `ParentExecutionId`** alongside `ExecutionId`.
|
||||||
|
|
||||||
|
**Recursion (immediate-spawner tree).** A routed script that itself calls
|
||||||
|
`Route.Call` threads its own `ExecutionId` onward, so a grandchild's
|
||||||
|
`ParentExecutionId` points at its immediate spawner, not the root. Walk the tree
|
||||||
|
recursively to reconstruct any depth.
|
||||||
|
|
||||||
|
**The inbound request's own row** (`InboundRequest` / `InboundAuthFailure`) is
|
||||||
|
top-level → `ParentExecutionId = NULL`. Only the routed site script and every
|
||||||
|
row it produces carry the pointer.
|
||||||
|
|
||||||
|
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ScadaLink.Commons` | `AuditEvent.ParentExecutionId` (`Guid?`); `RouteToCallRequest.ParentExecutionId` (`Guid?`); `Notification.OriginParentExecutionId` (`Guid?`); `NotificationSubmit.OriginParentExecutionId` (`Guid?`). |
|
||||||
|
| Central MS SQL `AuditLog` | `ParentExecutionId uniqueidentifier NULL` column + partition-aligned index `IX_AuditLog_ParentExecution (ParentExecutionId)` (mirror `AddAuditLogExecutionId`). EF migration — additive nullable column is a metadata-only `ALTER`. |
|
||||||
|
| Central MS SQL `Notifications` | `OriginParentExecutionId uniqueidentifier NULL` column + EF migration (mirror `AddNotificationOriginExecutionId`). |
|
||||||
|
| Site SQLite `auditlog.db` `AuditLog` | `ParentExecutionId TEXT NULL` — added **via the idempotent `ALTER`-if-missing upgrade path** (per commit `5198b11`), never relying on `CREATE TABLE IF NOT EXISTS`. |
|
||||||
|
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `parent_execution_id` field (next free number); `AuditEventDtoMapper` maps it both directions (Guid ↔ string; empty string ↔ null). |
|
||||||
|
| `ScriptRuntimeContext` | optional `parentExecutionId` ctor param + stored `_parentExecutionId` field. |
|
||||||
|
|
||||||
|
`IX_AuditLog_ParentExecution` is load-bearing: the tree view's downward
|
||||||
|
recursive join seeks on it, and it backs the `parentExecutionId` filter.
|
||||||
|
|
||||||
|
`SiteCalls` needs no new column — the cached telemetry packet carries the audit
|
||||||
|
half, which now has `ParentExecutionId` directly.
|
||||||
|
|
||||||
|
## Emitter coverage — full (mirrors the `ExecutionId` rollout)
|
||||||
|
|
||||||
|
Every audit row a routed-script run produces carries `ParentExecutionId`, so
|
||||||
|
`WHERE ParentExecutionId = X` returns the routed run's complete trust-boundary
|
||||||
|
footprint.
|
||||||
|
|
||||||
|
| Emitter | `ParentExecutionId` source |
|
||||||
|
|---|---|
|
||||||
|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext._parentExecutionId` (in scope) |
|
||||||
|
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext._parentExecutionId` |
|
||||||
|
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the S&F buffered message → `CachedCallAttemptContext` → the bridge, as a sibling to the `ExecutionId` already threaded there |
|
||||||
|
| `NotifySend` (site, script-side) | `ScriptRuntimeContext._parentExecutionId` |
|
||||||
|
| `NotifyDeliver` (central dispatch) | `Notifications.OriginParentExecutionId` — rides on `NotificationSubmit`, persisted on the `Notifications` row, dispatcher stamps every `NotifyDeliver` row |
|
||||||
|
| Inbound `InboundRequest` / `InboundAuthFailure` | `NULL` — inbound is top-level |
|
||||||
|
|
||||||
|
The threading reuses the carry points the `ExecutionId` rollout already opened
|
||||||
|
(S&F buffer, `NotificationSubmit` → `Notifications`); `ParentExecutionId` is a
|
||||||
|
sibling field at each, not a new boundary.
|
||||||
|
|
||||||
|
## Recursive chain/tree view
|
||||||
|
|
||||||
|
A new repository method `GetExecutionTreeAsync(Guid executionId)`:
|
||||||
|
|
||||||
|
- **Walk up** to the root: iterative single-parent follow
|
||||||
|
(`SELECT TOP 1 ParentExecutionId WHERE ExecutionId = current AND
|
||||||
|
ParentExecutionId IS NOT NULL`) until null. Cheap — each execution has exactly
|
||||||
|
one parent.
|
||||||
|
- **Walk down** from the root: recursive CTE joining
|
||||||
|
`ParentExecutionId = ancestor.ExecutionId`, seeking on
|
||||||
|
`IX_AuditLog_ParentExecution`. `MAXRECURSION` capped (e.g. 32) — chains are
|
||||||
|
shallow; the cap guards against corrupt/pathological data.
|
||||||
|
- Returns a flat list of execution nodes: `ExecutionId`, `ParentExecutionId`,
|
||||||
|
row count, channels/statuses present, `SourceSiteId`/`SourceInstanceId`,
|
||||||
|
first/last `OccurredAtUtc`. The UI assembles the tree from the flat list.
|
||||||
|
|
||||||
|
**UI.** New route `/audit/execution-tree?executionId=<guid>`, reached via a
|
||||||
|
"View execution chain" drill-in from any audit row and from the `ExecutionId`
|
||||||
|
column. Renders an expandable custom Blazor tree (no component frameworks); each
|
||||||
|
node shows the execution summary; clicking a node filters the Audit Log grid to
|
||||||
|
`?executionId=<node>`. The tree is always rooted at the topmost ancestor, so the
|
||||||
|
reader sees the full chain regardless of which row they entered from.
|
||||||
|
|
||||||
|
Plus the cheaper navigation affordances: `ParentExecutionId` grid column (short
|
||||||
|
form / monospace), a `ParentExecutionId` paste-filter, a `?parentExecutionId=`
|
||||||
|
query param, and a "View parent execution" drill-in (links
|
||||||
|
`?executionId=<parentId>`).
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
- **Parent with no rows of its own.** An execution that performed no
|
||||||
|
trust-boundary action emits no audit rows, yet a child still references it via
|
||||||
|
`ParentExecutionId`. The upward walk resolves the GUID but finds no rows for
|
||||||
|
that node → render it as a stub node ("execution with no audited actions").
|
||||||
|
- **Purged parent.** A parent execution older than the 365-day central
|
||||||
|
retention has no rows → the upward walk stops there; the chain renders as far
|
||||||
|
as it resolves.
|
||||||
|
- **Cycle guard.** The `ParentExecutionId` graph is acyclic by construction
|
||||||
|
(each execution is minted fresh and its parent always pre-exists), but
|
||||||
|
`MAXRECURSION` bounds the downward CTE against corrupt data.
|
||||||
|
|
||||||
|
## CLI / ManagementService
|
||||||
|
|
||||||
|
- CLI: `scadalink audit query --parent-execution-id <guid>`;
|
||||||
|
`AuditLogQueryFilter` gains a `ParentExecutionId` single-value filter
|
||||||
|
dimension (mirror `ExecutionId`).
|
||||||
|
- ManagementService `/api/audit/query` + export endpoint and the CentralUI
|
||||||
|
export endpoints parse a `parentExecutionId` query param (lax-parse —
|
||||||
|
unparseable dropped).
|
||||||
|
- The tree view's data path: `GetExecutionTreeAsync` is exposed however the
|
||||||
|
existing Audit Log page sources its grid data — mirror that path; add a
|
||||||
|
ManagementService endpoint only if the page goes through it.
|
||||||
|
- **No CLI `audit tree` command in the first cut** — the tree is a UI forensic
|
||||||
|
affordance; the `--parent-execution-id` filter covers scripted use. Noted as a
|
||||||
|
possible follow-up.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Additive nullable columns; additive proto field; additive message-contract
|
||||||
|
fields — all version-compatible. No backfill; historical rows keep
|
||||||
|
`ParentExecutionId = NULL`.
|
||||||
|
- `ExecutionId` and `CorrelationId` semantics unchanged — every existing
|
||||||
|
drill-in keeps working.
|
||||||
|
|
||||||
|
## Failure handling
|
||||||
|
|
||||||
|
- Audit-write failure NEVER aborts the user-facing action — unchanged invariant;
|
||||||
|
`ParentExecutionId` is just another field on the row.
|
||||||
|
- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing
|
||||||
|
path (commit `5198b11`); do not repeat the original `CREATE TABLE IF NOT
|
||||||
|
EXISTS` mistake.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Repository: query-by-`ParentExecutionId`; `GetExecutionTreeAsync` (multi-level
|
||||||
|
tree, stub-parent node, `MAXRECURSION` cap); migration smoke test.
|
||||||
|
- Emitter unit tests: each emitter stamps `ParentExecutionId`; the cached-call
|
||||||
|
lifecycle rows from one routed run share it; `NotifyDeliver` echoes
|
||||||
|
`Notifications.OriginParentExecutionId`.
|
||||||
|
- **Headline integration test:** an inbound API request that calls `Route.Call`
|
||||||
|
→ the routed site script does a sync `ExternalSystem.Call`, a cached call, and
|
||||||
|
a `Notify.Send` → every resulting audit row (site + central) carries
|
||||||
|
`ParentExecutionId` = the inbound request's `ExecutionId`, while each has its
|
||||||
|
own distinct `ExecutionId`.
|
||||||
|
- Central UI: bUnit (column renders, filter maps, query param parsed, tree
|
||||||
|
assembled from the flat list) + Playwright (drill-in → tree → node click
|
||||||
|
filters the grid).
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- **Tag cascade (case 2)** — deferred. If the attribute-write path ever carries
|
||||||
|
the writing execution's id into the triggered script's `ScriptRuntimeContext`,
|
||||||
|
the same `ParentExecutionId` column and tree view cover it with no schema
|
||||||
|
change.
|
||||||
|
- CLI `audit tree` command — possible follow-up.
|
||||||
|
- Backfilling `ParentExecutionId` on historical audit rows — not done.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Additive everywhere — nullable columns, additive proto/message fields, no
|
||||||
|
backfill.
|
||||||
|
- Never touch `infra/*`; `alog.md` is the locked v1 spec — do not modify it.
|
||||||
|
- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing
|
||||||
|
path (commit `5198b11`).
|
||||||
220
docs/plans/2026-05-21-audit-parent-executionid.md
Normal file
220
docs/plans/2026-05-21-audit-parent-executionid.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Audit Log ParentExecutionId — 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:** Add a `ParentExecutionId` column to the Audit Log so an execution spawned by another (first cut: an inbound API request that routes to a site script) records a pointer back to its spawner, making audit call chains traceable across the execution boundary.
|
||||||
|
|
||||||
|
**Architecture:** Additive nullable `ParentExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). The inbound API request's `ExecutionId` is minted once at the HTTP entry, threaded onto `RouteToCallRequest` → `ScriptCallRequest` → the routed script's `ScriptRuntimeContext` as a new `parentExecutionId`; the routed script still mints its own fresh `ExecutionId`. Every emitter stamps `ParentExecutionId` as a sibling to `ExecutionId` — through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginParentExecutionId` for central `NotifyDeliver` rows. A recursive repository query plus a Central UI tree view reconstruct the chain. Validated design: `docs/plans/2026-05-21-audit-parent-executionid-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
|
||||||
|
|
||||||
|
**Ground rules (every task):** branch is `feature/audit-parent-executionid` (already created) — never commit to `main`. TDD — write the failing test first, then the 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` is on; `dotnet test ScadaLink.slnx` for touched suites). Additive contract evolution only. Do not push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Prep — verify branch + baseline
|
||||||
|
|
||||||
|
**Files:** none.
|
||||||
|
|
||||||
|
**Steps:** confirm `git branch --show-current` is `feature/audit-parent-executionid`; run `dotnet build ScadaLink.slnx` and confirm it succeeds with 0 warnings.
|
||||||
|
|
||||||
|
**Acceptance:** on the branch, solution builds clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Foundation — `AuditEvent.ParentExecutionId`, central `AuditLog` column, repository query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ParentExecutionId` (sibling to `ExecutionId`, same XML-doc style).
|
||||||
|
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ParentExecutionId` single-value filter dimension (mirror `ExecutionId`).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_ParentExecution (ParentExecutionId)`.
|
||||||
|
- Create: EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogParentExecutionId` — `ParentExecutionId uniqueidentifier NULL` + the index. Mirror `20260521184044_AddAuditLogExecutionId` exactly (partition-aligned index, metadata-only `ALTER`).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ParentExecutionId` to `e.ParentExecutionId == value` (mirror the `ExecutionId` clause). Keyset paging untouched.
|
||||||
|
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByParentExecutionId`; migration smoke if the suite has that pattern.
|
||||||
|
|
||||||
|
**Approach:** purely additive; `ParentExecutionId` is `Guid?` everywhere. Generate the migration the same way `AddAuditLogExecutionId` was produced (match the repo's migration workflow).
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Foundation — site SQLite + gRPC DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ParentExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table; the insert command binds it; `MapRow` reads it back. **Add the column via the idempotent `ALTER TABLE ... ADD COLUMN`-if-missing upgrade path** (the same path commit `5198b11` introduced for `ExecutionId` — locate it and extend it; do NOT rely on `CREATE TABLE IF NOT EXISTS` for the new column on an existing site DB).
|
||||||
|
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string parent_execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
|
||||||
|
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ParentExecutionId` ↔ `parent_execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `ExecutionId` handling).
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` — column present, round-trips, and the `ALTER`-if-missing path adds it to a pre-existing DB lacking the column; `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` — `ParentExecutionId` round-trip incl. null.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Inbound request id minting + `RouteToCallRequest.ParentExecutionId`
|
||||||
|
|
||||||
|
**What:** The id propagated as `ParentExecutionId` is the inbound API request's `ExecutionId`. Today `AuditWriteMiddleware` mints it late, only for the inbound audit row. Mint it once early and stash it so `InboundScriptExecutor` can carry it onto the routing RPC.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs` — add `Guid? ParentExecutionId` to the `RouteToCallRequest` record (additive — append as the last positional param with a default, or make it a settable init property; match how the codebase evolves records).
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` (+ `AuditWriteMiddlewareExtensions.cs` if the pipeline order needs it) — mint the request `ExecutionId` (`Guid.NewGuid()`) at the start of the request, stash it on `HttpContext.Items` under a well-known key (add a small constant, e.g. `InboundExecutionContext.HttpItemKey`); `EmitInboundAudit` reads that same id for the inbound row's `ExecutionId` instead of minting its own.
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs` — read the stashed inbound `ExecutionId` from `HttpContext.Items` (or accept it as a parameter from the endpoint that has the `HttpContext`).
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/RouteHelper.cs` (~line where `RouteToCallRequest` is built) — set `ParentExecutionId` on the `RouteToCallRequest` from the inbound `ExecutionId`. Leave `RouteHelper`'s own per-op `CorrelationId` GUID alone — separate concern.
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/EndpointExtensions.cs` if the inbound `ExecutionId` must be plumbed from the endpoint into `InboundScriptExecutor`.
|
||||||
|
- Test: `tests/ScadaLink.InboundAPI.Tests/` — `AuditWriteMiddlewareTests` (inbound row uses the early-minted id; distinct per request); a `RouteHelper`/`InboundScriptExecutor` test that a routed `RouteToCallRequest` carries `ParentExecutionId` = the inbound request's `ExecutionId`.
|
||||||
|
|
||||||
|
**Approach:** the inbound request's own audit row stays top-level — `ParentExecutionId` is NOT set on it (it remains `NULL`). Only the spawn id flows outward on `RouteToCallRequest`. If the early mint cannot cleanly be shared between middleware and executor, STOP and report before guessing the pipeline shape.
|
||||||
|
|
||||||
|
**Commit:** `feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Thread `ParentExecutionId` into the routed script's `ScriptRuntimeContext`
|
||||||
|
|
||||||
|
**What:** Carry the `RouteToCallRequest.ParentExecutionId` site-side down to the routed script's `ScriptRuntimeContext`. The routed script still generates its own fresh `ExecutionId`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs` — add `Guid? ParentExecutionId` (additive). This is the message `RouteInboundApiCall` builds.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` `RouteInboundApiCall` (~line 734) — set `ParentExecutionId = request.ParentExecutionId` on the `ScriptCallRequest` it builds from the `RouteToCallRequest`.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs` `HandleScriptCallRequest` (~line 319) — forward `request.ParentExecutionId` onward.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs` `HandleScriptCallRequest` (~line 175) — pass `ParentExecutionId` into the `ScriptExecutionActor` it spawns.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs` — add an optional `Guid? parentExecutionId = null` ctor param; thread it through `ExecuteScript` into `new ScriptRuntimeContext(...)`.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add an optional `Guid? parentExecutionId = null` ctor param (sibling to the existing `executionId` param ~line 144); store `_parentExecutionId`; XML-doc it. Thread it to the helper sub-context types alongside `_executionId` (the inner `ExternalSystem`/`Database`/`Notify` helper structs at ~lines 386, 406, 1003 carry `_executionId` — give them `_parentExecutionId` too).
|
||||||
|
- Test: `tests/ScadaLink.SiteRuntime.Tests/` — a test that a `ScriptCallRequest` carrying `ParentExecutionId` produces a `ScriptRuntimeContext` whose `_parentExecutionId` is that value AND whose `ExecutionId` is freshly generated (distinct); a `RouteToCallRequest` → `ScriptCallRequest` mapping test on `DeploymentManagerActor`.
|
||||||
|
|
||||||
|
**Note for implementer:** this task only threads the value — no emitter stamps it yet (Task 5). A normal (tag/timer) script run passes no `ParentExecutionId`, so `_parentExecutionId` stays `null`. Verify the helper sub-context plumbing matches exactly how `_executionId` is already threaded; if the ctor param ordering is awkward, mirror the `executionId` decision documented at `ScriptRuntimeContext.cs:396`.
|
||||||
|
|
||||||
|
**Commit:** `feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Site script-side emitters stamp `ParentExecutionId`
|
||||||
|
|
||||||
|
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ParentExecutionId = _parentExecutionId` alongside `ExecutionId = _executionId`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
|
||||||
|
- Sync `ApiCall` (`BuildCallAuditEvent` / the sync emission ~line 932): set `ParentExecutionId = _parentExecutionId`.
|
||||||
|
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve` ~lines 582, 693, 759): set `ParentExecutionId = _parentExecutionId`.
|
||||||
|
- `NotifySend` emission: set `ParentExecutionId = _parentExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_parentExecutionId` (sibling to the audit `_executionId` already threaded); sync `DbWrite` and cached DB-write rows set `ParentExecutionId = _parentExecutionId`.
|
||||||
|
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, `ExecutionCorrelationContextTests.cs` — assert `ParentExecutionId` is the context's `_parentExecutionId` on every emitted row; assert it is `null` when the context was constructed without one.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): site script-side emitters stamp ParentExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Cached S&F retry-loop rows carry `ParentExecutionId`
|
||||||
|
|
||||||
|
**What:** Thread `ParentExecutionId` through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry it — a sibling to the `ExecutionId` the `ExecutionId` rollout already threaded through this exact path.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the S&F buffered cached-call message / payload in `src/ScadaLink.StoreAndForward/` (`StoreAndForwardService.cs` and the buffered message type — find where `ExecutionId` was added in the `ExecutionId` rollout's Task 4) — carry `ParentExecutionId` alongside.
|
||||||
|
- Modify: `CachedCallAttemptContext` (in `src/ScadaLink.StoreAndForward/` / referenced by `src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs`) — add a `ParentExecutionId` field beside `ExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ParentExecutionId` from the context, beside the existing `ExecutionId`.
|
||||||
|
- Modify: the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext.cs` ~line 520, where `executionId: _executionId` is already passed into the buffered message) — also write `_parentExecutionId` into the buffered message.
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ParentExecutionId` (incl. `null` for a non-routed run).
|
||||||
|
|
||||||
|
**Note for implementer:** the threading boundary is already open from the `ExecutionId` rollout — this is one more field at each existing carry point, not a new boundary. If the buffered message cannot cleanly carry it, STOP and report.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Central `NotifyDeliver` rows carry `ParentExecutionId`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginParentExecutionId` (sibling to `OriginExecutionId`).
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs` — `NotificationSubmit` carries `Guid? OriginParentExecutionId` (additive).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config for `Notifications` + a new migration `AddNotificationOriginParentExecutionId` (`Notifications.OriginParentExecutionId uniqueidentifier NULL`). Mirror `20260521193048_AddNotificationOriginExecutionId`.
|
||||||
|
- Modify: the site `NotifySend` forward path — the routed run's `_parentExecutionId` (on the `NotifySend` audit row from Task 5) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder, beside `OriginExecutionId`).
|
||||||
|
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginParentExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ParentExecutionId = notification.OriginParentExecutionId`.
|
||||||
|
- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginParentExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Repository — `GetExecutionTreeAsync`
|
||||||
|
|
||||||
|
**What:** A repository method that, given any `ExecutionId`, returns the whole execution chain rooted at the topmost ancestor — for the Central UI tree view.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs` — a record: `ExecutionId`, `ParentExecutionId`, `RowCount`, channels present, statuses present, `SourceSiteId`, `SourceInstanceId`, `FirstOccurredAtUtc`, `LastOccurredAtUtc`.
|
||||||
|
- Modify: `src/ScadaLink.Commons/Interfaces/` — the Audit Log repository interface gains `Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(Guid executionId, CancellationToken ct)`.
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implement it:
|
||||||
|
1. **Walk up** to the root — iterative `SELECT TOP 1 ParentExecutionId FROM AuditLog WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL` until none; the last `ExecutionId` with no parent is the root. Cap the loop (e.g. 32) against corrupt data.
|
||||||
|
2. **Walk down** — a recursive CTE seeded at the root, joining `child.ParentExecutionId = parent.ExecutionId`; `OPTION (MAXRECURSION 32)`. Project each distinct `ExecutionId` with the summary aggregates (`GROUP BY`).
|
||||||
|
Use `FromSqlInterpolated`/raw SQL for the recursive CTE (EF Core cannot express it in LINQ); keep the SQL append-only-safe (SELECT only).
|
||||||
|
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `GetExecutionTree_MultiLevelChain` (3-level chain returns all nodes rooted at the ancestor regardless of the entry node); `GetExecutionTree_StubParentNode` (a `ParentExecutionId` referencing an execution with no rows of its own yields a node with `RowCount = 0` / is surfaced as referenced); `GetExecutionTree_RespectsMaxRecursion`.
|
||||||
|
|
||||||
|
**Note for implementer:** chains are shallow (1–2 levels typical). The `ParentExecutionId` graph is acyclic by construction; `MAXRECURSION` is a guard, not a routine limit. A purged parent simply ends the upward walk.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): GetExecutionTreeAsync recursive execution-chain query`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Central UI — `ParentExecutionId` column, filter, parent drill-in
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ParentExecutionId` to the column set (short form / monospace, like `ExecutionId`); it participates in the existing resize/reorder + `ColumnOrder`.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — a `ParentExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ParentExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?parentExecutionId=<guid>`; `BuildExportUrl` emits it.
|
||||||
|
- Add a "View parent execution" row/drilldown action (in `AuditDrilldownDrawer` and/or a grid row action) linking `/audit/log?executionId=<ParentExecutionId>`, shown only when the row has a non-null `ParentExecutionId`. Mirror the existing `?executionId=` drill-in.
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed, drill-in hidden when `ParentExecutionId` null); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (parent drill-in filters the grid).
|
||||||
|
|
||||||
|
Use the `frontend-design` skill for the column/filter/drill-in styling. Custom Blazor + Bootstrap only — no component frameworks.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Central UI — execution-chain tree view
|
||||||
|
|
||||||
|
**What:** A page that renders the full execution chain (rooted at the topmost ancestor) as an expandable tree, reached via a "View execution chain" drill-in.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor` (+ `.razor.cs`) — route `/audit/execution-tree`, accepts `?executionId=<guid>`; calls `GetExecutionTreeAsync` via the same data path the Audit Log page uses for its grid (mirror that — repository service in-process, or a ManagementService endpoint if the grid goes through one; if the latter, add the endpoint in Task 11).
|
||||||
|
- Create: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor` (+ `.razor.cs` + `.razor.css`) — a custom recursive Blazor tree component: assembles the tree from the flat `ExecutionTreeNode` list, renders expandable nodes each showing the execution summary (id short form, row count, channels/statuses, site/instance, time span); a node referenced as a parent but with `RowCount = 0` renders as a stub ("execution with no audited actions"); clicking a node navigates to `/audit/log?executionId=<node>`.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` (+ `.razor.cs`) and/or `AuditResultsGrid` — add a "View execution chain" action linking `/audit/execution-tree?executionId=<ExecutionId of the row>`.
|
||||||
|
- Modify: the Central UI `Audit` nav group if the page should be linkable (decide — it is primarily a drill-in target; a nav entry is optional).
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (tree assembled correctly from a flat list incl. multi-level + stub node; node click navigates); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in → tree renders → node click filters the Audit Log grid).
|
||||||
|
|
||||||
|
Use the `frontend-design` skill for the tree component. Clean, corporate, internal-use aesthetic; custom component, no frameworks.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): execution-chain tree view on the Audit Log page`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: CLI + ManagementService — `ParentExecutionId` filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --parent-execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `parentExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `parentExecutionId` query param into `AuditLogQueryFilter.ParentExecutionId` (lax-parse — unparseable dropped).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
|
||||||
|
- If Task 10's tree page goes through ManagementService rather than the repository in-process: add `GET /api/audit/execution-tree?executionId=<guid>` to `AuditEndpoints.cs` returning the `ExecutionTreeNode` list. Otherwise skip this bullet. No CLI `audit tree` command in the first cut.
|
||||||
|
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
|
||||||
|
|
||||||
|
**Commit:** `feat(audit): ParentExecutionId filter in the CLI and ManagementService`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: End-to-end integration test + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ParentExecutionIdCorrelationTests.cs` — boot a site+central pair; issue an inbound API request whose method script calls `Route.Call` into a site instance; the routed site script does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`. Assert: every audit row the routed run produced (site + central, sync + cached lifecycle + `NotifySend`/`NotifyDeliver`) carries `ParentExecutionId` = the inbound request's `ExecutionId`; each routed-run row has its own distinct `ExecutionId`; the inbound `InboundRequest` row has `ParentExecutionId = NULL`. Assert `GetExecutionTreeAsync` returns both executions in one chain.
|
||||||
|
- Modify: `docs/requirements/Component-AuditLog.md` — add `ParentExecutionId` to the `AuditLog` schema table and the index list (`IX_AuditLog_ParentExecution`); extend the `ExecutionId vs CorrelationId` section with a paragraph on `ParentExecutionId` (cross-execution correlation; inbound→routed bridge; immediate-spawner tree; tag cascade deferred). (Do NOT modify `alog.md`.)
|
||||||
|
- Modify: `CLAUDE.md` — under the Centralized Audit Log decisions, one line noting `ParentExecutionId` as the cross-execution spawn pointer (inbound→routed-site-script bridge; tag cascade deferred).
|
||||||
|
- Modify: component #23 summary in `CLAUDE.md`'s Current Component List if it enumerates correlation columns (keep it in sync).
|
||||||
|
|
||||||
|
**Commit:** `test(auditlog): end-to-end ParentExecutionId correlation + 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 ← 1. 3 ← 0. 4 ← 3. 5 ← 4, 2. 6 ← 5. 7 ← 5, 1. 8 ← 1. 9 ← 1. 10 ← 8, 9. 11 ← 1. 12 ← 5, 6, 7, 10, 11.
|
||||||
|
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → final review.
|
||||||
19
docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json
Normal file
19
docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-21-audit-parent-executionid.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||||
|
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "pending", "blockedBy": [0]},
|
||||||
|
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "pending", "blockedBy": [0]},
|
||||||
|
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "pending", "blockedBy": [4, 2]},
|
||||||
|
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "pending", "blockedBy": [5]},
|
||||||
|
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "pending", "blockedBy": [5, 1]},
|
||||||
|
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "pending", "blockedBy": [8, 9]},
|
||||||
|
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "pending", "blockedBy": [5, 6, 7, 10, 11]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-21"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
148
docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
Normal file
148
docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Inbound API: Full Request/Response Capture in Audit Log
|
||||||
|
|
||||||
|
**Date:** 2026-05-23
|
||||||
|
**Status:** Approved (brainstorming complete)
|
||||||
|
**Affects:** Component-AuditLog (#23), Component-InboundAPI (#14)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today the centralized Audit Log captures inbound API request and response bodies
|
||||||
|
into `RequestSummary` / `ResponseSummary`, but with the global Payload Capture
|
||||||
|
Policy cap — 8 KB by default, 64 KB on error rows. For inbound API traffic this
|
||||||
|
is too tight: operators routinely need to replay exactly what an external caller
|
||||||
|
sent and exactly what we returned. Truncation defeats both replay and the most
|
||||||
|
common "why did this script see that input / what did the caller actually
|
||||||
|
receive" debugging path.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
For `Channel = ApiInbound` rows only, capture `RequestSummary` and
|
||||||
|
`ResponseSummary` verbatim up to a hard per-body ceiling of **1 MB**
|
||||||
|
(configurable). The 8 KB / 64 KB default/error caps that apply to other channels
|
||||||
|
do not apply here. The carve-out is channel-scoped (NOT kind-scoped): every
|
||||||
|
`Channel = ApiInbound` row uses the inbound ceiling regardless of `Kind`, so
|
||||||
|
`InboundAuthFailure` rows pick up the same ceiling as `InboundRequest`. All
|
||||||
|
other channels (`ApiOutbound`, `DbOutbound`, `Notification`, cached-call
|
||||||
|
lifecycle) keep the existing policy unchanged.
|
||||||
|
|
||||||
|
## Capture Policy Change
|
||||||
|
|
||||||
|
The Payload Capture Policy in `Component-AuditLog.md` gains an Inbound API
|
||||||
|
carve-out:
|
||||||
|
|
||||||
|
> **Inbound API exception.** For `Channel = ApiInbound`, `RequestSummary` and
|
||||||
|
> `ResponseSummary` are captured in full up to a per-body hard ceiling of 1 MB
|
||||||
|
> (configurable via `AuditLog:InboundMaxBytes`; default 1 048 576 bytes; min
|
||||||
|
> 8 192; max 16 777 216). The 8 KB / 64 KB default/error caps that apply to
|
||||||
|
> other channels do not apply here. `PayloadTruncated = 1` is set only when the
|
||||||
|
> 1 MB ceiling is hit — verbatim capture is the normal case.
|
||||||
|
|
||||||
|
The rest of the policy is unchanged:
|
||||||
|
|
||||||
|
- Header redact list (`Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`,
|
||||||
|
configured regex) still applies.
|
||||||
|
- Per-target body redactors (regex → replacement, keyed by inbound method name)
|
||||||
|
still run before persistence.
|
||||||
|
- The redactor-error safety net (`<redacted: redactor error>` plus
|
||||||
|
`AuditRedactionFailure` health metric increment) still applies.
|
||||||
|
- UTF-8 byte-safe truncation when the 1 MB ceiling *is* hit.
|
||||||
|
|
||||||
|
The ceiling applies independently to the request body and the response body —
|
||||||
|
each gets its own 1 MB budget on a given audit row.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
No schema change. `RequestSummary` and `ResponseSummary` are already
|
||||||
|
`nvarchar(max)`; SQL Server transparently stores LOB content out-of-row, so
|
||||||
|
larger row payloads are paid for only when the column is read. Only the column
|
||||||
|
description text changes to reflect the inbound carve-out.
|
||||||
|
|
||||||
|
## Ingestion Path
|
||||||
|
|
||||||
|
Unchanged. Inbound rows are already a central direct-write from the request-
|
||||||
|
handler middleware via `ICentralAuditWriter` before the HTTP response is
|
||||||
|
flushed, and audit-write failure is already fail-soft (logged + increments
|
||||||
|
`CentralAuditWriteFailures`, never fails the user-facing request).
|
||||||
|
|
||||||
|
The only code change at the write site is the cap selection:
|
||||||
|
|
||||||
|
```text
|
||||||
|
maxBytes = channel == ApiInbound
|
||||||
|
? options.InboundMaxBytes // default 1 MB
|
||||||
|
: isErrorRow ? 64*1024 : 8*1024; // existing policy
|
||||||
|
```
|
||||||
|
|
||||||
|
Redactors run before the cap; the cap is the final byte-budget step before the
|
||||||
|
INSERT.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
New option on the existing `AuditLog` options class:
|
||||||
|
|
||||||
|
| Key | Default | Min | Max | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `AuditLog:InboundMaxBytes` | `1048576` | `8192` | `16777216` | Per-body ceiling for `ApiInbound` `RequestSummary` / `ResponseSummary`. Truncation past this is the only case where `PayloadTruncated` is set on an inbound row. |
|
||||||
|
|
||||||
|
Bounds enforced on options binding; out-of-range values fail startup with the
|
||||||
|
same "options validation" path used for other AuditLog settings.
|
||||||
|
|
||||||
|
## Doc Edits
|
||||||
|
|
||||||
|
1. **`Component-AuditLog.md`**
|
||||||
|
- `RequestSummary` and `ResponseSummary` rows in the schema table: amend
|
||||||
|
descriptions to note the `ApiInbound` carve-out (full capture up to
|
||||||
|
`InboundMaxBytes`, default 1 MB).
|
||||||
|
- Payload Capture Policy section: add the **Inbound API exception**
|
||||||
|
paragraph above; add `AuditLog:InboundMaxBytes` to the configuration knobs
|
||||||
|
list.
|
||||||
|
2. **`Component-InboundAPI.md`**
|
||||||
|
- Line ~119 (audit row description): "truncated request/response bodies per
|
||||||
|
the Audit Log capture policy" → "request/response bodies captured in full
|
||||||
|
up to the configured `AuditLog:InboundMaxBytes` ceiling (default 1 MB);
|
||||||
|
`PayloadTruncated = 1` only when that ceiling is hit".
|
||||||
|
- Line ~202 (Dependencies → Audit Log): mirror the wording adjustment.
|
||||||
|
|
||||||
|
## Operational Trade-offs
|
||||||
|
|
||||||
|
- **Storage growth.** At 365-day retention, full-body capture on every inbound
|
||||||
|
request can grow `AuditLog` significantly compared to today's 8 KB cap.
|
||||||
|
Operators tune by lowering `InboundMaxBytes`, shortening retention via
|
||||||
|
`AuditLog:RetentionDays`, or — once per-target redaction is configured for
|
||||||
|
chatty methods — applying body redactors to drop noise. Monthly partition
|
||||||
|
purge keeps reclamation cheap regardless of row size.
|
||||||
|
- **No new health metric.** Hitting the 1 MB ceiling is reflected in the
|
||||||
|
existing `PayloadTruncated` bit; no separate counter in v1. If ceiling-hits
|
||||||
|
become a real operational signal, an `AuditInboundCeilingHits` metric can be
|
||||||
|
added later without schema change.
|
||||||
|
- **Append-only and audit role.** The `scadalink_audit_writer` role already
|
||||||
|
permits `INSERT` only — full-body rows don't change the security model.
|
||||||
|
|
||||||
|
## Not in Scope (Deferred)
|
||||||
|
|
||||||
|
- **Structured response capture.** `ResponseSummary` stays a single string;
|
||||||
|
response status code remains in `HttpStatus`. No separate columns for response
|
||||||
|
headers or content type. Inbound request headers remain uncaptured.
|
||||||
|
- **Per-method opt-out** from full capture. If specific methods produce
|
||||||
|
routinely-huge responses, operators use the existing per-target body redactor
|
||||||
|
to compress them, or lower the global ceiling.
|
||||||
|
- **Changes to other channels' caps.** `ApiOutbound`, `DbOutbound`,
|
||||||
|
`Notification`, and cached-call lifecycle rows keep the existing 8 KB / 64 KB
|
||||||
|
policy. (`InboundAuthFailure` rows carry `Channel = ApiInbound` and so fall
|
||||||
|
under the inbound ceiling like every other inbound row.)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `AuditLog:InboundMaxBytes` option exists on the AuditLog options class,
|
||||||
|
with the documented default and bounds, validated at startup.
|
||||||
|
- [ ] Inbound request middleware writes `RequestSummary` and `ResponseSummary`
|
||||||
|
using the inbound ceiling instead of the 8 KB / 64 KB defaults.
|
||||||
|
- [ ] Other channels' rows (e.g. an `ApiOutbound.ApiCall` over the limit) still
|
||||||
|
truncate at 8 KB (64 KB on error rows) — regression-tested.
|
||||||
|
- [ ] `PayloadTruncated = 1` on an inbound row iff request body or response
|
||||||
|
body exceeded `InboundMaxBytes`.
|
||||||
|
- [ ] Header redaction list and per-target body redactors still apply to
|
||||||
|
inbound rows.
|
||||||
|
- [ ] Redactor failure on an inbound row still produces `<redacted: redactor
|
||||||
|
error>` and increments `AuditRedactionFailure`.
|
||||||
|
- [ ] `Component-AuditLog.md` and `Component-InboundAPI.md` updated as
|
||||||
|
described in **Doc Edits**.
|
||||||
745
docs/plans/2026-05-23-inbound-api-full-response-audit.md
Normal file
745
docs/plans/2026-05-23-inbound-api-full-response-audit.md
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
# Inbound API: Full Request/Response Audit Capture — 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:** Inbound API audit rows (`AuditChannel.ApiInbound`) capture the FULL request and response body verbatim up to a configurable 1 MB per-body ceiling, instead of the global 8 KB / 64 KB caps. Other channels are untouched.
|
||||||
|
|
||||||
|
**Architecture:** Two changes. (1) `AuditLogOptions` gains an `InboundMaxBytes` knob; `DefaultAuditPayloadFilter` branches on `Channel == ApiInbound` to use it. (2) `AuditWriteMiddleware` finally implements the M5-deferred response-body capture — wraps `HttpContext.Response.Body` with a buffering `MemoryStream` swap, reads it after the pipeline runs, restores and flushes the original body. The redaction stages (headers, body regexes, SQL params) keep their existing semantics; only the truncation cap changes for ApiInbound rows. Validated design: `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, xUnit, ASP.NET Core Minimal API, `Microsoft.Extensions.Options.IOptionsMonitor`.
|
||||||
|
|
||||||
|
**Ground rules (every task):** create + work on branch `feature/inbound-api-full-response-audit` — never commit to `main`. TDD: failing test first, then minimal implementation, then verify. Edit in place; never edit `infra/*`, `alog.md`, or `docker/*` unless a task names them (none here). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. Solution stays green: `dotnet build ScadaLink.slnx` 0 warnings (`TreatWarningsAsErrors` on). Do not push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Prep — branch, baseline build
|
||||||
|
|
||||||
|
**Files:** none.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `git status --short` — confirm you are starting from the `main` revision that already contains commit `0670864` (`docs(audit): design — full request/response capture for inbound API rows`).
|
||||||
|
2. `git checkout -b feature/inbound-api-full-response-audit`.
|
||||||
|
3. `git branch --show-current` — expect `feature/inbound-api-full-response-audit`.
|
||||||
|
4. `dotnet build ScadaLink.slnx` from repo root — expect 0 warnings, 0 errors.
|
||||||
|
|
||||||
|
**Acceptance:** on the feature branch; solution builds clean.
|
||||||
|
|
||||||
|
**Commit:** none (no changes yet).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add `InboundMaxBytes` to `AuditLogOptions` (TDD)
|
||||||
|
|
||||||
|
**What:** New `int InboundMaxBytes` property on `AuditLogOptions` with default 1 048 576 bytes, validated to `[8192, 16777216]`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — add property + XML doc.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — add min/max constants + validation branch.
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs` — extend the binding test to assert the new field round-trips from JSON.
|
||||||
|
- Test (new): `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs` — if this file does not exist, create it with the four cases below; if a validator-tests file already exists (search for it under `tests/ScadaLink.AuditLog.Tests/Configuration/`), extend it instead.
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to a validator-tests file (create if missing — namespace `ScadaLink.AuditLog.Tests.Configuration`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class AuditLogOptionsValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Validate_InboundMaxBytes_DefaultOptions_IsOneMebibyte()
|
||||||
|
{
|
||||||
|
// The doc'd default per docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
|
||||||
|
// is 1 048 576 bytes (1 MiB). Pin it so a config drift is a test failure,
|
||||||
|
// not a silent operational surprise.
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
Assert.Equal(1_048_576, opts.InboundMaxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(8_192)] // documented min
|
||||||
|
[InlineData(1_048_576)] // default
|
||||||
|
[InlineData(16_777_216)] // documented max
|
||||||
|
public void Validate_InboundMaxBytes_InRange_Passes(int value)
|
||||||
|
{
|
||||||
|
var validator = new AuditLogOptionsValidator();
|
||||||
|
var opts = new AuditLogOptions { InboundMaxBytes = value };
|
||||||
|
Assert.True(validator.Validate(null, opts).Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(8_191)]
|
||||||
|
[InlineData(16_777_217)]
|
||||||
|
[InlineData(int.MaxValue)]
|
||||||
|
public void Validate_InboundMaxBytes_OutOfRange_Fails(int value)
|
||||||
|
{
|
||||||
|
var validator = new AuditLogOptionsValidator();
|
||||||
|
var opts = new AuditLogOptions { InboundMaxBytes = value };
|
||||||
|
var result = validator.Validate(null, opts);
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `AuditLogOptionsBindingTests.AuditLog_Section_Binds_AllFields` — add `"InboundMaxBytes": 524288` to the JSON literal and a matching `Assert.Equal(524_288, opts.InboundMaxBytes)`.
|
||||||
|
|
||||||
|
**Step 2: Run tests — confirm they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests \
|
||||||
|
--filter "FullyQualifiedName~AuditLogOptionsValidatorTests|FullyQualifiedName~AuditLogOptionsBindingTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the new validator tests fail (no `InboundMaxBytes` property), the binding test fails (property does not bind).
|
||||||
|
|
||||||
|
**Step 3: Add the property**
|
||||||
|
|
||||||
|
In `AuditLogOptions.cs`, insert after `RetentionDays`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Per-body byte ceiling applied to <see cref="AuditEvent.RequestSummary"/> and
|
||||||
|
/// <see cref="AuditEvent.ResponseSummary"/> for <see cref="AuditChannel.ApiInbound"/> rows
|
||||||
|
/// (default 1 MiB). The 8 KiB / 64 KiB default/error caps that apply to other channels
|
||||||
|
/// do not apply here — inbound traffic captures verbatim up to this ceiling and only
|
||||||
|
/// then sets <see cref="AuditEvent.PayloadTruncated"/>. See
|
||||||
|
/// <c>docs/plans/2026-05-23-inbound-api-full-response-audit-design.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int InboundMaxBytes { get; set; } = 1_048_576;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add the validator branch**
|
||||||
|
|
||||||
|
In `AuditLogOptionsValidator.cs`, add the constants beside the existing retention bounds:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public const int MinInboundMaxBytes = 8_192;
|
||||||
|
public const int MaxInboundMaxBytes = 16_777_216;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the validation block inside `Validate` (after the retention check, before the `return`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
|
||||||
|
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests — confirm they pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all green, including the extended binding test and the new validator tests.
|
||||||
|
|
||||||
|
**Step 6: Build the whole solution**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 warnings, 0 errors.
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs \
|
||||||
|
src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs \
|
||||||
|
tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsBindingTests.cs \
|
||||||
|
tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs
|
||||||
|
git commit -m "feat(auditlog): add AuditLog:InboundMaxBytes option (default 1 MiB, [8 KiB, 16 MiB])"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Wire `InboundMaxBytes` into `DefaultAuditPayloadFilter` (TDD)
|
||||||
|
|
||||||
|
**What:** When `AuditEvent.Channel == AuditChannel.ApiInbound`, the filter selects `InboundMaxBytes` as the truncation cap instead of `DefaultCapBytes` / `ErrorCapBytes`. Redaction stages run unchanged.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs` — change the one cap-selection line.
|
||||||
|
- Test (new): `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs` — pin the new behaviour.
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
|
using ScadaLink.AuditLog.Payload;
|
||||||
|
using ScadaLink.AuditLog.Tests.Configuration; // for TestOptionsMonitor — confirm namespace via existing file
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Payload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the docs/plans/2026-05-23-inbound-api-full-response-audit-design.md
|
||||||
|
/// inbound carve-out: ApiInbound rows use InboundMaxBytes (default 1 MiB) for
|
||||||
|
/// RequestSummary / ResponseSummary truncation, NOT DefaultCapBytes /
|
||||||
|
/// ErrorCapBytes. Other channels keep the existing caps.
|
||||||
|
/// </summary>
|
||||||
|
public class InboundChannelCapTests
|
||||||
|
{
|
||||||
|
private static AuditEvent MakeInbound(
|
||||||
|
AuditStatus status,
|
||||||
|
string? request = null,
|
||||||
|
string? response = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiInbound,
|
||||||
|
Kind = status == AuditStatus.Delivered
|
||||||
|
? AuditKind.InboundRequest
|
||||||
|
: AuditKind.InboundRequest,
|
||||||
|
Status = status,
|
||||||
|
RequestSummary = request,
|
||||||
|
ResponseSummary = response,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Delivered_RequestBody_BelowInboundMaxBytes_NotTruncated()
|
||||||
|
{
|
||||||
|
// Body well above the legacy 8 KiB default cap but under the 1 MiB
|
||||||
|
// inbound ceiling — must NOT truncate.
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var opts = new AuditLogOptions(); // defaults
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new TestOptionsMonitor<AuditLogOptions>(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, request: body));
|
||||||
|
|
||||||
|
Assert.False(result.PayloadTruncated);
|
||||||
|
Assert.Equal(body.Length, result.RequestSummary!.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Delivered_ResponseBody_BelowInboundMaxBytes_NotTruncated()
|
||||||
|
{
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new TestOptionsMonitor<AuditLogOptions>(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Delivered, response: body));
|
||||||
|
|
||||||
|
Assert.False(result.PayloadTruncated);
|
||||||
|
Assert.Equal(body.Length, result.ResponseSummary!.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
|
||||||
|
{
|
||||||
|
// Even on error rows, the inbound cap is InboundMaxBytes (NOT ErrorCapBytes).
|
||||||
|
var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
|
||||||
|
var oversized = new string('z', 50_000);
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new TestOptionsMonitor<AuditLogOptions>(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var result = filter.Apply(MakeInbound(AuditStatus.Failed, response: oversized));
|
||||||
|
|
||||||
|
Assert.True(result.PayloadTruncated);
|
||||||
|
Assert.True(Encoding.UTF8.GetByteCount(result.ResponseSummary!) <= 16_384);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiOutbound_StillUsesDefaultCap_NotInboundMaxBytes()
|
||||||
|
{
|
||||||
|
// Regression guard: lifting the inbound cap MUST NOT change other
|
||||||
|
// channels. An ApiOutbound 100 KB body still hits the 8 KiB cap.
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
var body = new string('a', 100_000);
|
||||||
|
var filter = new DefaultAuditPayloadFilter(
|
||||||
|
new TestOptionsMonitor<AuditLogOptions>(opts),
|
||||||
|
NullLogger<DefaultAuditPayloadFilter>.Instance);
|
||||||
|
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
RequestSummary = body,
|
||||||
|
};
|
||||||
|
var result = filter.Apply(evt);
|
||||||
|
|
||||||
|
Assert.True(result.PayloadTruncated);
|
||||||
|
Assert.True(Encoding.UTF8.GetByteCount(result.RequestSummary!) <= opts.DefaultCapBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the `TestOptionsMonitor<T>` helper lives in `tests/ScadaLink.AuditLog.Tests/`. Grep:
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -rn "class TestOptionsMonitor" tests/ScadaLink.AuditLog.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
If its namespace differs from `ScadaLink.AuditLog.Tests.Configuration`, update the `using` accordingly.
|
||||||
|
|
||||||
|
**Step 2: Run tests — confirm they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests \
|
||||||
|
--filter "FullyQualifiedName~InboundChannelCapTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the inbound tests fail (filter still applies the 8 KiB cap to ApiInbound). The `ApiOutbound_StillUsesDefaultCap` test SHOULD pass even before the change — that's the regression baseline and stays green after.
|
||||||
|
|
||||||
|
**Step 3: Add the channel branch to the filter**
|
||||||
|
|
||||||
|
In `DefaultAuditPayloadFilter.cs`, replace the single line:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
|
||||||
|
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
|
||||||
|
// replay exactly what the caller sent and what we returned. Other channels
|
||||||
|
// keep the global 8 KiB / 64 KiB policy.
|
||||||
|
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
|
||||||
|
var cap = rawEvent.Channel == AuditChannel.ApiInbound
|
||||||
|
? opts.InboundMaxBytes
|
||||||
|
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — confirm they pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all green. The existing `FilterIntegrationTests`, `BodyRegexRedactionTests`, `RedactionSafetyNetTests`, `SqlParamRedactionTests`, etc., MUST stay passing — the change is channel-scoped and the non-inbound cases never see the new branch.
|
||||||
|
|
||||||
|
**Step 5: Build the whole solution**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 warnings.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add src/ScadaLink.AuditLog/Payload/DefaultAuditPayloadFilter.cs \
|
||||||
|
tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs
|
||||||
|
git commit -m "feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Capture the response body in `AuditWriteMiddleware` (TDD)
|
||||||
|
|
||||||
|
**What:** Implement the M5-deferred response-body capture. Wrap `HttpContext.Response.Body` with a buffering `MemoryStream` BEFORE `_next(ctx)`, restore + copy the buffered bytes back to the original stream AFTER the pipeline runs, then read the buffer as UTF-8 into `ResponseSummary` on the audit event. The `AuditEvent.ResponseSummary = null` line goes away.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs`.
|
||||||
|
- Modify: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — extend.
|
||||||
|
|
||||||
|
**Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `AuditWriteMiddlewareTests.cs` (before the closing class brace), keeping the existing `BuildContext` / `CreateMiddleware` helpers:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Response body capture — Audit Log #23 (inbound full-response feature).
|
||||||
|
// Until the M5-deferred work landed, ResponseSummary was always null.
|
||||||
|
// These tests pin the new contract: the middleware wraps Response.Body,
|
||||||
|
// runs the pipeline, copies the buffered bytes back to the real stream,
|
||||||
|
// and stashes a UTF-8 string copy on ResponseSummary.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResponseBody_IsCaptured_OnResponseSummary()
|
||||||
|
{
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var responseJson = "{\"result\":42}";
|
||||||
|
var mw = CreateMiddleware(async hc =>
|
||||||
|
{
|
||||||
|
hc.Response.StatusCode = 200;
|
||||||
|
hc.Response.ContentType = "application/json";
|
||||||
|
await hc.Response.WriteAsync(responseJson);
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(responseJson, evt.ResponseSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
|
||||||
|
{
|
||||||
|
// Wrapping the response body must be TRANSPARENT — the real client
|
||||||
|
// stream still receives every byte the pipeline wrote.
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var captured = new MemoryStream();
|
||||||
|
ctx.Response.Body = captured; // simulate the client/test sink
|
||||||
|
|
||||||
|
var responseJson = "{\"ok\":true}";
|
||||||
|
var mw = CreateMiddleware(async hc =>
|
||||||
|
{
|
||||||
|
hc.Response.StatusCode = 200;
|
||||||
|
await hc.Response.WriteAsync(responseJson);
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
|
||||||
|
{
|
||||||
|
// No bytes written => null, not empty-string. Mirrors the request-body
|
||||||
|
// contract in ReadBufferedRequestBodyAsync.
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var mw = CreateMiddleware(hc =>
|
||||||
|
{
|
||||||
|
hc.Response.StatusCode = 204;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(ctx);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Null(evt.ResponseSummary);
|
||||||
|
Assert.Equal(204, evt.HttpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
|
||||||
|
{
|
||||||
|
// If the handler writes some bytes then throws, the audit row still
|
||||||
|
// surfaces whatever the framework had flushed. The middleware re-throws
|
||||||
|
// (audit is best-effort, the request's error path stays authoritative).
|
||||||
|
var writer = new RecordingAuditWriter();
|
||||||
|
var ctx = BuildContext();
|
||||||
|
var boom = new InvalidOperationException("kaboom");
|
||||||
|
var mw = CreateMiddleware(async hc =>
|
||||||
|
{
|
||||||
|
hc.Response.StatusCode = 500;
|
||||||
|
await hc.Response.WriteAsync("partial");
|
||||||
|
throw boom;
|
||||||
|
}, writer);
|
||||||
|
|
||||||
|
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => mw.InvokeAsync(ctx));
|
||||||
|
Assert.Same(boom, thrown);
|
||||||
|
|
||||||
|
var evt = Assert.Single(writer.Events);
|
||||||
|
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||||
|
Assert.Equal("partial", evt.ResponseSummary);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests — confirm they fail**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.InboundAPI.Tests \
|
||||||
|
--filter "FullyQualifiedName~AuditWriteMiddlewareTests"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the four new tests fail (ResponseSummary stays null today). Pre-existing tests still pass (they don't assert ResponseSummary).
|
||||||
|
|
||||||
|
**Step 3: Implement response capture in the middleware**
|
||||||
|
|
||||||
|
Edit `AuditWriteMiddleware.cs`:
|
||||||
|
|
||||||
|
(a) Update the XML doc at the top — remove the "Response body capture is deferred to M5…" paragraph (lines 42-50 in the current file). Replace with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <para>
|
||||||
|
/// <b>Body capture.</b> The request body is buffered via
|
||||||
|
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
||||||
|
/// rewound so the downstream endpoint handler still sees the full payload. The
|
||||||
|
/// response body is captured by swapping <see cref="HttpResponse.Body"/> for a
|
||||||
|
/// <see cref="MemoryStream"/> before the pipeline runs; after the pipeline
|
||||||
|
/// returns, the buffered bytes are copied to the original stream (transparent
|
||||||
|
/// to the real client) and read into <see cref="AuditEvent.ResponseSummary"/>.
|
||||||
|
/// Truncation to the configured inbound ceiling happens in
|
||||||
|
/// <see cref="ScadaLink.AuditLog.Payload.DefaultAuditPayloadFilter"/>; the
|
||||||
|
/// middleware itself stores the full buffered content.
|
||||||
|
/// </para>
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Rewrite `InvokeAsync` so the response stream is swapped, the buffer is read post-pipeline (in `finally`, even on a thrown handler), and the original stream receives the bytes back:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task InvokeAsync(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Request body — buffer for both audit + downstream handler.
|
||||||
|
ctx.Request.EnableBuffering();
|
||||||
|
var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Response body — swap in a MemoryStream so the pipeline writes are
|
||||||
|
// captured. The original Response.Body is restored in the finally block,
|
||||||
|
// and the captured bytes are copied back to it so the real client still
|
||||||
|
// receives every byte (transparent wrap). The captured string is then
|
||||||
|
// available for the audit row.
|
||||||
|
var originalResponseBody = ctx.Response.Body;
|
||||||
|
using var responseBuffer = new MemoryStream();
|
||||||
|
ctx.Response.Body = responseBuffer;
|
||||||
|
|
||||||
|
string? responseBody = null;
|
||||||
|
Exception? thrown = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(ctx).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
thrown = ex;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
// Whatever the handler managed to write — full success, partial
|
||||||
|
// success before throwing, or nothing at all — copy back to the
|
||||||
|
// original stream and read for audit.
|
||||||
|
responseBody = await DrainResponseBufferAsync(responseBuffer, originalResponseBody)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
ctx.Response.Body = originalResponseBody;
|
||||||
|
|
||||||
|
EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody, responseBody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Add the new helper (place beside `ReadBufferedRequestBodyAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the bytes buffered in <paramref name="buffer"/> to
|
||||||
|
/// <paramref name="originalBody"/> (so the real client still receives them)
|
||||||
|
/// and returns a UTF-8 string copy for <see cref="AuditEvent.ResponseSummary"/>.
|
||||||
|
/// Returns null when no bytes were written, mirroring the
|
||||||
|
/// <see cref="ReadBufferedRequestBodyAsync"/> empty-body contract.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string?> DrainResponseBufferAsync(
|
||||||
|
MemoryStream buffer,
|
||||||
|
Stream originalBody)
|
||||||
|
{
|
||||||
|
if (buffer.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
// Copy first so the client never misses bytes even if the read for audit
|
||||||
|
// throws somehow (defensive — MemoryStream.CopyToAsync to a sink shouldn't
|
||||||
|
// throw on its own, but the original body may).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort: a sink that refuses our copy is the sink's problem;
|
||||||
|
// the audit still records what the handler produced. Do NOT rethrow.
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new StreamReader(
|
||||||
|
buffer,
|
||||||
|
Encoding.UTF8,
|
||||||
|
detectEncodingFromByteOrderMarks: false,
|
||||||
|
bufferSize: 1024,
|
||||||
|
leaveOpen: true);
|
||||||
|
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||||
|
return string.IsNullOrEmpty(content) ? null : content;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Change `EmitInboundAudit`'s signature to take the response body, and drop the `ResponseSummary = null` line:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void EmitInboundAudit(
|
||||||
|
HttpContext ctx,
|
||||||
|
long durationMs,
|
||||||
|
Exception? thrown,
|
||||||
|
string? requestBody,
|
||||||
|
string? responseBody)
|
||||||
|
{
|
||||||
|
// ... unchanged up to the AuditEvent constructor ...
|
||||||
|
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
// ... existing fields ...
|
||||||
|
RequestSummary = requestBody,
|
||||||
|
ResponseSummary = responseBody, // was null in the M4 deliverable
|
||||||
|
PayloadTruncated = false,
|
||||||
|
Extra = extra,
|
||||||
|
ForwardState = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... unchanged fire-and-forget write ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run tests — confirm they pass**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.InboundAPI.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all green, including the four new response-body tests AND every pre-existing middleware test.
|
||||||
|
|
||||||
|
**Step 5: Build the whole solution**
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 warnings.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs \
|
||||||
|
tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs
|
||||||
|
git commit -m "feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Update `Component-AuditLog.md`
|
||||||
|
|
||||||
|
**What:** Reflect the inbound carve-out in the requirements doc — schema row descriptions + Payload Capture Policy.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/requirements/Component-AuditLog.md`.
|
||||||
|
|
||||||
|
**Edits (all in-place — no copies):**
|
||||||
|
|
||||||
|
1. Schema table — find the `RequestSummary` row. Change its `Description` cell from:
|
||||||
|
> Truncated request payload (configurable cap). Headers redacted.
|
||||||
|
to:
|
||||||
|
> Truncated request payload (configurable cap). Headers redacted. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) — see Payload Capture Policy.
|
||||||
|
|
||||||
|
2. Schema table — `ResponseSummary` row. Change its `Description` cell from:
|
||||||
|
> Truncated response payload. Full on errors.
|
||||||
|
to:
|
||||||
|
> Truncated response payload. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB). For other channels, capped at `DefaultCapBytes` by default and `ErrorCapBytes` on error rows.
|
||||||
|
|
||||||
|
3. **Payload Capture Policy** section — after the existing **Default cap** bullet, insert:
|
||||||
|
|
||||||
|
> - **Inbound API exception.** For `Channel = ApiInbound`, `RequestSummary` and `ResponseSummary` are captured in full up to a per-body hard ceiling of 1 MiB (configurable via `AuditLog:InboundMaxBytes`; default 1 048 576 bytes; min 8 192; max 16 777 216). The 8 KiB / 64 KiB default/error caps that apply to other channels do not apply here. `PayloadTruncated = 1` is set only when the inbound ceiling is hit — verbatim capture is the normal case. The ceiling applies independently to each body. Header redaction and per-target body redactors still run before persistence.
|
||||||
|
|
||||||
|
**Verify:** `git diff docs/requirements/Component-AuditLog.md` — three edits, no other lines touched.
|
||||||
|
|
||||||
|
**Commit:**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add docs/requirements/Component-AuditLog.md
|
||||||
|
git commit -m "docs(audit): schema + Payload Capture Policy note inbound full-body carve-out"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update `Component-InboundAPI.md`
|
||||||
|
|
||||||
|
**What:** Update the two existing references that say "truncated request/response bodies per the Audit Log capture policy" to reflect the new wording.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/requirements/Component-InboundAPI.md`.
|
||||||
|
|
||||||
|
**Edits:**
|
||||||
|
|
||||||
|
1. Around line 119 (the inbound-audit description in §Operational Audit / Logging) — change:
|
||||||
|
> Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and truncated request/response bodies per the Audit Log capture policy (see Component-AuditLog.md, Payload Capture Policy).
|
||||||
|
to:
|
||||||
|
> Every request — success or failure — emits one `ApiInbound.Completed` row to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and the request/response bodies. Bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB); `PayloadTruncated = 1` only when that ceiling is hit. Header redaction and per-target body redactors still apply (see Component-AuditLog.md, Payload Capture Policy).
|
||||||
|
|
||||||
|
2. Around line 202 (Dependencies → Audit Log) — change:
|
||||||
|
> **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Payload truncation/redaction follows the Audit Log Payload Capture Policy.
|
||||||
|
to:
|
||||||
|
> **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Request and response bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) per the Audit Log Payload Capture Policy; redaction (headers + per-target body redactors) still applies before persistence.
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
```
|
||||||
|
grep -nE "truncated request/response|InboundMaxBytes" docs/requirements/Component-InboundAPI.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no "truncated request/response" hits remain; two "InboundMaxBytes" hits land on the updated lines.
|
||||||
|
|
||||||
|
**Commit:**
|
||||||
|
|
||||||
|
```
|
||||||
|
git add docs/requirements/Component-InboundAPI.md
|
||||||
|
git commit -m "docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Final solution build + full test run + branch summary
|
||||||
|
|
||||||
|
**What:** Confirm the cumulative change is green end-to-end and summarise the branch.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. `dotnet build ScadaLink.slnx` from the repo root — expect 0 warnings, 0 errors.
|
||||||
|
|
||||||
|
2. Run the affected test projects:
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests
|
||||||
|
dotnet test tests/ScadaLink.InboundAPI.Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect both green. (The wider solution test run is optional but cheap — `dotnet test ScadaLink.slnx` if you want full coverage.)
|
||||||
|
|
||||||
|
3. `git log --oneline main..HEAD` — expect exactly five commits:
|
||||||
|
- feat(auditlog): add AuditLog:InboundMaxBytes option …
|
||||||
|
- feat(auditlog): payload filter uses InboundMaxBytes for ApiInbound rows
|
||||||
|
- feat(inboundapi): AuditWriteMiddleware captures response body on ApiInbound audit rows
|
||||||
|
- docs(audit): schema + Payload Capture Policy note inbound full-body carve-out
|
||||||
|
- docs(inboundapi): note request/response bodies captured in full to InboundMaxBytes
|
||||||
|
|
||||||
|
4. `git status --short` — expect a clean tree (no uncommitted files; pre-existing uncommitted files from `git status` at session start may still be present and are unrelated to this work — leave them).
|
||||||
|
|
||||||
|
**Acceptance:**
|
||||||
|
- All acceptance criteria in `docs/plans/2026-05-23-inbound-api-full-response-audit-design.md` met.
|
||||||
|
- Solution builds clean.
|
||||||
|
- All targeted tests pass.
|
||||||
|
- Five commits on the feature branch, no commits on `main`, branch not pushed.
|
||||||
|
|
||||||
|
**Commit:** none. Do not merge or push — that is the user's call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the executor
|
||||||
|
|
||||||
|
- The `ResponseSummary = null` comment in `AuditWriteMiddleware.cs` (line ~191 today) is the smoking-gun: response capture was always intended, just deferred. The design doc explicitly authorises closing that gap.
|
||||||
|
- The `TestOptionsMonitor<T>` helper is already used by `AuditLogOptionsBindingTests.cs` — reuse it; do not introduce a second one. If its public location moves between when this plan was written and execution, `grep -rn "class TestOptionsMonitor" tests/` and adjust the `using`.
|
||||||
|
- `appsettings.json` files in `docker/central-node-a` and `docker/central-node-b` do not currently override `AuditLog:*` — leave them alone. The 1 MiB default takes effect automatically from `AuditLogOptions`.
|
||||||
|
- No EF migration is needed — schema is unchanged (`nvarchar(max)` already).
|
||||||
|
- No new health metric — `PayloadTruncated = 1` carries the ceiling-hit signal. The design doc explicitly defers a dedicated `AuditInboundCeilingHits` counter.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-23-inbound-api-full-response-audit.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 0: Prep — branch, baseline build", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 1: Add InboundMaxBytes to AuditLogOptions (TDD)", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 2: Wire InboundMaxBytes into DefaultAuditPayloadFilter (TDD)", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 3: Capture response body in AuditWriteMiddleware (TDD)", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 5, "subject": "Task 4: Update Component-AuditLog.md", "status": "pending", "blockedBy": [4]},
|
||||||
|
{"id": 6, "subject": "Task 5: Update Component-InboundAPI.md", "status": "pending", "blockedBy": [5]},
|
||||||
|
{"id": 7, "subject": "Task 6: Final build + full test run + branch summary", "status": "pending", "blockedBy": [6]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-23"
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ row per lifecycle event across all channels.
|
|||||||
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
||||||
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
|
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
||||||
|
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). |
|
||||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
||||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||||
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
||||||
@@ -93,8 +95,8 @@ row per lifecycle event across all channels.
|
|||||||
| `DurationMs` | `int` NULL | Call / attempt duration. |
|
| `DurationMs` | `int` NULL | Call / attempt duration. |
|
||||||
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
||||||
| `ErrorDetail` | `nvarchar(max)` NULL | Optional full exception text on failures. |
|
| `ErrorDetail` | `nvarchar(max)` NULL | Optional full exception text on failures. |
|
||||||
| `RequestSummary` | `nvarchar(max)` NULL | Truncated request payload (configurable cap). Headers redacted. |
|
| `RequestSummary` | `nvarchar(max)` NULL | Truncated request payload (configurable cap). Headers redacted. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) — see Payload Capture Policy. |
|
||||||
| `ResponseSummary` | `nvarchar(max)` NULL | Truncated response payload. Full on errors. |
|
| `ResponseSummary` | `nvarchar(max)` NULL | Truncated response payload. For `Channel = ApiInbound`, captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB). For other channels, capped at `DefaultCapBytes` by default and `ErrorCapBytes` on error rows. |
|
||||||
| `PayloadTruncated` | `bit` | Set if either summary was truncated. |
|
| `PayloadTruncated` | `bit` | Set if either summary was truncated. |
|
||||||
| `Extra` | `nvarchar(max)` NULL | Channel-specific JSON for fields we don't promote to columns. |
|
| `Extra` | `nvarchar(max)` NULL | Channel-specific JSON for fields we don't promote to columns. |
|
||||||
|
|
||||||
@@ -102,7 +104,9 @@ row per lifecycle event across all channels.
|
|||||||
|
|
||||||
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
|
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
|
||||||
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
|
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
|
||||||
- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation.
|
- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
|
||||||
|
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
|
||||||
|
- `IX_AuditLog_ParentExecution (ParentExecutionId)` — cross-execution drilldown: the downward leg of the execution-tree walk seeks on it (`ParentExecutionId = ancestor.ExecutionId`), and it backs the `parentExecutionId` filter.
|
||||||
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
|
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
|
||||||
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
|
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
|
||||||
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
|
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
|
||||||
@@ -126,6 +130,43 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or
|
|||||||
`InboundAuthFailure` for auth rejections) row per request rather than a
|
`InboundAuthFailure` for auth rejections) row per request rather than a
|
||||||
multi-event lifecycle.
|
multi-event lifecycle.
|
||||||
|
|
||||||
|
### `ExecutionId` vs `CorrelationId`
|
||||||
|
|
||||||
|
The table carries two correlation columns at different granularities:
|
||||||
|
|
||||||
|
- **`ExecutionId`** is the *universal per-run* value: one id per script
|
||||||
|
execution (tag-change / timer-triggered or otherwise) or per inbound API
|
||||||
|
request. It is stamped on **every** audit row that run produces — the sync
|
||||||
|
`ApiCall` and `DbWrite` rows, the full cached-call lifecycle, the
|
||||||
|
`NotifySend` / `NotifyDeliver` rows, and the inbound row alike. A run that
|
||||||
|
performs no trust-boundary action emits no rows, but any run that emits
|
||||||
|
multiple rows ties them all together under one `ExecutionId`. This lets an
|
||||||
|
audit reader pull the complete trust-boundary footprint of a single script
|
||||||
|
run with one `ExecutionId` filter.
|
||||||
|
- **`CorrelationId`** is the *per-operation lifecycle* id — it groups the
|
||||||
|
multiple events of one long-running operation (`TrackedOperationId` for a
|
||||||
|
cached call, `NotificationId` for a notification, request-id for inbound
|
||||||
|
API) and is NULL for sync one-shot calls that have no operation lifecycle.
|
||||||
|
|
||||||
|
The two are orthogonal: one execution may touch several operations (each with
|
||||||
|
its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`.
|
||||||
|
|
||||||
|
**`ParentExecutionId`** adds *cross-execution* correlation on top. `ExecutionId`
|
||||||
|
is per-run and flat — `WHERE ExecutionId = X` returns everything one run did, but
|
||||||
|
nothing links a run to the run that *spawned* it. `ParentExecutionId` carries the
|
||||||
|
spawning execution's `ExecutionId`: a spawned run still gets its own fresh
|
||||||
|
`ExecutionId`, and every audit row it emits also carries the spawner's id in
|
||||||
|
`ParentExecutionId`. The first cut bridges the **inbound API → routed-site-script**
|
||||||
|
case: an inbound request runs a method script that calls `Route.Call`, routing to
|
||||||
|
a site instance; the routed site script records the inbound request's
|
||||||
|
`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row
|
||||||
|
itself is top-level (`ParentExecutionId` NULL). The pointer always references the
|
||||||
|
*immediate* spawner, so a routed run that itself routes onward threads its own
|
||||||
|
`ExecutionId` — walking `ParentExecutionId → ExecutionId` recursively
|
||||||
|
reconstructs the call chain as a tree of arbitrary depth. The tag-cascade case
|
||||||
|
(an attribute write triggering another script) is **deferred** — the model
|
||||||
|
generalises to it with no schema change once that spawn point is threaded.
|
||||||
|
|
||||||
## The Site-Local `AuditLog` (SQLite)
|
## The Site-Local `AuditLog` (SQLite)
|
||||||
|
|
||||||
A SQLite database file on each site node, alongside the Store-and-Forward
|
A SQLite database file on each site node, alongside the Store-and-Forward
|
||||||
@@ -221,6 +262,7 @@ operational `SiteCalls` shape for the dispatcher and UI.
|
|||||||
|
|
||||||
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
|
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
|
||||||
raised to 64 KB on any error row (`Status IN ('Failed', 'Parked', 'Discarded')`).
|
raised to 64 KB on any error row (`Status IN ('Failed', 'Parked', 'Discarded')`).
|
||||||
|
- **Inbound API exception.** For `Channel = ApiInbound`, `RequestSummary` and `ResponseSummary` are captured in full up to a per-body hard ceiling of 1 MiB (configurable via `AuditLog:InboundMaxBytes`; default 1 048 576 bytes; min 8 192; max 16 777 216). The 8 KiB / 64 KiB default/error caps that apply to other channels do not apply here. `PayloadTruncated = 1` is set only when the inbound ceiling is hit — verbatim capture is the normal case. The ceiling applies independently to each body. Header redaction and per-target body redactors still run before persistence.
|
||||||
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
|
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
|
||||||
bodies are never stored.
|
bodies are never stored.
|
||||||
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
|
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
|
||||||
@@ -387,6 +429,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`,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ API method scripts are compiled at central startup — all method definitions ar
|
|||||||
|
|
||||||
## API Call Logging
|
## API Call Logging
|
||||||
|
|
||||||
- **Every request — success or failure — emits one `ApiInbound.Completed` row** to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and truncated request/response bodies per the Audit Log capture policy (see Component-AuditLog.md, Payload Capture Policy). This supersedes the earlier failures-only stance: operational API traffic is now part of the centralized audit log, so configuration changes and call activity share a single retention/query surface.
|
- **Every request — success or failure — emits one `ApiInbound.Completed` row** to `ICentralAuditWriter` from request middleware before the HTTP response is flushed. The row captures the API key **name** (never the key material), remote IP, user-agent, response status, duration, and the request/response bodies. Bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB); `PayloadTruncated = 1` only when that ceiling is hit. Header redaction and per-target body redactors still apply (see Component-AuditLog.md, Payload Capture Policy). This supersedes the earlier failures-only stance: operational API traffic is now part of the centralized audit log, so configuration changes and call activity share a single retention/query surface.
|
||||||
- Script execution errors (500 responses) remain captured on the same `ApiInbound.Completed` row (response status + error fields) rather than emitting a separate failure-only event.
|
- Script execution errors (500 responses) remain captured on the same `ApiInbound.Completed` row (response status + error fields) rather than emitting a separate failure-only event.
|
||||||
- **Fail-soft semantics.** The audit write is synchronous (inline before the response is flushed), but failures are caught: a write that throws is logged and increments `CentralAuditWriteFailures` (see Health Monitoring #11) and the request still returns its normal HTTP response. A failed audit append never turns a successful API call into an error returned to the caller.
|
- **Fail-soft semantics.** The audit write is synchronous (inline before the response is flushed), but failures are caught: a write that throws is logged and increments `CentralAuditWriteFailures` (see Health Monitoring #11) and the request still returns its normal HTTP response. A failed audit append never turns a successful API call into an error returned to the caller.
|
||||||
- No rate limiting — this is a private API in a controlled industrial environment with a known set of callers. Misbehaving callers are handled operationally (disable the API key).
|
- No rate limiting — this is a private API in a controlled industrial environment with a known set of callers. Misbehaving callers are handled operationally (disable the API key).
|
||||||
@@ -199,7 +199,7 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
|||||||
- **Communication Layer**: Routes requests to sites when method implementations need site data.
|
- **Communication Layer**: Routes requests to sites when method implementations need site data.
|
||||||
- **Security & Auth**: API key validation (separate from LDAP/AD — API uses key-based auth).
|
- **Security & Auth**: API key validation (separate from LDAP/AD — API uses key-based auth).
|
||||||
- **Configuration Database (via IAuditService)**: All API key and method definition changes are audit logged.
|
- **Configuration Database (via IAuditService)**: All API key and method definition changes are audit logged.
|
||||||
- **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Payload truncation/redaction follows the Audit Log Payload Capture Policy.
|
- **Audit Log (#23)**: Every inbound API request emits an `ApiInbound.Completed` row via `ICentralAuditWriter` from request middleware (non-blocking for the HTTP response). Request and response bodies are captured in full up to `AuditLog:InboundMaxBytes` (default 1 MiB) per the Audit Log Payload Capture Policy; redaction (headers + per-target body redactors) still applies before persistence.
|
||||||
- **Cluster Infrastructure**: API is hosted on the active central node and fails over with it.
|
- **Cluster Infrastructure**: API is hosted on the active central node and fails over with it.
|
||||||
|
|
||||||
## Interactions
|
## Interactions
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
namespace ScadaLink.AuditLog.Configuration;
|
namespace ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -33,4 +36,14 @@ public sealed class AuditLogOptions
|
|||||||
|
|
||||||
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
|
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
|
||||||
public int RetentionDays { get; set; } = 365;
|
public int RetentionDays { get; set; } = 365;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-body byte ceiling applied to <see cref="AuditEvent.RequestSummary"/> and
|
||||||
|
/// <see cref="AuditEvent.ResponseSummary"/> for <see cref="AuditChannel.ApiInbound"/> rows
|
||||||
|
/// (default 1 MiB). The 8 KiB / 64 KiB default/error caps that apply to other channels
|
||||||
|
/// do not apply here — inbound traffic captures verbatim up to this ceiling and only
|
||||||
|
/// then sets <see cref="AuditEvent.PayloadTruncated"/>. See
|
||||||
|
/// <c>docs/plans/2026-05-23-inbound-api-full-response-audit-design.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int InboundMaxBytes { get; set; } = 1_048_576;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
|||||||
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||||
public const int MaxRetentionDays = 3650;
|
public const int MaxRetentionDays = 3650;
|
||||||
|
|
||||||
|
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.InboundMaxBytes"/> (8 KiB).</summary>
|
||||||
|
public const int MinInboundMaxBytes = 8_192;
|
||||||
|
|
||||||
|
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.InboundMaxBytes"/> (16 MiB).</summary>
|
||||||
|
public const int MaxInboundMaxBytes = 16_777_216;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
|
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
|
||||||
{
|
{
|
||||||
@@ -50,6 +56,13 @@ public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
|||||||
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.InboundMaxBytes < MinInboundMaxBytes || options.InboundMaxBytes > MaxInboundMaxBytes)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " +
|
||||||
|
$"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
return failures.Count == 0
|
return failures.Count == 0
|
||||||
? ValidateOptionsResult.Success
|
? ValidateOptionsResult.Success
|
||||||
: ValidateOptionsResult.Fail(failures);
|
: ValidateOptionsResult.Fail(failures);
|
||||||
|
|||||||
@@ -118,7 +118,14 @@ public sealed class DefaultAuditPayloadFilter : IAuditPayloadFilter
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var opts = _options.CurrentValue;
|
var opts = _options.CurrentValue;
|
||||||
var cap = IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
|
// Inbound API gets a dedicated, larger ceiling — request/response bodies are
|
||||||
|
// captured verbatim up to InboundMaxBytes (default 1 MiB) so support can
|
||||||
|
// replay exactly what the caller sent and what we returned. Other channels
|
||||||
|
// keep the global 8 KiB / 64 KiB policy.
|
||||||
|
// See docs/plans/2026-05-23-inbound-api-full-response-audit-design.md.
|
||||||
|
var cap = rawEvent.Channel == AuditChannel.ApiInbound
|
||||||
|
? opts.InboundMaxBytes
|
||||||
|
: (IsErrorStatus(rawEvent.Status) ? opts.ErrorCapBytes : opts.DefaultCapBytes);
|
||||||
|
|
||||||
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
// --- Header-redaction stage (runs BEFORE truncation) ----------
|
||||||
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
|
var request = RedactHeaders(rawEvent.RequestSummary, opts.HeaderRedactList);
|
||||||
|
|||||||
@@ -114,12 +114,63 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
PayloadTruncated INTEGER NOT NULL,
|
PayloadTruncated INTEGER NOT NULL,
|
||||||
Extra TEXT NULL,
|
Extra TEXT NULL,
|
||||||
ForwardState TEXT NOT NULL,
|
ForwardState TEXT NOT NULL,
|
||||||
|
ExecutionId TEXT NULL,
|
||||||
|
ParentExecutionId TEXT NULL,
|
||||||
PRIMARY KEY (EventId)
|
PRIMARY KEY (EventId)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
|
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
|
||||||
ON AuditLog (ForwardState, OccurredAtUtc);
|
ON AuditLog (ForwardState, OccurredAtUtc);
|
||||||
""";
|
""";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
|
|
||||||
|
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
|
||||||
|
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
|
||||||
|
// table that already exists from a pre-ExecutionId build, so an
|
||||||
|
// auditlog.db created by an older build needs the column ALTER-ed in.
|
||||||
|
// The file is durable across restart/failover by design (7-day
|
||||||
|
// retention), so without this step every WriteAsync on an upgraded
|
||||||
|
// deployment would bind $ExecutionId against a missing column and the
|
||||||
|
// best-effort write path would silently drop every site audit row.
|
||||||
|
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
|
||||||
|
// probed first and the ALTER skipped when already there. The column is
|
||||||
|
// nullable with no default, so any row written before this migration
|
||||||
|
// reads back ExecutionId = null (back-compat).
|
||||||
|
AddColumnIfMissing("ExecutionId", "TEXT NULL");
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
|
||||||
|
// ExecutionId above. A deployment that already ran the ExecutionId
|
||||||
|
// branch has an auditlog.db with the 21-column schema and no
|
||||||
|
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
|
||||||
|
// so it is ALTER-ed in here. Nullable with no default — rows written
|
||||||
|
// before this migration read back ParentExecutionId = null.
|
||||||
|
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
|
||||||
|
/// it is not already present (used for <c>ExecutionId</c> and
|
||||||
|
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||||
|
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
|
||||||
|
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
|
||||||
|
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
|
||||||
|
/// here to match the rest of this writer's bootstrap DDL.
|
||||||
|
/// </summary>
|
||||||
|
private void AddColumnIfMissing(string columnName, string columnDefinition)
|
||||||
|
{
|
||||||
|
using var probe = _connection.CreateCommand();
|
||||||
|
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
|
||||||
|
probe.Parameters.AddWithValue("$name", columnName);
|
||||||
|
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var alter = _connection.CreateCommand();
|
||||||
|
// Column name + definition are caller-controlled constants, never user
|
||||||
|
// input — safe to interpolate (parameters are not permitted in DDL).
|
||||||
|
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
|
||||||
|
alter.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -221,12 +272,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId, ParentExecutionId
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
||||||
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
||||||
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
||||||
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState
|
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
|
||||||
|
$ExecutionId, $ParentExecutionId
|
||||||
);
|
);
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -250,6 +303,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
||||||
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
||||||
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||||
|
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
|
||||||
|
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
|
||||||
|
|
||||||
foreach (var pending in batch)
|
foreach (var pending in batch)
|
||||||
{
|
{
|
||||||
@@ -274,6 +329,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
||||||
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
||||||
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
||||||
|
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
|
||||||
|
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -331,7 +388,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId, ParentExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState = $pending
|
WHERE ForwardState = $pending
|
||||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||||
@@ -379,7 +437,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId, ParentExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState = $forwarded
|
WHERE ForwardState = $forwarded
|
||||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||||
@@ -465,7 +524,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId, ParentExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState IN ($pending, $forwarded)
|
WHERE ForwardState IN ($pending, $forwarded)
|
||||||
AND OccurredAtUtc >= $since
|
AND OccurredAtUtc >= $since
|
||||||
@@ -642,6 +702,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
PayloadTruncated = reader.GetInt32(17) != 0,
|
PayloadTruncated = reader.GetInt32(17) != 0,
|
||||||
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
||||||
|
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
|
||||||
|
ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|||||||
Channel = channel,
|
Channel = channel,
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
CorrelationId = context.TrackedOperationId.Value,
|
CorrelationId = context.TrackedOperationId.Value,
|
||||||
|
// Audit Log #23 (ExecutionId Task 4): the originating script
|
||||||
|
// execution's per-run correlation id, threaded through the S&F
|
||||||
|
// buffer; null on rows buffered before Task 4 (back-compat).
|
||||||
|
ExecutionId = context.ExecutionId,
|
||||||
|
// Audit Log #23 (ParentExecutionId Task 6): the spawning
|
||||||
|
// inbound-API request's ExecutionId, threaded through the S&F
|
||||||
|
// buffer alongside ExecutionId so the retry-loop cached rows
|
||||||
|
// correlate back to the cross-execution chain. Null for a
|
||||||
|
// non-routed run and on rows buffered before Task 6.
|
||||||
|
ParentExecutionId = context.ParentExecutionId,
|
||||||
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
|
||||||
SourceInstanceId = context.SourceInstanceId,
|
SourceInstanceId = context.SourceInstanceId,
|
||||||
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
|
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
|
||||||
|
// threaded through the S&F buffer alongside ExecutionId — the
|
||||||
|
// retry-loop cached rows carry the same provenance the
|
||||||
|
// script-side cached rows do. Null on pre-Task-4 buffered rows.
|
||||||
|
SourceScript = context.SourceScript,
|
||||||
Target = context.Target,
|
Target = context.Target,
|
||||||
Status = status,
|
Status = status,
|
||||||
HttpStatus = httpStatus,
|
HttpStatus = httpStatus,
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ public static class AuditCommands
|
|||||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||||
|
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
||||||
|
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
|
||||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||||
@@ -74,6 +76,8 @@ public static class AuditCommands
|
|||||||
cmd.Add(targetOption);
|
cmd.Add(targetOption);
|
||||||
cmd.Add(actorOption);
|
cmd.Add(actorOption);
|
||||||
cmd.Add(correlationIdOption);
|
cmd.Add(correlationIdOption);
|
||||||
|
cmd.Add(executionIdOption);
|
||||||
|
cmd.Add(parentExecutionIdOption);
|
||||||
cmd.Add(errorsOnlyOption);
|
cmd.Add(errorsOnlyOption);
|
||||||
cmd.Add(pageSizeOption);
|
cmd.Add(pageSizeOption);
|
||||||
cmd.Add(allOption);
|
cmd.Add(allOption);
|
||||||
@@ -101,6 +105,8 @@ public static class AuditCommands
|
|||||||
Target = result.GetValue(targetOption),
|
Target = result.GetValue(targetOption),
|
||||||
Actor = result.GetValue(actorOption),
|
Actor = result.GetValue(actorOption),
|
||||||
CorrelationId = result.GetValue(correlationIdOption),
|
CorrelationId = result.GetValue(correlationIdOption),
|
||||||
|
ExecutionId = result.GetValue(executionIdOption),
|
||||||
|
ParentExecutionId = result.GetValue(parentExecutionIdOption),
|
||||||
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
ErrorsOnly = result.GetValue(errorsOnlyOption),
|
||||||
PageSize = result.GetValue(pageSizeOption),
|
PageSize = result.GetValue(pageSizeOption),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ public sealed class AuditQueryArgs
|
|||||||
public string? Target { get; set; }
|
public string? Target { get; set; }
|
||||||
public string? Actor { get; set; }
|
public string? Actor { get; set; }
|
||||||
public string? CorrelationId { get; set; }
|
public string? CorrelationId { get; set; }
|
||||||
|
public string? ExecutionId { get; set; }
|
||||||
|
public string? ParentExecutionId { get; set; }
|
||||||
public bool ErrorsOnly { get; set; }
|
public bool ErrorsOnly { get; set; }
|
||||||
public int PageSize { get; set; } = 100;
|
public int PageSize { get; set; } = 100;
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,8 @@ public static class AuditQueryHelpers
|
|||||||
Add("target", args.Target);
|
Add("target", args.Target);
|
||||||
Add("actor", args.Actor);
|
Add("actor", args.Actor);
|
||||||
Add("correlationId", args.CorrelationId);
|
Add("correlationId", args.CorrelationId);
|
||||||
|
Add("executionId", args.ExecutionId);
|
||||||
|
Add("parentExecutionId", args.ParentExecutionId);
|
||||||
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
if (afterOccurredAtUtc.HasValue)
|
if (afterOccurredAtUtc.HasValue)
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ public static class AuditExportEndpoints
|
|||||||
correlationId = parsedCorr;
|
correlationId = parsedCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Guid? executionId = null;
|
||||||
|
if (query.TryGetValue("executionId", out var execValues)
|
||||||
|
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||||
|
{
|
||||||
|
executionId = parsedExec;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? parentExecutionId = null;
|
||||||
|
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||||
|
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||||
|
{
|
||||||
|
parentExecutionId = parsedParentExec;
|
||||||
|
}
|
||||||
|
|
||||||
DateTime? fromUtc = ParseUtcDate(query, "from");
|
DateTime? fromUtc = ParseUtcDate(query, "from");
|
||||||
DateTime? toUtc = ParseUtcDate(query, "to");
|
DateTime? toUtc = ParseUtcDate(query, "to");
|
||||||
|
|
||||||
@@ -116,6 +130,8 @@ public static class AuditExportEndpoints
|
|||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
FromUtc: fromUtc,
|
FromUtc: fromUtc,
|
||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,131 +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">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>
|
|
||||||
}
|
|
||||||
<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,62 +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>. The parent page does not
|
|
||||||
/// auto-apply that filter today — it is a deep link the page can use
|
|
||||||
/// when Bundle D wires up query-string deserialization.
|
|
||||||
/// </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.
|
||||||
@@ -77,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.
|
||||||
@@ -90,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)
|
||||||
@@ -250,125 +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>
|
|
||||||
/// 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);
|
||||||
|
}
|
||||||
@@ -6,78 +6,58 @@
|
|||||||
|
|
||||||
<div class="card mb-3" data-test="audit-filter-bar">
|
<div class="card mb-3" data-test="audit-filter-bar">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@* Channel chip multi-select. *@
|
@* All filters sit in one wrapped row. Kind / Status / Site use compact
|
||||||
<div class="mb-2" data-test="filter-channel">
|
MultiSelectDropdown controls; Channel is a single-select because the
|
||||||
<label class="form-label small mb-1">Channel</label>
|
Kind options narrow to the chosen channel — so the bar stays a row or
|
||||||
<div>
|
two tall instead of four stacked blocks of chip buttons. *@
|
||||||
@foreach (var channel in Enum.GetValues<AuditChannel>())
|
|
||||||
{
|
|
||||||
var selected = _model.Channels.Contains(channel);
|
|
||||||
<button type="button" data-test="chip-channel-@channel"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleChannel(channel)">
|
|
||||||
@channel
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Kind chip multi-select — narrowed by Channel selection. *@
|
|
||||||
<div class="mb-2" data-test="filter-kind">
|
|
||||||
<label class="form-label small mb-1">Kind</label>
|
|
||||||
<div>
|
|
||||||
@foreach (var kind in _model.VisibleKinds())
|
|
||||||
{
|
|
||||||
var selected = _model.Kinds.Contains(kind);
|
|
||||||
<button type="button" data-test="chip-kind-@kind"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleKind(kind)">
|
|
||||||
@kind
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Status chip multi-select. *@
|
|
||||||
<div class="mb-2" data-test="filter-status">
|
|
||||||
<label class="form-label small mb-1">Status</label>
|
|
||||||
<div>
|
|
||||||
@foreach (var status in Enum.GetValues<AuditStatus>())
|
|
||||||
{
|
|
||||||
var selected = _model.Statuses.Contains(status);
|
|
||||||
<button type="button" data-test="chip-status-@status"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleStatus(status)">
|
|
||||||
@status
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* Site chip multi-select — populated from ISiteRepository. *@
|
|
||||||
<div class="mb-2" data-test="filter-site">
|
|
||||||
<label class="form-label small mb-1">Site</label>
|
|
||||||
<div>
|
|
||||||
@if (_sites.Count == 0)
|
|
||||||
{
|
|
||||||
<span class="text-muted small">No sites available.</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (var site in _sites)
|
|
||||||
{
|
|
||||||
var selected = _model.SiteIdentifiers.Contains(site.SiteIdentifier);
|
|
||||||
<button type="button" data-test="chip-site-@site.SiteIdentifier"
|
|
||||||
class="@ChipClass(selected)"
|
|
||||||
@onclick="() => ToggleSite(site.SiteIdentifier)">
|
|
||||||
@site.Name
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-2 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
|
@* Single-select: one channel at a time, so the Kind options below
|
||||||
|
narrow cleanly to that channel. "All channels" clears it. *@
|
||||||
|
<div class="col-auto" data-test="filter-channel">
|
||||||
|
<label class="form-label small mb-1" for="audit-channel">Channel</label>
|
||||||
|
<select id="audit-channel" data-test="filter-channel-select"
|
||||||
|
class="form-select form-select-sm" @bind="SelectedChannel">
|
||||||
|
<option value="">All channels</option>
|
||||||
|
@foreach (var channel in _channels)
|
||||||
|
{
|
||||||
|
<option value="@channel">@channel</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Kind options are narrowed by the Channel selection (VisibleKinds). *@
|
||||||
|
<div class="col-auto" data-test="filter-kind">
|
||||||
|
<label class="form-label small mb-1">Kind</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="AuditKind"
|
||||||
|
Items="_model.VisibleKinds()"
|
||||||
|
Selected="_model.Kinds"
|
||||||
|
DataTest="filter-kind-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto" data-test="filter-status">
|
||||||
|
<label class="form-label small mb-1">Status</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="AuditStatus"
|
||||||
|
Items="_statuses"
|
||||||
|
Selected="_model.Statuses"
|
||||||
|
DataTest="filter-status-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto" data-test="filter-site">
|
||||||
|
<label class="form-label small mb-1">Site</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="string"
|
||||||
|
Items="_siteIds"
|
||||||
|
Selected="_model.SiteIdentifiers"
|
||||||
|
Display="SiteName"
|
||||||
|
EmptyText="No sites available"
|
||||||
|
DataTest="filter-site-ms" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-auto" data-test="filter-time-range">
|
<div class="col-auto" data-test="filter-time-range">
|
||||||
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
|
||||||
<select id="audit-time-range" class="form-select form-select-sm"
|
<select id="audit-time-range" class="form-select form-select-sm"
|
||||||
@@ -137,6 +117,26 @@
|
|||||||
placeholder="contains…" @bind="_model.ActorSearch" />
|
placeholder="contains…" @bind="_model.ActorSearch" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ExecutionId is an exact-match Guid filter — the operator pastes the
|
||||||
|
universal per-run correlation value. Lax-parsed in ToFilter so a
|
||||||
|
blank/malformed paste simply drops the constraint. *@
|
||||||
|
<div class="col-auto" data-test="filter-execution-id">
|
||||||
|
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
|
||||||
|
<input id="audit-execution-id" type="text"
|
||||||
|
class="form-control form-control-sm font-monospace"
|
||||||
|
placeholder="paste GUID…" @bind="_model.ExecutionId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ParentExecutionId is an exact-match Guid filter — the operator pastes
|
||||||
|
the spawner execution's id to find every run it spawned. Lax-parsed
|
||||||
|
in ToFilter, exactly like ExecutionId above. *@
|
||||||
|
<div class="col-auto" data-test="filter-parent-execution-id">
|
||||||
|
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
|
||||||
|
<input id="audit-parent-execution-id" type="text"
|
||||||
|
class="form-control form-control-sm font-monospace"
|
||||||
|
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-auto" data-test="filter-errors-only">
|
<div class="col-auto" data-test="filter-errors-only">
|
||||||
<div class="form-check mb-1">
|
<div class="form-check mb-1">
|
||||||
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
||||||
|
|||||||
@@ -7,19 +7,32 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
/// Filter bar for the central Audit Log page (#23 M7-T2). Owns the
|
||||||
/// <see cref="AuditQueryModel"/> binding state, renders the 10 filter elements
|
/// <see cref="AuditQueryModel"/> binding state and renders the filter controls
|
||||||
/// plus the Errors-only toggle, and publishes a collapsed
|
/// — Channel as a single-select (one channel at a time, so the Kind options
|
||||||
/// <see cref="AuditLogQueryFilter"/> via <see cref="OnFilterChanged"/> when the
|
/// narrow to it cleanly); Kind / Status / Site as compact
|
||||||
/// user clicks Apply. See <see cref="AuditQueryModel"/> for the multi-select →
|
/// <see cref="ScadaLink.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
|
||||||
/// single-value collapse contract.
|
/// controls; plus the time range, free-text searches and the Errors-only
|
||||||
|
/// toggle — and publishes an <see cref="AuditLogQueryFilter"/> via
|
||||||
|
/// <see cref="OnFilterChanged"/> when the user clicks Apply. The selected
|
||||||
|
/// dimensions map through to the filter's list fields; see
|
||||||
|
/// <see cref="AuditQueryModel"/> for the Errors-only and time-range rules.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditFilterBar
|
public partial class AuditFilterBar
|
||||||
{
|
{
|
||||||
private readonly AuditQueryModel _model = new();
|
private readonly AuditQueryModel _model = new();
|
||||||
private List<Site> _sites = new();
|
private List<Site> _sites = new();
|
||||||
|
|
||||||
|
/// <summary>Channel options — the full enum, fixed for the component's lifetime.</summary>
|
||||||
|
private static readonly IReadOnlyList<AuditChannel> _channels = Enum.GetValues<AuditChannel>();
|
||||||
|
|
||||||
|
/// <summary>Status options — the full enum, fixed for the component's lifetime.</summary>
|
||||||
|
private static readonly IReadOnlyList<AuditStatus> _statuses = Enum.GetValues<AuditStatus>();
|
||||||
|
|
||||||
|
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
|
||||||
|
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when the user clicks Apply. Carries the collapsed
|
/// Raised when the user clicks Apply. Carries the
|
||||||
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
||||||
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -51,10 +64,9 @@ public partial class AuditFilterBar
|
|||||||
_model.InstanceSearch = InitialInstanceSearch.Trim();
|
_model.InstanceSearch = InitialInstanceSearch.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate the Site dropdown at component init. Failure is non-fatal — the
|
||||||
// Populate the Site chips at component init. Failure is non-fatal — the chip
|
// dropdown just shows "No sites available." Sites are listed by Name to
|
||||||
// section just shows "No sites available." Sites are listed by Name to match
|
// match operator expectations from the Notification Report.
|
||||||
// operator expectations from the Notification Report.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sites = await SiteRepository.GetAllSitesAsync();
|
var sites = await SiteRepository.GetAllSitesAsync();
|
||||||
@@ -62,48 +74,52 @@ public partial class AuditFilterBar
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Swallowed: filter bar still renders without the Site chips. The page
|
// Swallowed: filter bar still renders without the Site options. The page
|
||||||
// surfaces site-load errors elsewhere (the grid query path).
|
// surfaces site-load errors elsewhere (the grid query path).
|
||||||
_sites = new();
|
_sites = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleChannel(AuditChannel channel)
|
/// <summary>
|
||||||
|
/// Single-select Channel binding for the filter bar. The Audit Log filters one
|
||||||
|
/// channel at a time so the Kind options narrow cleanly to it; the model still
|
||||||
|
/// stores the selection as a set (0 or 1 entry) so <see cref="AuditQueryModel.ToFilter"/>
|
||||||
|
/// and <see cref="AuditQueryModel.VisibleKinds"/> are unchanged. <c>null</c> = all channels.
|
||||||
|
/// </summary>
|
||||||
|
private AuditChannel? SelectedChannel
|
||||||
{
|
{
|
||||||
if (!_model.Channels.Add(channel))
|
get => _model.Channels.Count > 0 ? _model.Channels.First() : null;
|
||||||
|
set
|
||||||
{
|
{
|
||||||
_model.Channels.Remove(channel);
|
_model.Channels.Clear();
|
||||||
}
|
if (value is { } channel)
|
||||||
|
{
|
||||||
|
_model.Channels.Add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
// Drop Kind chips that fall outside the new visible set. Keeps "Channel and
|
OnChannelsChanged();
|
||||||
// Kind both picked" coherent — without this, removing a channel could leave
|
}
|
||||||
// stale Kind chips selected that no longer match any visible chip.
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs after the Channel selection changes. Drops any Kind selections that fell
|
||||||
|
/// outside the new visible set — without this, changing the channel could leave
|
||||||
|
/// stale Kind selections that no longer match any visible option.
|
||||||
|
/// </summary>
|
||||||
|
private void OnChannelsChanged()
|
||||||
|
{
|
||||||
var visible = _model.VisibleKinds().ToHashSet();
|
var visible = _model.VisibleKinds().ToHashSet();
|
||||||
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
|
_model.Kinds.RemoveWhere(k => !visible.Contains(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleKind(AuditKind kind)
|
/// <summary>Display label for a site identifier — its friendly Name, id as fallback.</summary>
|
||||||
|
private string SiteName(string siteIdentifier)
|
||||||
{
|
{
|
||||||
if (!_model.Kinds.Add(kind))
|
var site = _sites.FirstOrDefault(s =>
|
||||||
{
|
string.Equals(s.SiteIdentifier, siteIdentifier, StringComparison.OrdinalIgnoreCase));
|
||||||
_model.Kinds.Remove(kind);
|
return site?.Name ?? siteIdentifier;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleStatus(AuditStatus status)
|
|
||||||
{
|
|
||||||
if (!_model.Statuses.Add(status))
|
|
||||||
{
|
|
||||||
_model.Statuses.Remove(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleSite(string siteIdentifier)
|
|
||||||
{
|
|
||||||
if (!_model.SiteIdentifiers.Add(siteIdentifier))
|
|
||||||
{
|
|
||||||
_model.SiteIdentifiers.Remove(siteIdentifier);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClearFilters()
|
private void ClearFilters()
|
||||||
@@ -119,6 +135,8 @@ public partial class AuditFilterBar
|
|||||||
_model.ScriptSearch = string.Empty;
|
_model.ScriptSearch = string.Empty;
|
||||||
_model.TargetSearch = string.Empty;
|
_model.TargetSearch = string.Empty;
|
||||||
_model.ActorSearch = string.Empty;
|
_model.ActorSearch = string.Empty;
|
||||||
|
_model.ExecutionId = string.Empty;
|
||||||
|
_model.ParentExecutionId = string.Empty;
|
||||||
_model.ErrorsOnly = false;
|
_model.ErrorsOnly = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,11 +147,6 @@ public partial class AuditFilterBar
|
|||||||
await OnFilterChanged.InvokeAsync(filter);
|
await OnFilterChanged.InvokeAsync(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ChipClass(bool selected) =>
|
|
||||||
selected
|
|
||||||
? "btn btn-sm btn-primary me-1 mb-1"
|
|
||||||
: "btn btn-sm btn-outline-secondary me-1 mb-1";
|
|
||||||
|
|
||||||
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
private static string TimeRangeLabel(AuditTimeRangePreset preset) => preset switch
|
||||||
{
|
{
|
||||||
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
AuditTimeRangePreset.Last5Minutes => "now − 5 min → now",
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ public sealed class AuditQueryModel
|
|||||||
public string TargetSearch { get; set; } = string.Empty;
|
public string TargetSearch { get; set; } = string.Empty;
|
||||||
public string ActorSearch { get; set; } = string.Empty;
|
public string ActorSearch { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
|
||||||
|
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
|
||||||
|
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
|
||||||
|
/// unparseable value simply yields no constraint.
|
||||||
|
/// </summary>
|
||||||
|
public string ExecutionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
|
||||||
|
/// execution's Guid to find every run it spawned. Stored as free text;
|
||||||
|
/// <see cref="ToFilter"/> lax-parses it through
|
||||||
|
/// <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or unparseable
|
||||||
|
/// value simply yields no constraint, mirroring <see cref="ExecutionId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string ParentExecutionId { get; set; } = string.Empty;
|
||||||
|
|
||||||
public bool ErrorsOnly { get; set; }
|
public bool ErrorsOnly { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -114,6 +131,17 @@ public sealed class AuditQueryModel
|
|||||||
|
|
||||||
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
|
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
|
||||||
|
|
||||||
|
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
|
||||||
|
// constraint rather than an error, mirroring the optional-filter contract.
|
||||||
|
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
|
||||||
|
? parsedExecutionId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Same lax-parse contract for the pasted ParentExecutionId.
|
||||||
|
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
|
||||||
|
? parsedParentExecutionId
|
||||||
|
: null;
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
|
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
|
||||||
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
|
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
|
||||||
@@ -122,6 +150,8 @@ public sealed class AuditQueryModel
|
|||||||
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
|
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
|
||||||
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
|
||||||
CorrelationId: null,
|
CorrelationId: null,
|
||||||
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
FromUtc: fromUtc,
|
FromUtc: fromUtc,
|
||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
// Compact display for Guid id columns: the first 8 hex digits, mirroring
|
||||||
|
// the drilldown drawer's ShortEventId presentation. The full value is kept
|
||||||
|
// in the cell's title attribute so it stays copy-paste accessible.
|
||||||
|
private static string ShortGuid(Guid value)
|
||||||
|
{
|
||||||
|
var n = value.ToString("N");
|
||||||
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
|
}
|
||||||
|
|
||||||
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
|
||||||
{
|
{
|
||||||
switch (key)
|
switch (key)
|
||||||
@@ -111,6 +120,30 @@
|
|||||||
case "Actor":
|
case "Actor":
|
||||||
<span class="small">@(row.Actor ?? "—")</span>
|
<span class="small">@(row.Actor ?? "—")</span>
|
||||||
break;
|
break;
|
||||||
|
case "ExecutionId":
|
||||||
|
@if (row.ExecutionId is { } executionId)
|
||||||
|
{
|
||||||
|
<span class="small font-monospace"
|
||||||
|
data-test="execution-id-@row.EventId"
|
||||||
|
title="@executionId">@ShortGuid(executionId)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="small text-muted">—</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ParentExecutionId":
|
||||||
|
@if (row.ParentExecutionId is { } parentExecutionId)
|
||||||
|
{
|
||||||
|
<span class="small font-monospace"
|
||||||
|
data-test="parent-execution-id-@row.EventId"
|
||||||
|
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="small text-muted">—</span>
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "DurationMs":
|
case "DurationMs":
|
||||||
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
|
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
|
||||||
/// Renders the 10 columns named in Component-AuditLog.md §10:
|
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
|
||||||
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs,
|
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
|
||||||
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/>
|
/// ErrorMessage — plus the ExecutionId per-run correlation column and the
|
||||||
|
/// ParentExecutionId spawner-correlation column. Talks to
|
||||||
|
/// <see cref="Services.IAuditLogQueryService"/>
|
||||||
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
|
||||||
/// source without standing up EF Core.
|
/// source without standing up EF Core.
|
||||||
///
|
///
|
||||||
@@ -121,6 +123,8 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
("Status", "Status"),
|
("Status", "Status"),
|
||||||
("Target", "Target"),
|
("Target", "Target"),
|
||||||
("Actor", "Actor"),
|
("Actor", "Actor"),
|
||||||
|
("ExecutionId", "ExecutionId"),
|
||||||
|
("ParentExecutionId", "ParentExecutionId"),
|
||||||
("DurationMs", "DurationMs"),
|
("DurationMs", "DurationMs"),
|
||||||
("HttpStatus", "HttpStatus"),
|
("HttpStatus", "HttpStatus"),
|
||||||
("ErrorMessage", "ErrorMessage"),
|
("ErrorMessage", "ErrorMessage"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
125
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
125
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
@using ScadaLink.Commons.Types.Audit
|
||||||
|
|
||||||
|
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
|
||||||
|
list the repository returns; this component assembles it into a tree (joining
|
||||||
|
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
|
||||||
|
|
||||||
|
Recursion is expressed by the component rendering <ExecutionTree> for each
|
||||||
|
child subtree. To keep that recursion finite even on corrupt/cyclic input,
|
||||||
|
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
|
||||||
|
downward via the PreBuiltRoots parameter — child instances never re-run the
|
||||||
|
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
|
||||||
|
cycle is broken on first revisit. *@
|
||||||
|
|
||||||
|
@if (_rootsToRender.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
|
||||||
|
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
|
||||||
|
@foreach (var subtree in _rootsToRender)
|
||||||
|
{
|
||||||
|
var node = subtree.Node;
|
||||||
|
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
|
||||||
|
var isStub = node.RowCount == 0;
|
||||||
|
<li class="execution-tree-item" @key="node.ExecutionId">
|
||||||
|
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
|
||||||
|
data-test="tree-node-@node.ExecutionId">
|
||||||
|
@if (subtree.Children.Count > 0)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="execution-tree-toggle"
|
||||||
|
data-test="tree-toggle-@node.ExecutionId"
|
||||||
|
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
|
||||||
|
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
|
||||||
|
@onclick="() => ToggleExpand(node.ExecutionId)">
|
||||||
|
<span class="execution-tree-toggle-glyph" aria-hidden="true">
|
||||||
|
@(IsExpanded(node.ExecutionId) ? "−" : "+")
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="execution-tree-body"
|
||||||
|
@ondblclick="() => OnNodeActivated.InvokeAsync(node.ExecutionId)">
|
||||||
|
<div class="execution-tree-headline">
|
||||||
|
<a class="execution-tree-link font-monospace"
|
||||||
|
data-test="tree-node-link-@node.ExecutionId"
|
||||||
|
href="@AuditLogUrl(node.ExecutionId)"
|
||||||
|
title="Open the Audit Log filtered to execution @node.ExecutionId">
|
||||||
|
@ShortId(node.ExecutionId)
|
||||||
|
</a>
|
||||||
|
@if (isCurrent)
|
||||||
|
{
|
||||||
|
<span class="badge text-bg-primary execution-tree-tag"
|
||||||
|
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
|
||||||
|
}
|
||||||
|
@if (isStub)
|
||||||
|
{
|
||||||
|
<span class="badge text-bg-secondary execution-tree-tag"
|
||||||
|
data-test="stub-node-@node.ExecutionId">No audited actions</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="execution-tree-rowcount text-muted small"
|
||||||
|
data-test="tree-rowcount-@node.ExecutionId">
|
||||||
|
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isStub)
|
||||||
|
{
|
||||||
|
<div class="execution-tree-meta text-muted small">
|
||||||
|
Execution with no audited actions — referenced as a parent, but it
|
||||||
|
emitted no audit rows of its own (or its rows have been purged).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="execution-tree-meta small">
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Source</span>
|
||||||
|
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
|
||||||
|
</span>
|
||||||
|
@if (node.Channels.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Channels</span>
|
||||||
|
@string.Join(", ", node.Channels)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (node.Statuses.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Statuses</span>
|
||||||
|
@string.Join(", ", node.Statuses)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span class="execution-tree-meta-item">
|
||||||
|
<span class="text-muted">Time span</span>
|
||||||
|
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId))
|
||||||
|
{
|
||||||
|
@* Recurse: each child subtree is already assembled, so the
|
||||||
|
nested instance renders directly from PreBuiltRoots and skips
|
||||||
|
the flat-list assembly entirely. *@
|
||||||
|
<ExecutionTree PreBuiltRoots="subtree.Children"
|
||||||
|
ArrivedFromExecutionId="ArrivedFromExecutionId"
|
||||||
|
OnNodeActivated="OnNodeActivated"
|
||||||
|
Depth="Depth + 1" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
275
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
275
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursive Blazor tree component for the execution-chain view (Audit Log
|
||||||
|
/// ParentExecutionId feature, Task 10).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
|
||||||
|
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
|
||||||
|
/// root instance (<see cref="Depth"/> == 0) assembles it once in
|
||||||
|
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
|
||||||
|
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
|
||||||
|
/// and identifies the roots (nodes whose parent is null or not present in the
|
||||||
|
/// list — a purged/ghost parent). Nested instances skip assembly: the parent
|
||||||
|
/// hands each child subtree down pre-built via <see cref="PreBuiltRoots"/>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
|
||||||
|
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
|
||||||
|
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> values while
|
||||||
|
/// walking children, so a node is attached to the tree at most once — a cycle
|
||||||
|
/// (A→B, B→A) is broken at the first revisit and every execution still renders
|
||||||
|
/// exactly once.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Presentation.</b> Each node shows the short execution id (a link to
|
||||||
|
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
|
||||||
|
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
|
||||||
|
/// == 0) is marked "No audited actions". The node the user arrived from
|
||||||
|
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
|
||||||
|
/// are expandable; all nodes start expanded so the whole chain is visible.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class ExecutionTree
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One assembled subtree: a node plus its already-linked child subtrees.
|
||||||
|
/// Recursive — children are themselves <see cref="Subtree"/> values.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Node">The execution this subtree is rooted at.</param>
|
||||||
|
/// <param name="Children">
|
||||||
|
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
|
||||||
|
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
|
||||||
|
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
|
||||||
|
/// </param>
|
||||||
|
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The flat node list to assemble into a tree. Supplied on the ROOT
|
||||||
|
/// instance only (<see cref="Depth"/> == 0); nested instances receive
|
||||||
|
/// <see cref="PreBuiltRoots"/> instead.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-assembled child subtrees, threaded down from a parent
|
||||||
|
/// <see cref="ExecutionTree"/> so nested instances render without
|
||||||
|
/// re-running the flat-list assembly. Null / unused on the root instance.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The execution the user drilled in from — its node is visually
|
||||||
|
/// highlighted so the user keeps their bearings within the chain.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nesting depth. 0 on the root instance (which owns flat-list assembly);
|
||||||
|
/// each recursive child increments it. Used purely to pick the assembly
|
||||||
|
/// path and to tag the root <c><ul></c> for styling.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public int Depth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when a node is double-clicked, carrying that node's
|
||||||
|
/// <see cref="ExecutionTreeNode.ExecutionId"/>. The same callback is
|
||||||
|
/// threaded unchanged into every recursive child instance, so a
|
||||||
|
/// double-click on a node at any depth invokes the root-supplied handler
|
||||||
|
/// (used to open the node detail modal).
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public EventCallback<Guid> OnNodeActivated { get; set; }
|
||||||
|
|
||||||
|
// The subtrees this instance renders: assembled from Nodes on the root,
|
||||||
|
// or taken straight from PreBuiltRoots on a nested instance.
|
||||||
|
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
||||||
|
|
||||||
|
// The Nodes reference the current _rootsToRender was assembled from. Used
|
||||||
|
// to skip a redundant re-assembly when OnParametersSet fires for an
|
||||||
|
// unrelated parameter change (the flat list itself is unchanged).
|
||||||
|
private IReadOnlyList<ExecutionTreeNode>? _assembledFrom;
|
||||||
|
|
||||||
|
// Per-execution expand/collapse state. Absent => expanded (the default):
|
||||||
|
// the whole chain is shown on arrival so the user sees the full picture.
|
||||||
|
private readonly HashSet<Guid> _collapsed = new();
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
// Nested instance: the parent already assembled our subtrees.
|
||||||
|
if (Depth > 0)
|
||||||
|
{
|
||||||
|
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root instance: assemble the flat list into a tree. Re-assemble only
|
||||||
|
// when the Nodes reference itself changes — OnParametersSet also fires
|
||||||
|
// for unrelated parameter changes (e.g. ArrivedFromExecutionId), and
|
||||||
|
// re-running assembly then would needlessly rebuild an identical tree.
|
||||||
|
if (!ReferenceEquals(Nodes, _assembledFrom))
|
||||||
|
{
|
||||||
|
_assembledFrom = Nodes;
|
||||||
|
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
|
||||||
|
/// <see cref="Subtree"/> values. There is normally exactly one root (the
|
||||||
|
/// chain's topmost ancestor); the method returns a list to stay total if
|
||||||
|
/// the input ever contains disjoint fragments. A fully-cyclic feed has no
|
||||||
|
/// real root, so each remaining cyclic component is seeded with a fallback
|
||||||
|
/// root after the main pass — every execution in <paramref name="nodes"/>
|
||||||
|
/// is therefore placed in the forest exactly once.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
|
||||||
|
{
|
||||||
|
if (nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<Subtree>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-dupe defensively: the repository emits one node per execution, but
|
||||||
|
// a corrupt feed could repeat an id. First write wins.
|
||||||
|
var byId = new Dictionary<Guid, ExecutionTreeNode>();
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
byId.TryAdd(node.ExecutionId, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children grouped by parent id. A node whose parent is null or absent
|
||||||
|
// from the list (a purged/ghost parent) is a root.
|
||||||
|
var childrenByParent = new Dictionary<Guid, List<ExecutionTreeNode>>();
|
||||||
|
var roots = new List<ExecutionTreeNode>();
|
||||||
|
foreach (var node in byId.Values)
|
||||||
|
{
|
||||||
|
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(parentId, out var bucket))
|
||||||
|
{
|
||||||
|
bucket = new List<ExecutionTreeNode>();
|
||||||
|
childrenByParent[parentId] = bucket;
|
||||||
|
}
|
||||||
|
bucket.Add(node);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var visited = new HashSet<Guid>();
|
||||||
|
var forest = roots
|
||||||
|
.OrderBy(SortKey)
|
||||||
|
.Select(root => BuildSubtree(root, childrenByParent, visited))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Cycle guard: if the input is fully cyclic every node has a present
|
||||||
|
// parent, so a cyclic component contributes no entry to `roots`. Any
|
||||||
|
// execution still missing from `visited` after the pass above belongs
|
||||||
|
// to such a component (a corrupt feed may contain several independent
|
||||||
|
// cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of
|
||||||
|
// each remaining component as an extra root and assemble it, looping
|
||||||
|
// until every node has been placed — so every execution renders.
|
||||||
|
while (visited.Count < byId.Count)
|
||||||
|
{
|
||||||
|
var fallbackRoot = byId.Values
|
||||||
|
.Where(n => !visited.Contains(n.ExecutionId))
|
||||||
|
.OrderBy(SortKey)
|
||||||
|
.First();
|
||||||
|
forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
return forest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively builds one <see cref="Subtree"/>, tracking
|
||||||
|
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
|
||||||
|
/// recursion — a node already attached is never descended into again.
|
||||||
|
/// </summary>
|
||||||
|
private static Subtree BuildSubtree(
|
||||||
|
ExecutionTreeNode node,
|
||||||
|
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
|
||||||
|
HashSet<Guid> visited)
|
||||||
|
{
|
||||||
|
visited.Add(node.ExecutionId);
|
||||||
|
|
||||||
|
var children = new List<Subtree>();
|
||||||
|
if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren))
|
||||||
|
{
|
||||||
|
foreach (var child in directChildren.OrderBy(SortKey))
|
||||||
|
{
|
||||||
|
// Cycle / DAG guard: skip any execution already placed in the
|
||||||
|
// tree so each renders exactly once and recursion terminates.
|
||||||
|
if (visited.Contains(child.ExecutionId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
children.Add(BuildSubtree(child, childrenByParent, visited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Subtree(node, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable child ordering: earliest activity first; stub nodes (null
|
||||||
|
// timestamp) sort last; ExecutionId breaks ties so rendering is
|
||||||
|
// deterministic across requests.
|
||||||
|
private static (DateTime, Guid) SortKey(ExecutionTreeNode node)
|
||||||
|
=> (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId);
|
||||||
|
|
||||||
|
private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId);
|
||||||
|
|
||||||
|
private void ToggleExpand(Guid executionId)
|
||||||
|
{
|
||||||
|
if (!_collapsed.Remove(executionId))
|
||||||
|
{
|
||||||
|
_collapsed.Add(executionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
|
||||||
|
private static string AuditLogUrl(Guid executionId)
|
||||||
|
=> $"/audit/log?executionId={executionId}";
|
||||||
|
|
||||||
|
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
|
||||||
|
private static string ShortId(Guid value)
|
||||||
|
{
|
||||||
|
var n = value.ToString("N");
|
||||||
|
return n.Length >= 8 ? n[..8] : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the [first, last] occurrence span. Both null on a stub node
|
||||||
|
/// (handled by the caller); a single-row execution shows one timestamp.
|
||||||
|
/// </summary>
|
||||||
|
private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc)
|
||||||
|
{
|
||||||
|
if (firstUtc is null && lastUtc is null)
|
||||||
|
{
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = firstUtc ?? lastUtc!.Value;
|
||||||
|
var last = lastUtc ?? firstUtc!.Value;
|
||||||
|
var firstText = Iso(first);
|
||||||
|
if (first == last)
|
||||||
|
{
|
||||||
|
return firstText;
|
||||||
|
}
|
||||||
|
return $"{firstText} → {Iso(last)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit timestamps are UTC by system convention, so the value is formatted
|
||||||
|
// with a literal 'Z' suffix without re-tagging its DateTimeKind.
|
||||||
|
private static string Iso(DateTime utc)
|
||||||
|
=> utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
141
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
141
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
Clean, corporate, internal-tool aesthetic — consistent with the Audit Log
|
||||||
|
grid / drilldown drawer. Bootstrap CSS variables drive every colour so the
|
||||||
|
tree tracks the active theme. No component framework, no JS for layout. */
|
||||||
|
|
||||||
|
.execution-tree {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nested lists indent and carry a vertical guide rule that ties children to
|
||||||
|
their parent — the classic file-tree connector, kept subtle. */
|
||||||
|
.execution-tree--root {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree .execution-tree {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
border-left: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The node card: a flex row of [toggle][body].
|
||||||
|
user-select: none — the body is double-clickable (opens the node detail
|
||||||
|
modal), so suppress the text selection a double-click would otherwise
|
||||||
|
leave behind. */
|
||||||
|
.execution-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The execution the user drilled in from — a left accent rule + tinted
|
||||||
|
background so it stands out without shouting. */
|
||||||
|
.execution-tree-node--current {
|
||||||
|
border-color: var(--bs-primary-border-subtle);
|
||||||
|
background-color: var(--bs-primary-bg-subtle);
|
||||||
|
box-shadow: inset 3px 0 0 0 var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stub node — an execution with no audited actions. Muted + dashed border so
|
||||||
|
it reads as a placeholder rather than a real audited execution. */
|
||||||
|
.execution-tree-node--stub {
|
||||||
|
border-style: dashed;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand / collapse control. A small square that mirrors the table-light
|
||||||
|
header tone used elsewhere on the Audit pages. */
|
||||||
|
.execution-tree-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-top: 0.0625rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle:hover {
|
||||||
|
background-color: var(--bs-secondary-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle--leaf {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-toggle-glyph {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headline row: short id link, tags, row count. */
|
||||||
|
.execution-tree-headline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-tag {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-rowcount {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta row: source / channels / statuses / time span, pipe-separated visually
|
||||||
|
via spacing rather than literal separators. */
|
||||||
|
.execution-tree-meta {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-tree-meta-item .text-muted {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<NavMenu />
|
<NavMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="flex-grow-1 p-3" style="background-color: #f8f9fa;">
|
<main class="flex-grow-1 p-3">
|
||||||
@Body
|
@Body
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
@using System.Linq
|
||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@implements IDisposable
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<nav class="sidebar d-flex flex-column">
|
<nav class="sidebar d-flex flex-column">
|
||||||
<div class="brand">ScadaLink</div>
|
<div class="brand"><span class="mark">▮</span> ScadaBridge</div>
|
||||||
|
|
||||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
@@ -14,130 +20,152 @@
|
|||||||
@* Admin section — Admin role only *@
|
@* Admin section — Admin role only *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
<Authorized Context="adminContext">
|
<Authorized Context="adminContext">
|
||||||
<div role="presentation" class="nav-section-header">Admin</div>
|
<NavSection Title="Admin"
|
||||||
<li class="nav-item">
|
Expanded="@_expanded.Contains("admin")"
|
||||||
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
OnToggle="@(() => ToggleAsync("admin"))">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
|
</li>
|
||||||
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Design section — Design role *@
|
@* Design section — Design role *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||||
<Authorized Context="designContext">
|
<Authorized Context="designContext">
|
||||||
<div role="presentation" class="nav-section-header">Design</div>
|
<NavSection Title="Design"
|
||||||
<li class="nav-item">
|
Expanded="@_expanded.Contains("design")"
|
||||||
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
OnToggle="@(() => ToggleAsync("design"))">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||||
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
||||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||||
|
</li>
|
||||||
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Deployment section — Deployment role *@
|
@* Deployment section — Deployment role *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
<Authorized Context="deploymentContext">
|
<Authorized Context="deploymentContext">
|
||||||
<div role="presentation" class="nav-section-header">Deployment</div>
|
<NavSection Title="Deployment"
|
||||||
<li class="nav-item">
|
Expanded="@_expanded.Contains("deployment")"
|
||||||
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
OnToggle="@(() => ToggleAsync("deployment"))">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||||
|
</li>
|
||||||
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Notifications — mixed-role section; each item gated by its own policy.
|
@* Notifications — mixed-role section; each item gated by its own policy.
|
||||||
The header is ungated: every authenticated user holds at least one of
|
The section is ungated: every authenticated user holds at least one of
|
||||||
Admin/Design/Deployment, so it always has a visible child. *@
|
Admin/Design/Deployment, so it always has a visible child. *@
|
||||||
<div role="presentation" class="nav-section-header">Notifications</div>
|
<NavSection Title="Notifications"
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
Expanded="@_expanded.Contains("notifications")"
|
||||||
<Authorized Context="notifAdminContext">
|
OnToggle="@(() => ToggleAsync("notifications"))">
|
||||||
<li class="nav-item">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
<NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
|
<Authorized Context="notifAdminContext">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
</Authorized>
|
<NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink>
|
||||||
</AuthorizeView>
|
</li>
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
</Authorized>
|
||||||
<Authorized Context="notifDesignContext">
|
</AuthorizeView>
|
||||||
<li class="nav-item">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||||
<NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
|
<Authorized Context="notifDesignContext">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
</Authorized>
|
<NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink>
|
||||||
</AuthorizeView>
|
</li>
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
</Authorized>
|
||||||
<Authorized Context="notifDeploymentContext">
|
</AuthorizeView>
|
||||||
<li class="nav-item">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
<NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
|
<Authorized Context="notifDeploymentContext">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink>
|
||||||
<NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
</Authorized>
|
<NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink>
|
||||||
</AuthorizeView>
|
</li>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
@* Site Calls — Site Call Audit (#22). Deployment-role only,
|
||||||
matching the Notification Report page's gate; the section
|
matching the Notification Report page's gate; the whole
|
||||||
header sits inside the policy block so a non-Deployment
|
section sits inside the policy block so a non-Deployment
|
||||||
user does not see the heading. *@
|
user does not see the heading. *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
<Authorized Context="siteCallsContext">
|
<Authorized Context="siteCallsContext">
|
||||||
<div role="presentation" class="nav-section-header">Site Calls</div>
|
<NavSection Title="Site Calls"
|
||||||
<li class="nav-item">
|
Expanded="@_expanded.Contains("sitecalls")"
|
||||||
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
OnToggle="@(() => ToggleAsync("sitecalls"))">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/site-calls/report">Site Calls</NavLink>
|
||||||
|
</li>
|
||||||
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
@* Monitoring — Health Dashboard is all-roles; Event Logs and
|
||||||
Parked Messages are Deployment-role only (Component-CentralUI). *@
|
Parked Messages are Deployment-role only (Component-CentralUI).
|
||||||
<div role="presentation" class="nav-section-header">Monitoring</div>
|
The section is ungated because Health Dashboard is always
|
||||||
<li class="nav-item">
|
a visible child. *@
|
||||||
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
<NavSection Title="Monitoring"
|
||||||
</li>
|
Expanded="@_expanded.Contains("monitoring")"
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
OnToggle="@(() => ToggleAsync("monitoring"))">
|
||||||
<Authorized Context="monitoringContext">
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||||
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
</li>
|
||||||
</li>
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||||
<li class="nav-item">
|
<Authorized Context="monitoringContext">
|
||||||
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
<li class="nav-item">
|
||||||
</li>
|
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||||
</Authorized>
|
</li>
|
||||||
</AuthorizeView>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||||
|
</li>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</NavSection>
|
||||||
|
|
||||||
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
@* Audit — gated on the OperationalAudit policy (#23 M7-T15
|
||||||
/ Bundle G). Hosts the new Audit Log page (#23 M7) and
|
/ Bundle G). Hosts the Audit Log page (#23 M7) and the
|
||||||
the renamed Configuration Audit Log (IAuditService
|
Configuration Audit Log (IAuditService config-change
|
||||||
config-change viewer). Both items share the same gate,
|
viewer). The whole section sits inside the policy block:
|
||||||
so the section header sits inside the same policy block:
|
|
||||||
a non-audit user does not even see the heading.
|
a non-audit user does not even see the heading.
|
||||||
OperationalAudit is satisfied by the Admin, Audit, and
|
OperationalAudit is satisfied by the Admin, Audit, and
|
||||||
AuditReadOnly roles. *@
|
AuditReadOnly roles. *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
<AuthorizeView Policy="@AuthorizationPolicies.OperationalAudit">
|
||||||
<Authorized Context="auditContext">
|
<Authorized Context="auditContext">
|
||||||
<div role="presentation" class="nav-section-header">Audit</div>
|
<NavSection Title="Audit"
|
||||||
<li class="nav-item">
|
Expanded="@_expanded.Contains("audit")"
|
||||||
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
|
OnToggle="@(() => ToggleAsync("audit"))">
|
||||||
</li>
|
<li class="nav-item">
|
||||||
<li class="nav-item">
|
<NavLink class="nav-link" href="/audit/log">Audit Log</NavLink>
|
||||||
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
|
</li>
|
||||||
</li>
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/audit/configuration">Configuration Audit Log</NavLink>
|
||||||
|
</li>
|
||||||
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
@@ -147,18 +175,141 @@
|
|||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<div class="border-top border-secondary px-3 py-2">
|
<div class="border-top px-3 py-2">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
@* CentralUI-024: claim type resolved via JwtTokenService. *@
|
||||||
<span class="text-light small">@context.User.GetDisplayName()</span>
|
<span class="text-body-secondary small">@context.User.GetDisplayName()</span>
|
||||||
<form method="post" action="/auth/logout" data-enhance="false">
|
<form method="post" action="/auth/logout" data-enhance="false">
|
||||||
@* CentralUI-017: logout is a state-changing POST and is
|
@* CentralUI-017: logout is a state-changing POST and is
|
||||||
CSRF-protected — the antiforgery token is required. *@
|
CSRF-protected — the antiforgery token is required. *@
|
||||||
<AntiforgeryToken />
|
<AntiforgeryToken />
|
||||||
<button type="submit" class="btn btn-outline-light btn-sm py-0 px-2">Sign Out</button>
|
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// Expanded-section state persists in the "scadabridge_nav" cookie, written
|
||||||
|
// by navState.set / read by navState.get (wwwroot/js/nav-state.js) — a
|
||||||
|
// comma-separated list of section ids.
|
||||||
|
|
||||||
|
// Every collapsible section id. Also the allow-list for parsing the cookie.
|
||||||
|
private static readonly string[] SectionIds =
|
||||||
|
{ "admin", "design", "deployment", "notifications", "sitecalls", "monitoring", "audit" };
|
||||||
|
|
||||||
|
// The currently-expanded sections. Populated from the cookie on first
|
||||||
|
// render; mutated by ToggleAsync and by navigating into a section.
|
||||||
|
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate from the cookie. Until this completes the sidebar paints
|
||||||
|
// collapsed (the "collapsed by default" state) — matching how TreeView
|
||||||
|
// hydrates its expand state in OnAfterRenderAsync(firstRender).
|
||||||
|
string saved;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var id in saved.Split(
|
||||||
|
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(SectionIds, id) >= 0)
|
||||||
|
{
|
||||||
|
_expanded.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The section of the page we loaded on is always expanded.
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
{
|
||||||
|
await PersistAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// Navigating into a collapsed section expands it (and remembers it).
|
||||||
|
if (EnsureCurrentSectionExpanded())
|
||||||
|
{
|
||||||
|
_ = PersistAsync();
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleAsync(string id)
|
||||||
|
{
|
||||||
|
if (!_expanded.Remove(id))
|
||||||
|
{
|
||||||
|
_expanded.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PersistAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds the current page's section to _expanded; returns true if it changed.
|
||||||
|
private bool EnsureCurrentSectionExpanded()
|
||||||
|
{
|
||||||
|
var section = CurrentSection();
|
||||||
|
return section is not null && _expanded.Add(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps the current URL's first path segment to a section id, or null for
|
||||||
|
// sectionless pages (Dashboard, Login).
|
||||||
|
private string? CurrentSection()
|
||||||
|
{
|
||||||
|
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||||
|
var firstSegment = relative.Split('?', '#')[0]
|
||||||
|
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return firstSegment switch
|
||||||
|
{
|
||||||
|
"admin" => "admin",
|
||||||
|
"design" => "design",
|
||||||
|
"deployment" => "deployment",
|
||||||
|
"notifications" => "notifications",
|
||||||
|
"site-calls" => "sitecalls",
|
||||||
|
"monitoring" => "monitoring",
|
||||||
|
"audit" => "audit",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
// The circuit is gone — nothing to persist to.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
35
src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
Normal file
35
src/ScadaLink.CentralUI/Components/Layout/NavSection.razor
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@* A collapsible sidebar nav section: an uppercase-eyebrow header button that
|
||||||
|
toggles the visibility of its child nav items. The header <li> and the item
|
||||||
|
<li>s (ChildContent) render as siblings inside NavMenu's <ul>. *@
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-section-toggle"
|
||||||
|
@onclick="OnToggle"
|
||||||
|
aria-expanded="@(Expanded ? "true" : "false")">
|
||||||
|
<i class="bi @(Expanded ? "bi-chevron-down" : "bi-chevron-right")" aria-hidden="true"></i>
|
||||||
|
<span>@Title</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
@if (Expanded)
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Section label shown in the header (e.g. "Deployment").</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool Expanded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the header button is clicked.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnToggle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public RenderFragment? ChildContent { get; set; }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Types.Audit;
|
using ScadaLink.Commons.Types.Audit;
|
||||||
@@ -22,14 +23,29 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
|||||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
||||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
||||||
|
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
|
||||||
|
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
|
||||||
|
/// "View parent execution" drill-in. When any param is present we allocate a
|
||||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||||
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
||||||
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
||||||
/// are silently dropped — the page still renders, just without that constraint.
|
/// are silently dropped — the page still renders, just without that constraint.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Query-string filters are re-applied on every <see cref="NavigationManager.LocationChanged"/>,
|
||||||
|
/// not just on init. The drilldown drawer's "View this/parent execution" actions
|
||||||
|
/// navigate to <c>/audit/log?executionId=…</c> while the user is ALREADY on this
|
||||||
|
/// routed page — Blazor treats that as a same-component navigation, so
|
||||||
|
/// <see cref="OnInitialized"/> does not re-run. Without the
|
||||||
|
/// <see cref="NavigationManager.LocationChanged"/> subscription the URL would
|
||||||
|
/// change but <see cref="_currentFilter"/> would stay stale and the grid would
|
||||||
|
/// never reload to the new drill-in. The subscription is disposed via
|
||||||
|
/// <see cref="IDisposable"/>.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class AuditLogPage
|
public partial class AuditLogPage : IDisposable
|
||||||
{
|
{
|
||||||
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
@@ -41,6 +57,33 @@ public partial class AuditLogPage
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ApplyQueryStringFilters();
|
ApplyQueryStringFilters();
|
||||||
|
Navigation.LocationChanged += HandleLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-applies the query-string drill-in filters when the URL changes while
|
||||||
|
/// this page stays routed (e.g. the drawer's "View parent execution" action
|
||||||
|
/// navigates to <c>/audit/log?executionId=…</c>). Reassigning
|
||||||
|
/// <see cref="_currentFilter"/> to a fresh instance is what kicks the results
|
||||||
|
/// grid into reloading; we also close the drawer so the operator sees the
|
||||||
|
/// newly filtered grid. The body is marshalled through
|
||||||
|
/// <see cref="ComponentBase.InvokeAsync(Action)"/> because
|
||||||
|
/// <see cref="NavigationManager.LocationChanged"/> can fire off the renderer's
|
||||||
|
/// synchronization context.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
ApplyQueryStringFilters();
|
||||||
|
_drawerOpen = false;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= HandleLocationChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyQueryStringFilters()
|
private void ApplyQueryStringFilters()
|
||||||
@@ -48,6 +91,10 @@ public partial class AuditLogPage
|
|||||||
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
var query = QueryHelpers.ParseQuery(uri.Query);
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
|
||||||
|
// A paramless navigation (e.g. clicking the "Audit Log" nav link while
|
||||||
|
// already here) intentionally preserves the last applied filter rather
|
||||||
|
// than clearing the grid: this method is a drill-in mechanism and every
|
||||||
|
// drill-in carries query params. The operator clears via the filter bar.
|
||||||
if (query.Count == 0)
|
if (query.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -60,6 +107,25 @@ public partial class AuditLogPage
|
|||||||
correlationId = parsedCorr;
|
correlationId = parsedCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ?executionId= is the "View this execution" drill-in target — the
|
||||||
|
// universal per-run correlation value. Lax-parsed like ?correlationId=:
|
||||||
|
// an unparseable value is silently dropped (no constraint).
|
||||||
|
Guid? executionId = null;
|
||||||
|
if (query.TryGetValue("executionId", out var execValues)
|
||||||
|
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||||
|
{
|
||||||
|
executionId = parsedExec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?parentExecutionId= constrains to runs spawned by a given execution.
|
||||||
|
// Lax-parsed like ?executionId=: an unparseable value is silently dropped.
|
||||||
|
Guid? parentExecutionId = null;
|
||||||
|
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||||
|
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||||
|
{
|
||||||
|
parentExecutionId = parsedParentExec;
|
||||||
|
}
|
||||||
|
|
||||||
string? target = null;
|
string? target = null;
|
||||||
if (query.TryGetValue("target", out var targetValues))
|
if (query.TryGetValue("target", out var targetValues))
|
||||||
{
|
{
|
||||||
@@ -117,7 +183,8 @@ public partial class AuditLogPage
|
|||||||
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
|
||||||
// because the filter contract has no instance column — the user still needs
|
// because the filter contract has no instance column — the user still needs
|
||||||
// to refine + Apply for those.
|
// to refine + Apply for those.
|
||||||
if (correlationId is null && target is null && actor is null
|
if (correlationId is null && executionId is null && parentExecutionId is null
|
||||||
|
&& target is null && actor is null
|
||||||
&& sites is null && channels is null && kinds is null && statuses is null)
|
&& sites is null && channels is null && kinds is null && statuses is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -130,7 +197,9 @@ public partial class AuditLogPage
|
|||||||
SourceSiteIds: sites,
|
SourceSiteIds: sites,
|
||||||
Target: target,
|
Target: target,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
CorrelationId: correlationId);
|
CorrelationId: correlationId,
|
||||||
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -236,6 +305,14 @@ public partial class AuditLogPage
|
|||||||
{
|
{
|
||||||
parts.Add(new("correlationId", corr.ToString()));
|
parts.Add(new("correlationId", corr.ToString()));
|
||||||
}
|
}
|
||||||
|
if (filter.ExecutionId is { } exec)
|
||||||
|
{
|
||||||
|
parts.Add(new("executionId", exec.ToString()));
|
||||||
|
}
|
||||||
|
if (filter.ParentExecutionId is { } parentExec)
|
||||||
|
{
|
||||||
|
parts.Add(new("parentExecutionId", parentExec.ToString()));
|
||||||
|
}
|
||||||
if (filter.FromUtc is { } from)
|
if (filter.FromUtc is { } from)
|
||||||
{
|
{
|
||||||
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
|
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@page "/audit/execution-tree"
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||||
|
@using ScadaLink.CentralUI.Components.Audit
|
||||||
|
@using ScadaLink.CentralUI.Services
|
||||||
|
@using ScadaLink.Commons.Types.Audit
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@inject IAuditLogQueryService AuditLogQueryService
|
||||||
|
|
||||||
|
<PageTitle>Execution Chain</PageTitle>
|
||||||
|
|
||||||
|
@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10).
|
||||||
|
A drill-in target reached from the Audit Log drawer's "View execution chain"
|
||||||
|
action: /audit/execution-tree?executionId={guid}. The page parses the id,
|
||||||
|
asks the query service for the whole chain (flat ExecutionTreeNode list), and
|
||||||
|
hands it to the recursive ExecutionTree component. There is deliberately NO
|
||||||
|
nav-menu entry — this page is only meaningful in the context of a specific
|
||||||
|
execution, so it is reachable only via drill-in (the Audit nav group keeps
|
||||||
|
just the Audit Log + Configuration Audit Log pages). *@
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<h1 class="h4 mb-1">Execution Chain</h1>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
The full chain of script / inbound-request executions linked by
|
||||||
|
<span class="font-monospace">ParentExecutionId</span>, rooted at the
|
||||||
|
topmost ancestor. Select an execution to open the Audit Log filtered to
|
||||||
|
its rows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (_executionId is null)
|
||||||
|
{
|
||||||
|
@* No (or unparseable) ?executionId= — render guidance rather than an
|
||||||
|
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
|
||||||
|
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
|
||||||
|
No execution selected. Open this view from an audit row's
|
||||||
|
<strong>View execution chain</strong> action.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_loading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
|
||||||
|
}
|
||||||
|
else if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
|
||||||
|
}
|
||||||
|
else if (_nodes is { Count: > 0 })
|
||||||
|
{
|
||||||
|
<div class="mb-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-test="execution-tree-back-to-log"
|
||||||
|
href="@($"/audit/log?executionId={_executionId}")">
|
||||||
|
View this execution in the Audit Log
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary small" data-test="execution-tree-empty">
|
||||||
|
No execution chain found for this id.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
|
||||||
|
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
|
||||||
|
/// Log drilldown drawer's "View execution chain" action with
|
||||||
|
/// <c>?executionId={guid}</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// On initialization the page parses <c>?executionId=</c> (lax-parsed, matching
|
||||||
|
/// the Audit Log page's drill-in contract — an absent or unparseable value
|
||||||
|
/// leaves the page in a guidance state and issues NO service call), then asks
|
||||||
|
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
|
||||||
|
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
|
||||||
|
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
|
||||||
|
/// the tree.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
|
||||||
|
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
|
||||||
|
/// directly, so the page can be unit-tested with a substituted service.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public partial class ExecutionTreePage
|
||||||
|
{
|
||||||
|
[Inject] private NavigationManager Navigation { get; set; } = null!;
|
||||||
|
|
||||||
|
// The parsed ?executionId= value, or null when absent / unparseable.
|
||||||
|
private Guid? _executionId;
|
||||||
|
|
||||||
|
// The flat chain returned by the query service; null until the load
|
||||||
|
// completes (or when no id was supplied).
|
||||||
|
private IReadOnlyList<ExecutionTreeNode>? _nodes;
|
||||||
|
|
||||||
|
private bool _loading;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
// Execution-Tree Node Detail Modal feature (Task 4) — state backing the
|
||||||
|
// <ExecutionDetailModal>. A double-click on a tree node sets
|
||||||
|
// _modalExecutionId + flips _modalOpen true; the modal loads that
|
||||||
|
// execution's audit rows on the closed → open transition. _modalOpen is the
|
||||||
|
// visibility gate — _modalExecutionId is left intact across a close (it is
|
||||||
|
// harmless while the modal is hidden and avoids a flicker if reopened).
|
||||||
|
private Guid? _modalExecutionId;
|
||||||
|
private bool _modalOpen;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_executionId = ParseExecutionId();
|
||||||
|
if (_executionId is null)
|
||||||
|
{
|
||||||
|
// No id — render guidance, do not touch the service.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadChainAsync(_executionId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
|
||||||
|
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
|
||||||
|
/// of an error, consistent with the Audit Log page's drill-in handling.
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseExecutionId()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
var query = QueryHelpers.ParseQuery(uri.Query);
|
||||||
|
if (query.TryGetValue("executionId", out var values)
|
||||||
|
&& Guid.TryParse(values.ToString(), out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadChainAsync(Guid executionId)
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A transient DB outage degrades this page to an error banner
|
||||||
|
// rather than killing the circuit — the same defensive posture the
|
||||||
|
// Audit Log grid takes around its query.
|
||||||
|
_error = $"Could not load the execution chain: {ex.Message}";
|
||||||
|
_nodes = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_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;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Welcome to ScadaLink</h4>
|
<h4 class="mb-0">Welcome to ScadaBridge</h4>
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<span class="text-muted small">
|
<span class="text-muted small">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
<p class="text-muted">Central management console for the ScadaBridge SCADA system.</p>
|
||||||
|
|
||||||
@* KPI row *@
|
@* KPI row *@
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<div class="card shadow-sm" style="max-width: 400px; width: 100%;">
|
<div class="card shadow-sm" style="max-width: 400px; width: 100%;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h4 class="card-title mb-4 text-center">ScadaLink</h4>
|
<h4 class="card-title mb-4 text-center">ScadaBridge</h4>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@typeparam TValue
|
||||||
|
@*
|
||||||
|
Compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||||
|
summarises the current selection over a checkbox menu. Replaces a wrapped
|
||||||
|
block of chip buttons with a single control of one row's height.
|
||||||
|
*@
|
||||||
|
<div class="dropdown msd" data-test="@DataTest">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary dropdown-toggle msd-toggle text-start"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
data-bs-auto-close="outside"
|
||||||
|
aria-expanded="false"
|
||||||
|
disabled="@(Items.Count == 0)"
|
||||||
|
data-test="@($"{DataTest}-toggle")">
|
||||||
|
<span class="msd-summary">@Summary()</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu msd-menu">
|
||||||
|
@if (Items.Count == 0)
|
||||||
|
{
|
||||||
|
<li><span class="dropdown-item-text text-muted small">@EmptyText</span></li>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var item in Items)
|
||||||
|
{
|
||||||
|
var isSelected = Selected.Contains(item);
|
||||||
|
<li>
|
||||||
|
<label class="dropdown-item msd-item">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="form-check-input msd-check"
|
||||||
|
checked="@isSelected"
|
||||||
|
@onchange="() => Toggle(item)"
|
||||||
|
data-test="@($"{DataTest}-opt-{item}")" />
|
||||||
|
<span>@Display(item)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A compact multi-select control: a Bootstrap dropdown whose toggle button
|
||||||
|
/// summarises the current selection ("All" when empty, the single item's label
|
||||||
|
/// when one is picked, or "N selected" otherwise) over a checkbox menu.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// It exists to keep multi-value filter controls one row tall instead of a
|
||||||
|
/// wrapped block of chip buttons. The component mutates the caller-owned
|
||||||
|
/// <see cref="Selected"/> collection in place and raises
|
||||||
|
/// <see cref="SelectionChanged"/> after every toggle so the parent can react
|
||||||
|
/// (re-render, prune dependent selections, …).
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Requires the Bootstrap JS bundle (loaded in <c>App.razor</c>) for the
|
||||||
|
/// dropdown toggle; <c>data-bs-auto-close="outside"</c> keeps the menu open
|
||||||
|
/// while the operator ticks several boxes.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TValue">The option value type (an enum or string).</typeparam>
|
||||||
|
public partial class MultiSelectDropdown<TValue> where TValue : notnull
|
||||||
|
{
|
||||||
|
/// <summary>The options shown in the menu, in display order.</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<TValue> Items { get; set; } = Array.Empty<TValue>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The caller-owned selection set. Mutated in place by <see cref="Toggle"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public ICollection<TValue> Selected { get; set; } = default!;
|
||||||
|
|
||||||
|
/// <summary>Maps an option to its display label. Defaults to <c>ToString()</c>.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public Func<TValue, string> Display { get; set; } = static v => v.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Raised after each toggle, once <see cref="Selected"/> has been updated.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback SelectionChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Summary text shown on the toggle button when nothing is selected.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string AllLabel { get; set; } = "All";
|
||||||
|
|
||||||
|
/// <summary>Text shown in the menu when there are no options.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string EmptyText { get; set; } = "None available";
|
||||||
|
|
||||||
|
/// <summary><c>data-test</c> root for this control, its toggle and its options.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string DataTest { get; set; } = "multi-select";
|
||||||
|
|
||||||
|
private async Task Toggle(TValue item)
|
||||||
|
{
|
||||||
|
// ICollection.Remove returns false when the item was absent — that is the
|
||||||
|
// "not currently selected" case, so add it. This is a plain toggle.
|
||||||
|
if (!Selected.Remove(item))
|
||||||
|
{
|
||||||
|
Selected.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SelectionChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Summary()
|
||||||
|
{
|
||||||
|
var count = Selected.Count;
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return AllLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 1)
|
||||||
|
{
|
||||||
|
// Prefer the single selection's label over a bare "1 selected".
|
||||||
|
foreach (var item in Items)
|
||||||
|
{
|
||||||
|
if (Selected.Contains(item))
|
||||||
|
{
|
||||||
|
return Display(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one selected value is not in the current Items list (e.g. a Kind
|
||||||
|
// narrowed out by a Channel change before the parent pruned it).
|
||||||
|
return "1 selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{count} selected";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Compact multi-select dropdown. Tuned to sit inline with form-select-sm /
|
||||||
|
form-control-sm controls in a filter row. */
|
||||||
|
|
||||||
|
.msd-toggle {
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 15rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep a long option list from running off-screen — scroll within the menu. */
|
||||||
|
.msd-menu {
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The whole row is a <label> so a click anywhere toggles the checkbox; the
|
||||||
|
menu stays open thanks to data-bs-auto-close="outside". */
|
||||||
|
.msd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neutralise the default form-check-input top margin so the box lines up with
|
||||||
|
the option text inside the dropdown-item. */
|
||||||
|
.msd-check {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
<div class="d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
|
<div class="card shadow-sm" style="max-width: 480px; width: 100%;">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h4 class="card-title mb-3 text-center">ScadaLink</h4>
|
<h4 class="card-title mb-3 text-center">ScadaBridge</h4>
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<h5 class="alert-heading">Not Authorized</h5>
|
<h5 class="alert-heading">Not Authorized</h5>
|
||||||
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
||||||
|
|||||||
@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
|
|
||||||
return repoSnapshot with { BacklogTotal = backlog };
|
return repoSnapshot with { BacklogTotal = backlog };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Test-seam ctor: use the injected repository directly.
|
||||||
|
if (_injectedRepository is not null)
|
||||||
|
{
|
||||||
|
return await _injectedRepository.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production: a fresh scope (and thus a fresh DbContext) per call — the
|
||||||
|
// same context-isolation contract QueryAsync upholds, so the tree
|
||||||
|
// page's auto-load never shares the circuit-scoped context.
|
||||||
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
|
|||||||
/// dashboard.
|
/// dashboard.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log ParentExecutionId feature (Task 10) — returns the full
|
||||||
|
/// execution chain containing <paramref name="executionId"/> as a flat list
|
||||||
|
/// of <see cref="ExecutionTreeNode"/>, delegating to
|
||||||
|
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||||
|
/// The execution-chain tree view (<c>/audit/execution-tree</c>) assembles the
|
||||||
|
/// returned flat list into a tree by joining
|
||||||
|
/// <see cref="ExecutionTreeNode.ParentExecutionId"/> to a parent node's
|
||||||
|
/// <see cref="ExecutionTreeNode.ExecutionId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A pure pass-through, mirroring <see cref="QueryAsync"/> — the production
|
||||||
|
/// implementation opens its own DI scope per call so the tree page's
|
||||||
|
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>.
|
||||||
|
/// </remarks>
|
||||||
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: var(--bs-dark);
|
background: var(--card);
|
||||||
|
border-right: 1px solid var(--rule-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the sidebar pinned to the viewport on lg+ so it stays visible even
|
/* Keep the sidebar pinned to the viewport on lg+ so it stays visible even
|
||||||
@@ -22,40 +23,66 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
color: var(--bs-gray-500);
|
color: var(--ink-soft);
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-link:hover {
|
.sidebar .nav-link:hover {
|
||||||
color: var(--bs-white);
|
color: var(--ink);
|
||||||
background-color: var(--bs-gray-700);
|
background-color: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-link.active {
|
.sidebar .nav-link.active {
|
||||||
color: var(--bs-white);
|
color: var(--accent-deep);
|
||||||
background-color: var(--bs-primary);
|
background-color: var(--paper);
|
||||||
|
font-weight: 600;
|
||||||
/* Left accent so active state isn't carried by color alone. */
|
/* Left accent so active state isn't carried by color alone. */
|
||||||
border-left: 3px solid var(--bs-primary);
|
border-left: 3px solid var(--accent);
|
||||||
padding-left: calc(1rem - 3px);
|
padding-left: calc(1rem - 3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .nav-section-header {
|
/* Collapsible section header — a full-width button styled as an uppercase
|
||||||
color: var(--bs-gray-600);
|
eyebrow with a leading expand/collapse chevron. */
|
||||||
font-size: 0.75rem;
|
.sidebar .nav-section-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.07em;
|
||||||
padding: 0.75rem 1rem 0.25rem;
|
padding: 0.75rem 1rem 0.25rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-section-toggle:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-section-toggle .bi {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar .brand {
|
.sidebar .brand {
|
||||||
color: var(--bs-white);
|
color: var(--ink);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--bs-gray-700);
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The single accent glyph in the brand mark. */
|
||||||
|
.sidebar .brand .mark {
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When the sidebar is collapsed under <lg viewports the Bootstrap collapse
|
/* When the sidebar is collapsed under <lg viewports the Bootstrap collapse
|
||||||
|
|||||||
379
src/ScadaLink.CentralUI/wwwroot/css/theme.css
Executable file
379
src/ScadaLink.CentralUI/wwwroot/css/theme.css
Executable file
@@ -0,0 +1,379 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Technical-Light design system — portable theme layer
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
A refined technical-light aesthetic: warm-neutral paper, hairline rules,
|
||||||
|
IBM Plex type, monospace tabular numerics, status carried by colour. Built
|
||||||
|
to layer over Bootstrap 5 via --bs-* overrides, but every rule below works
|
||||||
|
standalone — Bootstrap is optional.
|
||||||
|
|
||||||
|
HOW TO ADOPT
|
||||||
|
1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the
|
||||||
|
@font-face url() paths below to wherever you serve them.
|
||||||
|
2. Include this file once, globally. Add view-specific rules in a separate
|
||||||
|
stylesheet — never edit the token block per-view.
|
||||||
|
3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.*
|
||||||
|
helpers; do not hand-pick hex values in feature CSS.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ───────────────────
|
||||||
|
Adjust these url()s to your asset route. If you cannot vendor the fonts the
|
||||||
|
--sans / --mono fallback stacks below degrade gracefully to system fonts. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Sans';
|
||||||
|
font-style: normal; font-weight: 400; font-display: swap;
|
||||||
|
src: url('../fonts/ibm-plex-sans-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Sans';
|
||||||
|
font-style: normal; font-weight: 600; font-display: swap;
|
||||||
|
src: url('../fonts/ibm-plex-sans-600.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Mono';
|
||||||
|
font-style: normal; font-weight: 500; font-display: swap;
|
||||||
|
src: url('../fonts/ibm-plex-mono-500.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Design tokens ───────────────────────────────────────────────────────────
|
||||||
|
The single source of truth. Re-theme by editing only this block. */
|
||||||
|
:root {
|
||||||
|
/* Surfaces & ink */
|
||||||
|
--paper: #f4f4f1; /* page background — warm off-white, never pure */
|
||||||
|
--card: #ffffff; /* raised surfaces: cards, bars, table heads */
|
||||||
|
--ink: #1b1d21; /* primary text */
|
||||||
|
--ink-soft: #5a6066; /* secondary text, labels */
|
||||||
|
--ink-faint: #8b9097; /* tertiary text, captions, units */
|
||||||
|
--rule: #e4e4df; /* hairline borders / row dividers */
|
||||||
|
--rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--accent: #2f5fd0; /* links, sort arrows, primary actions */
|
||||||
|
--accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */
|
||||||
|
|
||||||
|
/* Status — foreground */
|
||||||
|
--ok: #2f9e44;
|
||||||
|
--warn: #e8920c;
|
||||||
|
--bad: #e03131;
|
||||||
|
--idle: #868e96;
|
||||||
|
|
||||||
|
/* Status — tinted backgrounds (pair with the matching foreground) */
|
||||||
|
--ok-bg: #e9f6ec;
|
||||||
|
--warn-bg: #fdf1dd;
|
||||||
|
--bad-bg: #fceaea;
|
||||||
|
--idle-bg: #eef0f2;
|
||||||
|
|
||||||
|
/* Type stacks — Plex first, graceful system fallback */
|
||||||
|
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
|
||||||
|
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
/* Bootstrap 5 overrides — harmless if Bootstrap is absent */
|
||||||
|
--bs-body-bg: var(--paper);
|
||||||
|
--bs-body-color: var(--ink);
|
||||||
|
--bs-body-font-family: var(--sans);
|
||||||
|
--bs-body-font-size: 0.9rem;
|
||||||
|
--bs-primary: var(--accent);
|
||||||
|
--bs-border-color: var(--rule);
|
||||||
|
--bs-emphasis-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ────────────────────────────────────────────────────────────────────
|
||||||
|
The faint top-right radial is the one deliberate flourish — a soft sheen,
|
||||||
|
not a gradient wash. Keep it subtle. */
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
|
||||||
|
var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */
|
||||||
|
.numeric,
|
||||||
|
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── App chrome: top bar ─────────────────────────────────────────────────────
|
||||||
|
One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta
|
||||||
|
text and any status pill pushed hard right. */
|
||||||
|
.app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
.app-bar .brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */
|
||||||
|
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
|
||||||
|
.app-bar .spacer { flex: 1; } /* pushes meta/pill right */
|
||||||
|
.app-bar .meta {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Connection / liveness pill ──────────────────────────────────────────────
|
||||||
|
A rounded pill with a dot, driven entirely by data-state. Use for any
|
||||||
|
live-link health indicator (websocket, SSE, polling). */
|
||||||
|
.conn-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.conn-pill .dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--idle);
|
||||||
|
}
|
||||||
|
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
|
||||||
|
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
|
||||||
|
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
|
||||||
|
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||||
|
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
|
||||||
|
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||||
|
|
||||||
|
/* ── Status text helpers ─────────────────────────────────────────────────────
|
||||||
|
Recolour a value in place — counts, ratios, error totals. */
|
||||||
|
.s-ok { color: var(--ok); }
|
||||||
|
.s-warn { color: var(--warn); }
|
||||||
|
.s-bad { color: var(--bad); }
|
||||||
|
.s-idle { color: var(--idle); }
|
||||||
|
|
||||||
|
/* ── State chip ──────────────────────────────────────────────────────────────
|
||||||
|
Compact rectangular badge for an enumerated state (bound/recovering/…).
|
||||||
|
Squarer than the pill; use the pill for liveness, the chip for state. */
|
||||||
|
.chip {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
|
||||||
|
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
|
||||||
|
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
|
||||||
|
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
|
||||||
|
|
||||||
|
/* ── Panel — the base raised surface ─────────────────────────────────────────
|
||||||
|
A white card with a hairline border and 8px radius. .panel-head is the
|
||||||
|
uppercase eyebrow label that sits on top. */
|
||||||
|
.panel {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Page wrapper ────────────────────────────────────────────────────────────
|
||||||
|
Centred, capped width, even gutter. */
|
||||||
|
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── Reveal-on-paint ─────────────────────────────────────────────────────────
|
||||||
|
Add .rise to top-level sections; stagger with inline animation-delay
|
||||||
|
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
.rise { animation: rise 0.4s ease both; }
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
COMPONENT LIBRARY
|
||||||
|
Generic, reusable pieces. View-specific layout belongs in a separate sheet.
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── KPI / aggregate cards ───────────────────────────────────────────────────
|
||||||
|
A responsive strip of headline numbers. .agg-card.alert / .caution tint the
|
||||||
|
whole card when a watched metric goes non-zero. */
|
||||||
|
.agg-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||||
|
@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
.agg-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
}
|
||||||
|
.agg-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.agg-value {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.1;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
|
||||||
|
.agg-card.alert .agg-value { color: var(--bad); }
|
||||||
|
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
|
||||||
|
.agg-card.caution .agg-value { color: #b56a00; }
|
||||||
|
|
||||||
|
/* ── Metric card + key/value rows ────────────────────────────────────────────
|
||||||
|
A .panel-head over a stack of .kv rows: label left, monospace value right.
|
||||||
|
Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.metric-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.metric-card .panel-head { margin: 0; }
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.32rem 0.9rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.kv:nth-child(even) { background: #fbfbf9; }
|
||||||
|
.kv .k { color: var(--ink-soft); }
|
||||||
|
.kv .v {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.kv .v.warn { color: var(--warn); }
|
||||||
|
.kv .v.bad { color: var(--bad); }
|
||||||
|
.kv .v.ok { color: var(--ok); }
|
||||||
|
|
||||||
|
/* ── Toolbar ─────────────────────────────────────────────────────────────────
|
||||||
|
Filter/search row that sits inside a .panel above a table. */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
.tb-search { max-width: 280px; }
|
||||||
|
.tb-state { max-width: 150px; }
|
||||||
|
.tb-check {
|
||||||
|
display: flex; align-items: center; gap: 0.35rem;
|
||||||
|
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
|
||||||
|
|
||||||
|
/* ── Data table ──────────────────────────────────────────────────────────────
|
||||||
|
Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric
|
||||||
|
columns get .num (right-aligned, monospace). Rows are clickable by default —
|
||||||
|
drop the cursor/hover rules if yours are not. */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: #fbfbf9;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.data-table th.num,
|
||||||
|
.data-table td.num { text-align: right; font-family: var(--mono); }
|
||||||
|
|
||||||
|
.data-table th.sortable { cursor: pointer; user-select: none; }
|
||||||
|
.data-table th.sortable:hover { color: var(--ink); }
|
||||||
|
.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
|
||||||
|
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
|
||||||
|
|
||||||
|
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
|
||||||
|
.data-table tbody tr:hover { background: #f3f6fd; }
|
||||||
|
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
text-align: center !important;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
padding: 1.6rem !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Direction / category tag ────────────────────────────────────────────────
|
||||||
|
Tiny inline tag for a per-row category (e.g. read vs write). */
|
||||||
|
.dir-tag {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
|
||||||
|
.dir-write { color: #8a5a00; background: var(--warn-bg); }
|
||||||
|
|
||||||
|
/* ── Inline notice ───────────────────────────────────────────────────────────
|
||||||
|
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
|
||||||
|
.notice {
|
||||||
|
padding: 0.85rem 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #b56a00;
|
||||||
|
background: var(--warn-bg);
|
||||||
|
border-color: #efd6a6;
|
||||||
|
}
|
||||||
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-mono-500.woff2
Executable file
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-mono-500.woff2
Executable file
Binary file not shown.
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-sans-400.woff2
Executable file
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-sans-400.woff2
Executable file
Binary file not shown.
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-sans-600.woff2
Executable file
BIN
src/ScadaLink.CentralUI/wwwroot/fonts/ibm-plex-sans-600.woff2
Executable file
Binary file not shown.
18
src/ScadaLink.CentralUI/wwwroot/js/nav-state.js
Normal file
18
src/ScadaLink.CentralUI/wwwroot/js/nav-state.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Sidebar nav collapse state — persisted in the `scadabridge_nav` cookie so it
|
||||||
|
// survives full page reloads and reconnects. Invoked from NavMenu.razor via
|
||||||
|
// JS interop (window.navState.get / .set), mirroring window.treeviewStorage.
|
||||||
|
window.navState = {
|
||||||
|
// Returns the raw cookie value (comma-separated expanded section ids), or
|
||||||
|
// an empty string when the cookie is absent.
|
||||||
|
get: function () {
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)scadabridge_nav=([^;]*)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
},
|
||||||
|
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
|
||||||
|
// (JS must write it) and not sensitive.
|
||||||
|
set: function (value) {
|
||||||
|
const oneYearSeconds = 60 * 60 * 24 * 365;
|
||||||
|
document.cookie = "scadabridge_nav=" + encodeURIComponent(value) +
|
||||||
|
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -26,6 +26,20 @@ public sealed record AuditEvent
|
|||||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||||
public Guid? CorrelationId { get; init; }
|
public Guid? CorrelationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id of the originating script execution / inbound request — the universal
|
||||||
|
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
||||||
|
/// is the per-operation lifecycle id).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ExecutionId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
|
||||||
|
/// run was spawned by another; null for top-level runs. Lets a spawned
|
||||||
|
/// execution point back at its spawner for cross-run correlation.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ParentExecutionId { get; init; }
|
||||||
|
|
||||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||||
public string? SourceSiteId { get; init; }
|
public string? SourceSiteId { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ public class Notification
|
|||||||
public string SourceSiteId { get; set; }
|
public string SourceSiteId { get; set; }
|
||||||
public string? SourceInstanceId { get; set; }
|
public string? SourceInstanceId { get; set; }
|
||||||
public string? SourceScript { get; set; }
|
public string? SourceScript { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
|
||||||
|
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
|
||||||
|
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
|
||||||
|
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
|
||||||
|
/// submitted before the column existed, or raised outside a script-execution context.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? OriginExecutionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
|
||||||
|
/// Carried from the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/>
|
||||||
|
/// so the central dispatcher can stamp the same parent id onto its <c>NotifyDeliver</c>
|
||||||
|
/// audit rows, correlating them with the site-emitted <c>NotifySend</c> row. Null for
|
||||||
|
/// non-routed runs, or for notifications submitted before the column existed.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? OriginParentExecutionId { get; set; }
|
||||||
public DateTimeOffset SiteEnqueuedAt { get; set; }
|
public DateTimeOffset SiteEnqueuedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>Central ingest time.</summary>
|
/// <summary>Central ingest time.</summary>
|
||||||
|
|||||||
@@ -134,4 +134,45 @@ public interface IAuditLogRepository
|
|||||||
TimeSpan window,
|
TimeSpan window,
|
||||||
DateTime? nowUtc = null,
|
DateTime? nowUtc = null,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log ParentExecutionId feature (Task 8) — given any
|
||||||
|
/// <paramref name="executionId"/> in an execution chain, returns the whole
|
||||||
|
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
|
||||||
|
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
|
||||||
|
/// UI renders the result as a tree.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The input id may be any node in the chain — a leaf, the root, or a middle
|
||||||
|
/// node. The implementation first walks <em>up</em> via
|
||||||
|
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
|
||||||
|
/// the root via a recursive CTE, so the full chain is returned regardless of
|
||||||
|
/// entry point.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The <c>ParentExecutionId</c> graph is a tree (acyclic by construction —
|
||||||
|
/// each execution is minted fresh and its parent always pre-exists). Both
|
||||||
|
/// the upward walk and the downward CTE are nonetheless bounded at 32 levels
|
||||||
|
/// as a guard against corrupt/pathological data: a depth that exceeds the
|
||||||
|
/// guard raises an error rather than hanging the server. Chains are shallow
|
||||||
|
/// (1-2 levels typical) so the guard is never reached in practice.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A "stub" node — an execution that emitted no rows of its own yet is
|
||||||
|
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
|
||||||
|
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
|
||||||
|
/// = 0. A purged/missing parent simply ends the upward walk.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
|
||||||
|
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
|
||||||
|
/// single stub node for <paramref name="executionId"/> itself
|
||||||
|
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
|
||||||
|
/// stub-node treatment of any other row-less execution.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ public interface ICachedCallLifecycleObserver
|
|||||||
/// <param name="OccurredAtUtc">When this attempt completed.</param>
|
/// <param name="OccurredAtUtc">When this attempt completed.</param>
|
||||||
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
|
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
|
||||||
/// <param name="SourceInstanceId">Originating instance, when known.</param>
|
/// <param name="SourceInstanceId">Originating instance, when known.</param>
|
||||||
|
/// <param name="ExecutionId">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
|
||||||
|
/// per-run correlation id, threaded through the store-and-forward buffer from
|
||||||
|
/// the cached-call enqueue path. The audit bridge stamps it onto the
|
||||||
|
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
|
||||||
|
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
|
||||||
|
/// <c>null</c> for rows buffered before Task 4 (back-compat).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SourceScript">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||||
|
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
|
||||||
|
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
|
||||||
|
/// rows already do. <c>null</c> when not known.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ParentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||||
|
/// inbound-API request that spawned the originating script execution,
|
||||||
|
/// threaded through the store-and-forward buffer alongside
|
||||||
|
/// <paramref name="ExecutionId"/>. The audit bridge stamps it onto the
|
||||||
|
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
|
||||||
|
/// <c>CachedResolve</c> rows so they correlate back to the spawning run.
|
||||||
|
/// <c>null</c> for a non-routed run and for rows buffered before Task 6
|
||||||
|
/// (back-compat).
|
||||||
|
/// </param>
|
||||||
public sealed record CachedCallAttemptContext(
|
public sealed record CachedCallAttemptContext(
|
||||||
TrackedOperationId TrackedOperationId,
|
TrackedOperationId TrackedOperationId,
|
||||||
string Channel,
|
string Channel,
|
||||||
@@ -69,7 +93,10 @@ public sealed record CachedCallAttemptContext(
|
|||||||
DateTime CreatedAtUtc,
|
DateTime CreatedAtUtc,
|
||||||
DateTime OccurredAtUtc,
|
DateTime OccurredAtUtc,
|
||||||
int? DurationMs,
|
int? DurationMs,
|
||||||
string? SourceInstanceId);
|
string? SourceInstanceId,
|
||||||
|
Guid? ExecutionId = null,
|
||||||
|
string? SourceScript = null,
|
||||||
|
Guid? ParentExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
||||||
|
|||||||
@@ -29,11 +29,33 @@ public interface IDatabaseGateway
|
|||||||
/// <c>null</c> — when omitted the S&F engine mints a fresh GUID and no
|
/// <c>null</c> — when omitted the S&F engine mints a fresh GUID and no
|
||||||
/// M3 telemetry is correlated (pre-M3 caller behaviour).
|
/// M3 telemetry is correlated (pre-M3 caller behaviour).
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="executionId">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
|
||||||
|
/// per-run correlation id. When the write is buffered on a transient
|
||||||
|
/// failure this is threaded onto the S&F message so the retry-loop
|
||||||
|
/// cached-write audit rows carry it. <c>null</c> when not threaded.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sourceScript">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||||
|
/// threaded onto the buffered S&F message alongside
|
||||||
|
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||||
|
/// inbound-API request that spawned the originating script execution.
|
||||||
|
/// When the write is buffered on a transient failure this is threaded onto
|
||||||
|
/// the S&F message alongside <paramref name="executionId"/> so the
|
||||||
|
/// retry-loop cached-write audit rows carry it. <c>null</c> for a
|
||||||
|
/// non-routed run.
|
||||||
|
/// </param>
|
||||||
Task CachedWriteAsync(
|
Task CachedWriteAsync(
|
||||||
string connectionName,
|
string connectionName,
|
||||||
string sql,
|
string sql,
|
||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||||
string? originInstanceName = null,
|
string? originInstanceName = null,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null);
|
TrackedOperationId? trackedOperationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,35 @@ public interface IExternalSystemClient
|
|||||||
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
|
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
|
||||||
/// on).
|
/// on).
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="executionId">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
|
||||||
|
/// per-run correlation id. When the call is buffered on a transient
|
||||||
|
/// failure this is threaded onto the S&F message so the retry-loop
|
||||||
|
/// cached-call audit rows carry it. <c>null</c> when not threaded.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="sourceScript">
|
||||||
|
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
|
||||||
|
/// threaded onto the buffered S&F message alongside
|
||||||
|
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||||
|
/// inbound-API request that spawned the originating script execution.
|
||||||
|
/// When the call is buffered on a transient failure this is threaded onto
|
||||||
|
/// the S&F message alongside <paramref name="executionId"/> so the
|
||||||
|
/// retry-loop cached-call audit rows carry it. <c>null</c> for a non-routed
|
||||||
|
/// run.
|
||||||
|
/// </param>
|
||||||
Task<ExternalCallResult> CachedCallAsync(
|
Task<ExternalCallResult> CachedCallAsync(
|
||||||
string systemName,
|
string systemName,
|
||||||
string methodName,
|
string methodName,
|
||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||||
string? originInstanceName = null,
|
string? originInstanceName = null,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null);
|
TrackedOperationId? trackedOperationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi;
|
|||||||
/// Request routed from Inbound API to a site to invoke a script on an instance.
|
/// Request routed from Inbound API to a site to invoke a script on an instance.
|
||||||
/// Used by Route.To("instanceCode").Call("scriptName", params).
|
/// Used by Route.To("instanceCode").Call("scriptName", params).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="ParentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>
|
||||||
|
/// — for an inbound-API-routed call this is the inbound request's per-request
|
||||||
|
/// execution id. The site records it as the routed script execution's
|
||||||
|
/// <c>ParentExecutionId</c> so a spawned execution points back at its spawner.
|
||||||
|
/// Additive trailing member — null for requests built before the field existed
|
||||||
|
/// or for routed calls with no spawning execution (e.g. the Central UI sandbox).
|
||||||
|
/// </param>
|
||||||
public record RouteToCallRequest(
|
public record RouteToCallRequest(
|
||||||
string CorrelationId,
|
string CorrelationId,
|
||||||
string InstanceUniqueName,
|
string InstanceUniqueName,
|
||||||
string ScriptName,
|
string ScriptName,
|
||||||
IReadOnlyDictionary<string, object?>? Parameters,
|
IReadOnlyDictionary<string, object?>? Parameters,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp,
|
||||||
|
Guid? ParentExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Response from a Route.To() call.
|
/// Response from a Route.To() call.
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ namespace ScadaLink.Commons.Messages.Notification;
|
|||||||
/// Site -> Central: submit a notification for central delivery.
|
/// Site -> Central: submit a notification for central delivery.
|
||||||
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
|
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="OriginExecutionId">
|
||||||
|
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Stamped at
|
||||||
|
/// <c>Notify.Send</c> time and carried, inside the serialized payload, through the site
|
||||||
|
/// store-and-forward buffer so the central dispatcher can echo it onto the
|
||||||
|
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||||
|
/// before the field existed, or for notifications raised outside a script execution.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OriginParentExecutionId">
|
||||||
|
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
|
||||||
|
/// Stamped at <c>Notify.Send</c> time and carried, inside the serialized payload, through
|
||||||
|
/// the site store-and-forward buffer so the central dispatcher can echo it onto the
|
||||||
|
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||||
|
/// before the field existed, or for non-routed runs.
|
||||||
|
/// </param>
|
||||||
public record NotificationSubmit(
|
public record NotificationSubmit(
|
||||||
string NotificationId,
|
string NotificationId,
|
||||||
string ListName,
|
string ListName,
|
||||||
@@ -12,7 +26,9 @@ public record NotificationSubmit(
|
|||||||
string SourceSiteId,
|
string SourceSiteId,
|
||||||
string? SourceInstanceId,
|
string? SourceInstanceId,
|
||||||
string? SourceScript,
|
string? SourceScript,
|
||||||
DateTimeOffset SiteEnqueuedAt);
|
DateTimeOffset SiteEnqueuedAt,
|
||||||
|
Guid? OriginExecutionId = null,
|
||||||
|
Guid? OriginParentExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Site: ack sent after the notification row is persisted.
|
/// Central -> Site: ack sent after the notification row is persisted.
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
namespace ScadaLink.Commons.Messages.ScriptExecution;
|
namespace ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
|
|
||||||
|
/// <param name="ParentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>.
|
||||||
|
/// For an inbound-API-routed call this is the inbound request's per-request
|
||||||
|
/// execution id (carried in from <c>RouteToCallRequest.ParentExecutionId</c>);
|
||||||
|
/// the routed script execution records it as its <c>ParentExecutionId</c> so a
|
||||||
|
/// spawned execution points back at its spawner. Additive trailing member —
|
||||||
|
/// null for normal (tag-change / timer-triggered) runs, nested <c>Script.Call</c>
|
||||||
|
/// invocations, and any request built before the field existed.
|
||||||
|
/// </param>
|
||||||
public record ScriptCallRequest(
|
public record ScriptCallRequest(
|
||||||
string ScriptName,
|
string ScriptName,
|
||||||
IReadOnlyDictionary<string, object?>? Parameters,
|
IReadOnlyDictionary<string, object?>? Parameters,
|
||||||
int CurrentCallDepth,
|
int CurrentCallDepth,
|
||||||
string CorrelationId);
|
string CorrelationId,
|
||||||
|
Guid? ParentExecutionId = null);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
||||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
||||||
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||||
/// respectively. All filter dimensions are AND-combined with one another.
|
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||||
|
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
||||||
|
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AuditLogQueryFilter(
|
public sealed record AuditLogQueryFilter(
|
||||||
IReadOnlyList<AuditChannel>? Channels = null,
|
IReadOnlyList<AuditChannel>? Channels = null,
|
||||||
@@ -21,5 +23,7 @@ public sealed record AuditLogQueryFilter(
|
|||||||
string? Target = null,
|
string? Target = null,
|
||||||
string? Actor = null,
|
string? Actor = null,
|
||||||
Guid? CorrelationId = null,
|
Guid? CorrelationId = null,
|
||||||
|
Guid? ExecutionId = null,
|
||||||
|
Guid? ParentExecutionId = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null);
|
DateTime? ToUtc = null);
|
||||||
|
|||||||
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal file
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One execution within an execution chain returned by
|
||||||
|
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||||
|
/// Each node summarises the <c>AuditLog</c> rows sharing a single
|
||||||
|
/// <see cref="ExecutionId"/>; the Central UI renders the set as a tree by
|
||||||
|
/// joining <see cref="ParentExecutionId"/> to a parent node's
|
||||||
|
/// <see cref="ExecutionId"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Stub nodes.</b> An execution that performed a trust-boundary action but
|
||||||
|
/// crossed it without emitting any audit row — or whose own rows have been
|
||||||
|
/// purged — still appears as a node when a child references it via
|
||||||
|
/// <see cref="ParentExecutionId"/>. Such a stub node has <see cref="RowCount"/>
|
||||||
|
/// = 0, empty <see cref="Channels"/>/<see cref="Statuses"/>, null
|
||||||
|
/// <see cref="SourceSiteId"/>/<see cref="SourceInstanceId"/>, null timestamps,
|
||||||
|
/// and a null <see cref="ParentExecutionId"/> (a purged/ghost parent leaves no
|
||||||
|
/// row from which its own parent could be read — the upward walk ends there).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Channels"/> and <see cref="Statuses"/> are the distinct sets of
|
||||||
|
/// the corresponding enum names present across the execution's rows, modelled
|
||||||
|
/// as <see cref="IReadOnlyList{T}"/> of string to mirror how the repository's
|
||||||
|
/// query filters already pass small bounded sets around.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="ExecutionId">The execution this node summarises.</param>
|
||||||
|
/// <param name="ParentExecutionId">
|
||||||
|
/// The <see cref="ExecutionId"/> of the spawning execution, or null for the
|
||||||
|
/// root (and for stub nodes, whose own parent is unknowable).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="RowCount">
|
||||||
|
/// Number of <c>AuditLog</c> rows carrying this <see cref="ExecutionId"/>; 0 for
|
||||||
|
/// a stub node.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Channels">
|
||||||
|
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> names
|
||||||
|
/// present across this execution's rows; empty for a stub node.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Statuses">
|
||||||
|
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/> names
|
||||||
|
/// present across this execution's rows; empty for a stub node.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SourceSiteId">
|
||||||
|
/// Source site of the execution's rows when consistent; null for a stub node
|
||||||
|
/// (or when the rows carry no site).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="SourceInstanceId">
|
||||||
|
/// Source instance of the execution's rows when consistent; null for a stub
|
||||||
|
/// node (or when the rows carry no instance).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="FirstOccurredAtUtc">
|
||||||
|
/// Earliest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||||
|
/// node.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="LastOccurredAtUtc">
|
||||||
|
/// Latest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||||
|
/// node.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ExecutionTreeNode(
|
||||||
|
Guid ExecutionId,
|
||||||
|
Guid? ParentExecutionId,
|
||||||
|
int RowCount,
|
||||||
|
IReadOnlyList<string> Channels,
|
||||||
|
IReadOnlyList<string> Statuses,
|
||||||
|
string? SourceSiteId,
|
||||||
|
string? SourceInstanceId,
|
||||||
|
DateTime? FirstOccurredAtUtc,
|
||||||
|
DateTime? LastOccurredAtUtc);
|
||||||
@@ -47,6 +47,8 @@ public static class AuditEventDtoMapper
|
|||||||
Channel = evt.Channel.ToString(),
|
Channel = evt.Channel.ToString(),
|
||||||
Kind = evt.Kind.ToString(),
|
Kind = evt.Kind.ToString(),
|
||||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
||||||
|
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
|
||||||
|
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
|
||||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
||||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
||||||
SourceScript = evt.SourceScript ?? string.Empty,
|
SourceScript = evt.SourceScript ?? string.Empty,
|
||||||
@@ -92,6 +94,8 @@ public static class AuditEventDtoMapper
|
|||||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
||||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
||||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||||
|
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
||||||
|
ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
|
||||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
||||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
||||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
SourceScript = NullIfEmpty(dto.SourceScript),
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ message AuditEventDto {
|
|||||||
string response_summary = 17;
|
string response_summary = 17;
|
||||||
bool payload_truncated = 18;
|
bool payload_truncated = 18;
|
||||||
string extra = 19;
|
string extra = 19;
|
||||||
|
string execution_id = 20; // empty string represents null
|
||||||
|
string parent_execution_id = 21; // empty string represents null
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuditEventBatch { repeated AuditEventDto events = 1; }
|
message AuditEventBatch { repeated AuditEventDto events = 1; }
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
|
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
|
||||||
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
|
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
|
||||||
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
|
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
|
||||||
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE",
|
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE",
|
||||||
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
|
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
|
||||||
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
|
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
|
||||||
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
|
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
|
||||||
@@ -52,43 +52,44 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
|
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
|
||||||
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
|
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
|
||||||
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
|
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
|
||||||
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk",
|
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
|
||||||
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk",
|
"Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki",
|
||||||
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz",
|
"PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl",
|
||||||
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf",
|
"YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl",
|
||||||
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0",
|
"bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy",
|
||||||
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT",
|
"YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG",
|
||||||
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0",
|
"dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF",
|
||||||
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS",
|
"IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ",
|
||||||
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
|
"EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy",
|
||||||
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv",
|
"VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i",
|
||||||
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n",
|
"dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl",
|
||||||
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
|
"LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy",
|
||||||
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
|
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0",
|
||||||
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
|
"cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1",
|
||||||
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
|
"ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt",
|
||||||
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
|
"LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0",
|
||||||
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg",
|
"Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l",
|
||||||
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl",
|
"dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf",
|
||||||
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB",
|
"dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj",
|
||||||
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls",
|
"aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2",
|
||||||
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ",
|
"ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl",
|
||||||
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K",
|
"X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD",
|
||||||
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB",
|
"SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ",
|
||||||
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB",
|
"ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB",
|
||||||
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB",
|
"Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ",
|
||||||
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN",
|
"ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S",
|
||||||
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB",
|
"FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK",
|
||||||
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK",
|
"E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS",
|
||||||
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh",
|
"GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2",
|
||||||
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu",
|
"aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j",
|
||||||
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga",
|
"ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB",
|
||||||
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0",
|
"EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50",
|
||||||
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0",
|
"QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU",
|
||||||
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh",
|
"ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu",
|
||||||
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk",
|
"c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0",
|
||||||
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u",
|
"ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q",
|
||||||
"R3JwY2IGcHJvdG8z"));
|
"dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj",
|
||||||
|
"YXRpb24uR3JwY2IGcHJvdG8z"));
|
||||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
||||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||||
@@ -96,7 +97,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
|
||||||
@@ -1591,6 +1592,8 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
responseSummary_ = other.responseSummary_;
|
responseSummary_ = other.responseSummary_;
|
||||||
payloadTruncated_ = other.payloadTruncated_;
|
payloadTruncated_ = other.payloadTruncated_;
|
||||||
extra_ = other.extra_;
|
extra_ = other.extra_;
|
||||||
|
executionId_ = other.executionId_;
|
||||||
|
parentExecutionId_ = other.parentExecutionId_;
|
||||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1838,6 +1841,36 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Field number for the "execution_id" field.</summary>
|
||||||
|
public const int ExecutionIdFieldNumber = 20;
|
||||||
|
private string executionId_ = "";
|
||||||
|
/// <summary>
|
||||||
|
/// empty string represents null
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
|
public string ExecutionId {
|
||||||
|
get { return executionId_; }
|
||||||
|
set {
|
||||||
|
executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Field number for the "parent_execution_id" field.</summary>
|
||||||
|
public const int ParentExecutionIdFieldNumber = 21;
|
||||||
|
private string parentExecutionId_ = "";
|
||||||
|
/// <summary>
|
||||||
|
/// empty string represents null
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
|
public string ParentExecutionId {
|
||||||
|
get { return parentExecutionId_; }
|
||||||
|
set {
|
||||||
|
parentExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public override bool Equals(object other) {
|
public override bool Equals(object other) {
|
||||||
@@ -1872,6 +1905,8 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (ResponseSummary != other.ResponseSummary) return false;
|
if (ResponseSummary != other.ResponseSummary) return false;
|
||||||
if (PayloadTruncated != other.PayloadTruncated) return false;
|
if (PayloadTruncated != other.PayloadTruncated) return false;
|
||||||
if (Extra != other.Extra) return false;
|
if (Extra != other.Extra) return false;
|
||||||
|
if (ExecutionId != other.ExecutionId) return false;
|
||||||
|
if (ParentExecutionId != other.ParentExecutionId) return false;
|
||||||
return Equals(_unknownFields, other._unknownFields);
|
return Equals(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1898,6 +1933,8 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
|
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
|
||||||
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
|
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
|
||||||
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
|
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
|
||||||
|
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
|
||||||
|
if (ParentExecutionId.Length != 0) hash ^= ParentExecutionId.GetHashCode();
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
hash ^= _unknownFields.GetHashCode();
|
hash ^= _unknownFields.GetHashCode();
|
||||||
}
|
}
|
||||||
@@ -1990,6 +2027,14 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
output.WriteRawTag(154, 1);
|
output.WriteRawTag(154, 1);
|
||||||
output.WriteString(Extra);
|
output.WriteString(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(162, 1);
|
||||||
|
output.WriteString(ExecutionId);
|
||||||
|
}
|
||||||
|
if (ParentExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(170, 1);
|
||||||
|
output.WriteString(ParentExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(output);
|
_unknownFields.WriteTo(output);
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2119,14 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
output.WriteRawTag(154, 1);
|
output.WriteRawTag(154, 1);
|
||||||
output.WriteString(Extra);
|
output.WriteString(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(162, 1);
|
||||||
|
output.WriteString(ExecutionId);
|
||||||
|
}
|
||||||
|
if (ParentExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(170, 1);
|
||||||
|
output.WriteString(ParentExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(ref output);
|
_unknownFields.WriteTo(ref output);
|
||||||
}
|
}
|
||||||
@@ -2141,6 +2194,12 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (Extra.Length != 0) {
|
if (Extra.Length != 0) {
|
||||||
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
|
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
|
||||||
|
}
|
||||||
|
if (ParentExecutionId.Length != 0) {
|
||||||
|
size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
size += _unknownFields.CalculateSize();
|
size += _unknownFields.CalculateSize();
|
||||||
}
|
}
|
||||||
@@ -2217,6 +2276,12 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (other.Extra.Length != 0) {
|
if (other.Extra.Length != 0) {
|
||||||
Extra = other.Extra;
|
Extra = other.Extra;
|
||||||
}
|
}
|
||||||
|
if (other.ExecutionId.Length != 0) {
|
||||||
|
ExecutionId = other.ExecutionId;
|
||||||
|
}
|
||||||
|
if (other.ParentExecutionId.Length != 0) {
|
||||||
|
ParentExecutionId = other.ParentExecutionId;
|
||||||
|
}
|
||||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2321,6 +2386,14 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
Extra = input.ReadString();
|
Extra = input.ReadString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 162: {
|
||||||
|
ExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 170: {
|
||||||
|
ParentExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -2425,6 +2498,14 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
Extra = input.ReadString();
|
Extra = input.ReadString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 162: {
|
||||||
|
ExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 170: {
|
||||||
|
ParentExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
|||||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.ExecutionId)
|
||||||
|
.HasFilter("[ExecutionId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Execution");
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.ParentExecutionId)
|
||||||
|
.HasFilter("[ParentExecutionId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_AuditLog_ParentExecution");
|
||||||
|
|
||||||
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||||
.IsDescending(false, false, true)
|
.IsDescending(false, false, true)
|
||||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
|||||||
|
|
||||||
builder.Property(n => n.SourceScript).HasMaxLength(200);
|
builder.Property(n => n.SourceScript).HasMaxLength(200);
|
||||||
|
|
||||||
|
// OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the
|
||||||
|
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
|
||||||
|
// it is never a query predicate on this table, only copied onto audit events.
|
||||||
|
|
||||||
|
// OriginParentExecutionId (Audit Log #23): nullable uniqueidentifier carried from
|
||||||
|
// the site — the routed run's parent ExecutionId — so the dispatcher can echo it
|
||||||
|
// onto NotifyDeliver audit rows. No index — same rationale as OriginExecutionId.
|
||||||
|
|
||||||
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
||||||
|
|
||||||
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
||||||
|
|||||||
1626
src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs
generated
Normal file
1626
src/ScadaLink.ConfigurationDatabase/Migrations/20260521184044_AddAuditLogExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
|
||||||
|
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
|
||||||
|
/// script execution / inbound request and is distinct from the per-operation
|
||||||
|
/// <c>CorrelationId</c>.
|
||||||
|
///
|
||||||
|
/// The change is purely additive:
|
||||||
|
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
|
||||||
|
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
|
||||||
|
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
|
||||||
|
/// rows stay <c>NULL</c> (no backfill).
|
||||||
|
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
|
||||||
|
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
|
||||||
|
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
|
||||||
|
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddAuditLogExecutionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ExecutionId",
|
||||||
|
table: "AuditLog",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Raw SQL so the index is created on the partition scheme — EF's
|
||||||
|
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||||
|
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
|
||||||
|
ON dbo.AuditLog (ExecutionId)
|
||||||
|
WHERE ExecutionId IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExecutionId",
|
||||||
|
table: "AuditLog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1629
src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs
generated
Normal file
1629
src/ScadaLink.ConfigurationDatabase/Migrations/20260521193048_AddNotificationOriginExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the <c>OriginExecutionId</c> correlation column to the central
|
||||||
|
/// <c>Notifications</c> table (#21). It carries the originating script execution's
|
||||||
|
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
|
||||||
|
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
|
||||||
|
/// row for the same run.
|
||||||
|
///
|
||||||
|
/// The change is purely additive: <c>OriginExecutionId uniqueidentifier NULL</c> is
|
||||||
|
/// added with no default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>.
|
||||||
|
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a
|
||||||
|
/// plain <c>ADD</c> is fine. No index is created — the column is never a query
|
||||||
|
/// predicate, only copied onto audit events. Historical rows stay <c>NULL</c>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddNotificationOriginExecutionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "OriginExecutionId",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginExecutionId",
|
||||||
|
table: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1636
src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs
generated
Normal file
1636
src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the <c>ParentExecutionId</c> correlation column to the centralized
|
||||||
|
/// <c>AuditLog</c> table (#23). <c>ParentExecutionId</c> carries the
|
||||||
|
/// <c>ExecutionId</c> of the execution that spawned this run, letting a
|
||||||
|
/// spawned execution point back at its spawner — a sibling to the universal
|
||||||
|
/// per-run <c>ExecutionId</c>.
|
||||||
|
///
|
||||||
|
/// The change is purely additive:
|
||||||
|
/// 1. <c>ParentExecutionId uniqueidentifier NULL</c> is added with no default,
|
||||||
|
/// so the operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does
|
||||||
|
/// NOT rewrite the monthly-partitioned <c>AuditLog</c> table, and
|
||||||
|
/// historical rows stay <c>NULL</c> (no backfill).
|
||||||
|
/// 2. <c>IX_AuditLog_ParentExecution</c> is created via raw SQL so it lands on
|
||||||
|
/// the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching
|
||||||
|
/// every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
|
||||||
|
/// preserves the partition-switch purge path (see
|
||||||
|
/// AuditLogRepository.SwitchOutPartitionAsync).
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddAuditLogParentExecutionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ParentExecutionId",
|
||||||
|
table: "AuditLog",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Raw SQL so the index is created on the partition scheme — EF's
|
||||||
|
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||||
|
// clause. Mirrors IX_AuditLog_Execution (filtered, aligned).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
|
||||||
|
ON dbo.AuditLog (ParentExecutionId)
|
||||||
|
WHERE ParentExecutionId IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ParentExecution' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog;");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentExecutionId",
|
||||||
|
table: "AuditLog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the <c>OriginParentExecutionId</c> correlation column to the central
|
||||||
|
/// <c>Notifications</c> table (#21). It carries the originating routed script
|
||||||
|
/// execution's <c>ParentExecutionId</c> from the site so the dispatcher can echo it
|
||||||
|
/// onto the <c>NotifyDeliver</c> audit rows (#23), linking them to the routed run's
|
||||||
|
/// parent. Sibling of <c>OriginExecutionId</c>.
|
||||||
|
///
|
||||||
|
/// The change is purely additive: <c>OriginParentExecutionId uniqueidentifier NULL</c>
|
||||||
|
/// is added with no default, so the operation is a metadata-only
|
||||||
|
/// <c>ALTER TABLE … ADD</c>. Unlike <c>AuditLog</c>, the <c>Notifications</c> table is
|
||||||
|
/// NOT partitioned, so a plain <c>ADD</c> is fine. No index is created — the column is
|
||||||
|
/// never a query predicate, only copied onto audit events. Historical rows stay
|
||||||
|
/// <c>NULL</c>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddNotificationOriginParentExecutionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "OriginParentExecutionId",
|
||||||
|
table: "Notifications",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OriginParentExecutionId",
|
||||||
|
table: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ExecutionId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("Extra")
|
b.Property<string>("Extra")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -93,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.IsUnicode(false)
|
.IsUnicode(false)
|
||||||
.HasColumnType("varchar(32)");
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentExecutionId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<bool>("PayloadTruncated")
|
b.Property<bool>("PayloadTruncated")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -138,10 +144,18 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("UX_AuditLog_EventId");
|
.HasDatabaseName("UX_AuditLog_EventId");
|
||||||
|
|
||||||
|
b.HasIndex("ExecutionId")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Execution")
|
||||||
|
.HasFilter("[ExecutionId] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("OccurredAtUtc")
|
b.HasIndex("OccurredAtUtc")
|
||||||
.IsDescending()
|
.IsDescending()
|
||||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("ParentExecutionId")
|
||||||
|
.HasDatabaseName("IX_AuditLog_ParentExecution")
|
||||||
|
.HasFilter("[ParentExecutionId] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("SourceSiteId", "OccurredAtUtc")
|
b.HasIndex("SourceSiteId", "OccurredAtUtc")
|
||||||
.IsDescending(false, true)
|
.IsDescending(false, true)
|
||||||
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||||
@@ -780,6 +794,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
b.Property<DateTimeOffset?>("NextAttemptAt")
|
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OriginExecutionId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OriginParentExecutionId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("ResolvedTargets")
|
b.Property<string>("ResolvedTargets")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
|
|||||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
||||||
INSERT INTO dbo.AuditLog
|
INSERT INTO dbo.AuditLog
|
||||||
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
|
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||||
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||||
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||||
VALUES
|
VALUES
|
||||||
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
|
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
|
||||||
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
||||||
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
||||||
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
||||||
@@ -157,6 +157,16 @@ VALUES
|
|||||||
query = query.Where(e => e.CorrelationId == correlationId);
|
query = query.Where(e => e.CorrelationId == correlationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.ExecutionId is { } executionId)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.ExecutionId == executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ParentExecutionId is { } parentExecutionId)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.FromUtc is { } fromUtc)
|
if (filter.FromUtc is { } fromUtc)
|
||||||
{
|
{
|
||||||
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
||||||
@@ -263,6 +273,13 @@ VALUES
|
|||||||
PayloadTruncated bit NOT NULL,
|
PayloadTruncated bit NOT NULL,
|
||||||
Extra nvarchar(max) NULL,
|
Extra nvarchar(max) NULL,
|
||||||
ForwardState varchar(32) NULL,
|
ForwardState varchar(32) NULL,
|
||||||
|
-- ExecutionId and ParentExecutionId are last (in this ordinal order)
|
||||||
|
-- because each was added to the live AuditLog table by a later
|
||||||
|
-- ALTER TABLE ADD migration; the staging table must match the live
|
||||||
|
-- table column shape ordinal-for-ordinal or
|
||||||
|
-- ALTER TABLE ... SWITCH PARTITION fails.
|
||||||
|
ExecutionId uniqueidentifier NULL,
|
||||||
|
ParentExecutionId uniqueidentifier NULL,
|
||||||
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||||
) ON [PRIMARY];
|
) ON [PRIMARY];
|
||||||
|
|
||||||
@@ -538,4 +555,227 @@ VALUES
|
|||||||
BacklogTotal: 0L,
|
BacklogTotal: 0L,
|
||||||
AsOfUtc: anchorUtc);
|
AsOfUtc: anchorUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard ceiling on chain depth for both the upward walk and the downward
|
||||||
|
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
|
||||||
|
// construction — each execution is minted fresh, its parent always
|
||||||
|
// pre-exists), so this is purely a guard against corrupt/pathological data:
|
||||||
|
// a cycle must surface as a bounded error rather than hang the server.
|
||||||
|
// Chains are shallow (1-2 levels typical) so the guard is never reached in
|
||||||
|
// practice.
|
||||||
|
private const int ExecutionChainMaxDepth = 32;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
|
||||||
|
/// containing <paramref name="executionId"/>, regardless of entry point.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two phases. <b>Walk up:</b> an iterative
|
||||||
|
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
|
||||||
|
/// climbs from the supplied node to the root — the last execution id with no
|
||||||
|
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
|
||||||
|
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
|
||||||
|
/// down:</b> a recursive CTE over a DISTINCT
|
||||||
|
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
|
||||||
|
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
|
||||||
|
/// enumerate every descendant. Recursing over edges rather than raw rows
|
||||||
|
/// keeps the recursion one path wide per execution. It is bounded by
|
||||||
|
/// <c>OPTION (MAXRECURSION ...)</c> at <see cref="ExecutionChainMaxDepth"/>
|
||||||
|
/// — corrupt cyclic data raises a <see cref="SqlException"/> (msg 530)
|
||||||
|
/// rather than spinning.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
|
||||||
|
/// unioned with its non-null <c>ParentExecutionId</c>, so an execution
|
||||||
|
/// referenced only as a parent — a "stub" that emitted no rows of its own,
|
||||||
|
/// and therefore owns no edge of its own — is still included. The final
|
||||||
|
/// projection LEFT JOINs that id set back to <c>AuditLog</c> and
|
||||||
|
/// <c>GROUP BY</c>s, so a stub yields a node with <c>RowCount = 0</c> and
|
||||||
|
/// empty/null aggregates. The query is SELECT-only
|
||||||
|
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
|
Guid executionId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = _context.Database.GetDbConnection();
|
||||||
|
var openedHere = false;
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
{
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
openedHere = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// --- Phase 1: walk up to the root ---------------------------------
|
||||||
|
// Climb ParentExecutionId until a node has no parent (root) or the
|
||||||
|
// parent execution has no rows of its own (purged/stub — the climb
|
||||||
|
// cannot continue past a row-less node). The depth cap guards
|
||||||
|
// against a cycle in corrupt data; a tree never reaches it.
|
||||||
|
var rootExecutionId = executionId;
|
||||||
|
for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
|
||||||
|
{
|
||||||
|
Guid? parent;
|
||||||
|
await using (var upCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
upCmd.CommandText =
|
||||||
|
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
|
||||||
|
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
|
||||||
|
var pCur = upCmd.CreateParameter();
|
||||||
|
pCur.ParameterName = "@cur";
|
||||||
|
pCur.Value = rootExecutionId;
|
||||||
|
upCmd.Parameters.Add(pCur);
|
||||||
|
|
||||||
|
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
parent = result is null or DBNull ? null : (Guid)result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent is null)
|
||||||
|
{
|
||||||
|
// No parent row for the current node — it is the root (or a
|
||||||
|
// row-less stub at the top of what survives). Stop climbing.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
rootExecutionId = parent.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 2: walk down from the root via a recursive CTE ---------
|
||||||
|
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
|
||||||
|
// edge set distilled from AuditLog. Recursing over edges
|
||||||
|
// instead of raw rows means an execution with N audit rows
|
||||||
|
// contributes ONE recursion path, not N — MAXRECURSION
|
||||||
|
// bounds depth, not per-level width, so the raw-row form
|
||||||
|
// could fan out badly. One edge per execution because all
|
||||||
|
// rows of an execution share a single ParentExecutionId
|
||||||
|
// (see the MIN(...) note on the final projection).
|
||||||
|
// Chain : seeded at the root edge, recursively joins each edge whose
|
||||||
|
// ParentExecutionId is an ExecutionId already in the chain.
|
||||||
|
// Each edge carries its own ParentExecutionId, so the chain
|
||||||
|
// of edges already surfaces every execution id in the tree
|
||||||
|
// — including a row-less stub parent, which appears as the
|
||||||
|
// ParentExecutionId of its child's edge. No separate
|
||||||
|
// union-back CTE is needed.
|
||||||
|
// Final : collect every distinct execution id reachable from the
|
||||||
|
// chain (each edge's ExecutionId plus its non-null
|
||||||
|
// ParentExecutionId), LEFT JOIN back to AuditLog and
|
||||||
|
// GROUP BY so a stub parent — which owns no edge of its own
|
||||||
|
// because it emitted no rows — still surfaces as a node with
|
||||||
|
// RowCount 0 and NULL aggregates.
|
||||||
|
var nodes = new List<ExecutionTreeNode>();
|
||||||
|
await using (var downCmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
downCmd.CommandText = $@"
|
||||||
|
WITH Edges AS (
|
||||||
|
SELECT DISTINCT ExecutionId, ParentExecutionId
|
||||||
|
FROM dbo.AuditLog
|
||||||
|
WHERE ExecutionId IS NOT NULL
|
||||||
|
),
|
||||||
|
Chain AS (
|
||||||
|
-- Anchor: the root execution id, seeded as a literal so
|
||||||
|
-- it is present even when the root is a row-less stub
|
||||||
|
-- (a purged/no-action parent owns no edge of its own).
|
||||||
|
-- The root is parentless by construction — the upward
|
||||||
|
-- walk stopped there — so its ParentExecutionId is NULL.
|
||||||
|
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
|
||||||
|
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.ExecutionId, e.ParentExecutionId
|
||||||
|
FROM Edges e
|
||||||
|
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||||
|
),
|
||||||
|
ChainIds AS (
|
||||||
|
SELECT ExecutionId FROM Chain
|
||||||
|
UNION
|
||||||
|
SELECT ParentExecutionId FROM Chain
|
||||||
|
WHERE ParentExecutionId IS NOT NULL
|
||||||
|
)
|
||||||
|
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
|
||||||
|
-- derived via MIN: every audit row of one execution carries
|
||||||
|
-- the SAME ParentExecutionId (and source identity) — it is
|
||||||
|
-- stamped once per script run / inbound request — so MIN
|
||||||
|
-- simply picks that one shared value, it is not collapsing a
|
||||||
|
-- genuine disagreement across rows.
|
||||||
|
SELECT
|
||||||
|
ids.ExecutionId AS [ExecutionId],
|
||||||
|
MIN(a.ParentExecutionId) AS [ParentExecutionId],
|
||||||
|
COUNT(a.EventId) AS [RowCount],
|
||||||
|
(SELECT STRING_AGG(d.Channel, ',')
|
||||||
|
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
|
||||||
|
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
|
||||||
|
(SELECT STRING_AGG(d.Status, ',')
|
||||||
|
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
|
||||||
|
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
|
||||||
|
MIN(a.SourceSiteId) AS [SourceSiteId],
|
||||||
|
MIN(a.SourceInstanceId) AS [SourceInstanceId],
|
||||||
|
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
|
||||||
|
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
|
||||||
|
FROM ChainIds ids
|
||||||
|
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
|
||||||
|
GROUP BY ids.ExecutionId
|
||||||
|
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
|
||||||
|
|
||||||
|
var pRoot = downCmd.CreateParameter();
|
||||||
|
pRoot.ParameterName = "@root";
|
||||||
|
pRoot.Value = rootExecutionId;
|
||||||
|
downCmd.Parameters.Add(pRoot);
|
||||||
|
|
||||||
|
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var nodeExecutionId = reader.GetGuid(0);
|
||||||
|
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
|
||||||
|
var rowCount = reader.GetInt32(2);
|
||||||
|
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
|
||||||
|
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
|
||||||
|
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
|
||||||
|
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
|
||||||
|
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
|
||||||
|
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
|
||||||
|
|
||||||
|
nodes.Add(new ExecutionTreeNode(
|
||||||
|
ExecutionId: nodeExecutionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
|
RowCount: rowCount,
|
||||||
|
Channels: channels,
|
||||||
|
Statuses: statuses,
|
||||||
|
SourceSiteId: sourceSiteId,
|
||||||
|
SourceInstanceId: sourceInstanceId,
|
||||||
|
FirstOccurredAtUtc: firstOccurred,
|
||||||
|
LastOccurredAtUtc: lastOccurred));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (openedHere)
|
||||||
|
{
|
||||||
|
await conn.CloseAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
|
||||||
|
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
|
||||||
|
/// list rather than a single empty string.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(aggregate))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregate
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.OrderBy(v => v, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ public class DatabaseGateway : IDatabaseGateway
|
|||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||||
string? originInstanceName = null,
|
string? originInstanceName = null,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null)
|
TrackedOperationId? trackedOperationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||||
if (definition == null)
|
if (definition == null)
|
||||||
@@ -124,7 +127,18 @@ public class DatabaseGateway : IDatabaseGateway
|
|||||||
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
|
// read it back via StoreAndForwardMessage.Id and emit per-attempt +
|
||||||
// terminal cached-write telemetry. Null -> S&F mints its own GUID
|
// terminal cached-write telemetry. Null -> S&F mints its own GUID
|
||||||
// (legacy pre-M3 behaviour).
|
// (legacy pre-M3 behaviour).
|
||||||
messageId: trackedOperationId?.ToString());
|
messageId: trackedOperationId?.ToString(),
|
||||||
|
// Audit Log #23 (ExecutionId Task 4): thread the originating script
|
||||||
|
// execution's ExecutionId + SourceScript onto the buffered row so
|
||||||
|
// the retry-loop cached-write audit rows carry the same provenance
|
||||||
|
// the script-side cached rows do.
|
||||||
|
executionId: executionId,
|
||||||
|
sourceScript: sourceScript,
|
||||||
|
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
|
||||||
|
// inbound-API request's ExecutionId onto the buffered row so the
|
||||||
|
// retry-loop cached-write audit rows correlate back to the
|
||||||
|
// cross-execution chain. Null for a non-routed run.
|
||||||
|
parentExecutionId: parentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -86,7 +86,10 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||||
string? originInstanceName = null,
|
string? originInstanceName = null,
|
||||||
CancellationToken cancellationToken = default,
|
CancellationToken cancellationToken = default,
|
||||||
TrackedOperationId? trackedOperationId = null)
|
TrackedOperationId? trackedOperationId = null,
|
||||||
|
Guid? executionId = null,
|
||||||
|
string? sourceScript = null,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||||
if (system == null || method == null)
|
if (system == null || method == null)
|
||||||
@@ -144,7 +147,18 @@ public class ExternalSystemClient : IExternalSystemClient
|
|||||||
// StoreAndForwardMessage.Id and emit per-attempt + terminal
|
// StoreAndForwardMessage.Id and emit per-attempt + terminal
|
||||||
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
|
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
|
||||||
// mints its own GUID (legacy pre-M3 behaviour).
|
// mints its own GUID (legacy pre-M3 behaviour).
|
||||||
messageId: trackedOperationId?.ToString());
|
messageId: trackedOperationId?.ToString(),
|
||||||
|
// Audit Log #23 (ExecutionId Task 4): thread the originating
|
||||||
|
// script execution's ExecutionId + SourceScript onto the
|
||||||
|
// buffered row so the retry-loop cached-call audit rows carry
|
||||||
|
// the same provenance the script-side cached rows do.
|
||||||
|
executionId: executionId,
|
||||||
|
sourceScript: sourceScript,
|
||||||
|
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
|
||||||
|
// inbound-API request's ExecutionId onto the buffered row so
|
||||||
|
// the retry-loop cached-call audit rows correlate back to the
|
||||||
|
// cross-execution chain. Null for a non-routed run.
|
||||||
|
parentExecutionId: parentExecutionId);
|
||||||
|
|
||||||
return new ExternalCallResult(true, null, null, WasBuffered: true);
|
return new ExternalCallResult(true, null, null, WasBuffered: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<title>ScadaLink</title>
|
<title>ScadaBridge</title>
|
||||||
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
|
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" />
|
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" />
|
||||||
|
<link href="_content/ScadaLink.CentralUI/css/theme.css" rel="stylesheet" />
|
||||||
<link href="/ScadaLink.Host.styles.css" rel="stylesheet" />
|
<link href="/ScadaLink.Host.styles.css" rel="stylesheet" />
|
||||||
<link href="_content/ScadaLink.CentralUI/css/site.css" rel="stylesheet" />
|
<link href="_content/ScadaLink.CentralUI/css/site.css" rel="stylesheet" />
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/treeview-storage.js"></script>
|
<script src="/js/treeview-storage.js"></script>
|
||||||
|
<script src="_content/ScadaLink.CentralUI/js/nav-state.js"></script>
|
||||||
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
<script src="_content/ScadaLink.CentralUI/js/monaco-init.js"></script>
|
||||||
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
|
<script src="_content/ScadaLink.CentralUI/js/audit-grid.js"></script>
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|||||||
@@ -92,8 +92,21 @@ public static class EndpointExtensions
|
|||||||
? TimeSpan.FromSeconds(method.TimeoutSeconds)
|
? TimeSpan.FromSeconds(method.TimeoutSeconds)
|
||||||
: options.DefaultMethodTimeout;
|
: options.DefaultMethodTimeout;
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): the inbound request's per-request
|
||||||
|
// ExecutionId was minted early by AuditWriteMiddleware and stashed on
|
||||||
|
// HttpContext.Items. Thread it into the executor so a routed
|
||||||
|
// Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
|
||||||
|
// — the spawned site script execution points back at this inbound request.
|
||||||
|
var parentExecutionId =
|
||||||
|
httpContext.Items.TryGetValue(
|
||||||
|
AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId)
|
||||||
|
&& stashedExecutionId is Guid inboundExecutionId
|
||||||
|
? inboundExecutionId
|
||||||
|
: (Guid?)null;
|
||||||
|
|
||||||
var scriptResult = await executor.ExecuteAsync(
|
var scriptResult = await executor.ExecuteAsync(
|
||||||
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted);
|
method, paramResult.Parameters, routeHelper, timeout,
|
||||||
|
httpContext.RequestAborted, parentExecutionId);
|
||||||
|
|
||||||
if (!scriptResult.Success)
|
if (!scriptResult.Success)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ScadaLink.Commons.Entities.InboundApi;
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Messages.InboundApi;
|
||||||
using ScadaLink.Commons.Types;
|
using ScadaLink.Commons.Types;
|
||||||
|
|
||||||
namespace ScadaLink.InboundAPI;
|
namespace ScadaLink.InboundAPI;
|
||||||
@@ -156,12 +157,25 @@ public class InboundScriptExecutor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes the script for the given method with the provided context.
|
/// Executes the script for the given method with the provided context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
|
||||||
|
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
|
||||||
|
/// on <c>HttpContext.Items</c>). When supplied, a routed
|
||||||
|
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
|
||||||
|
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
|
||||||
|
/// script execution points back at this inbound request. Null when the script
|
||||||
|
/// runs outside an inbound API request flow.
|
||||||
|
/// </param>
|
||||||
public async Task<InboundScriptResult> ExecuteAsync(
|
public async Task<InboundScriptResult> ExecuteAsync(
|
||||||
ApiMethod method,
|
ApiMethod method,
|
||||||
IReadOnlyDictionary<string, object?> parameters,
|
IReadOnlyDictionary<string, object?> parameters,
|
||||||
RouteHelper route,
|
RouteHelper route,
|
||||||
TimeSpan timeout,
|
TimeSpan timeout,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default,
|
||||||
|
// Deliberate ordering: this optional parameter trails the CancellationToken
|
||||||
|
// because it was appended additively for non-breaking contract evolution.
|
||||||
|
// Every call site passes it by named argument (parentExecutionId:).
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
// InboundAPI-004: keep the timeout source and the request-abort source
|
// InboundAPI-004: keep the timeout source and the request-abort source
|
||||||
// separable. A single linked CTS makes a genuine client disconnect
|
// separable. A single linked CTS makes a genuine client disconnect
|
||||||
@@ -177,7 +191,14 @@ public class InboundScriptExecutor
|
|||||||
// InboundAPI-016: bind the route helper to the method deadline so a
|
// InboundAPI-016: bind the route helper to the method deadline so a
|
||||||
// routed Route.To(...).Call(...) inherits the method-level timeout
|
// routed Route.To(...).Call(...) inherits the method-level timeout
|
||||||
// without the script having to thread the context token by hand.
|
// without the script having to thread the context token by hand.
|
||||||
var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token);
|
//
|
||||||
|
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
|
||||||
|
// ExecutionId so a routed call carries it as ParentExecutionId — the
|
||||||
|
// spawned site script execution points back at this inbound request.
|
||||||
|
var context = new InboundScriptContext(
|
||||||
|
parameters,
|
||||||
|
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Buffers;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
using ScadaLink.Commons.Entities.Audit;
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
using ScadaLink.Commons.Interfaces.Services;
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Types.Enums;
|
using ScadaLink.Commons.Types.Enums;
|
||||||
@@ -42,11 +45,22 @@ namespace ScadaLink.InboundAPI.Middleware;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Body capture.</b> The request body is buffered via
|
/// <b>Body capture.</b> The request body is buffered via
|
||||||
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
|
||||||
/// rewound so the downstream endpoint handler still sees the full payload.
|
/// rewound so the downstream endpoint handler still sees the full payload. The
|
||||||
/// Response body capture is deferred to M5 — wrapping <c>Response.Body</c>
|
/// response body is captured by wrapping <see cref="HttpResponse.Body"/> in a
|
||||||
/// requires a memory-stream swap that interacts awkwardly with Minimal API's
|
/// forwarding stream that mirrors writes to the original sink (transparent to
|
||||||
/// <c>Results.Json</c>/<c>Results.Text</c> writers; the M4 deliverable emits
|
/// the real client) while capturing a bounded copy for audit.
|
||||||
/// the audit row with <see cref="AuditEvent.ResponseSummary"/> left null.
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Bounded capture at the source.</b> Both the request- and response-body
|
||||||
|
/// audit copies are bounded at <see cref="AuditLogOptions.InboundMaxBytes"/>
|
||||||
|
/// (default 1 MiB) AT THE CAPTURE SITE — we never buffer more than
|
||||||
|
/// <c>cap + 1</c> bytes per body even when the client streams hundreds of MiB.
|
||||||
|
/// The downstream handler and the real client still see every byte; only the
|
||||||
|
/// audit copy is bounded. The cap is also enforced again by
|
||||||
|
/// <see cref="ScadaLink.AuditLog.Payload.DefaultAuditPayloadFilter"/> (which OR's
|
||||||
|
/// in its own <see cref="AuditEvent.PayloadTruncated"/> determination), so a
|
||||||
|
/// row truncated here remains truncated even if the filter is bypassed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditWriteMiddleware
|
public sealed class AuditWriteMiddleware
|
||||||
@@ -59,31 +73,71 @@ public sealed class AuditWriteMiddleware
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
|
||||||
|
/// which this middleware stashes the inbound request's per-request
|
||||||
|
/// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
|
||||||
|
/// The id is minted ONCE and shared: the endpoint handler reads it to thread it
|
||||||
|
/// onto a routed <c>RouteToCallRequest.ParentExecutionId</c>, and the
|
||||||
|
/// middleware's own inbound audit row uses the same id for its
|
||||||
|
/// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
|
||||||
|
/// and middleware share a single source of truth (no stringly-typed coupling).
|
||||||
|
/// </summary>
|
||||||
|
public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId";
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly ICentralAuditWriter _auditWriter;
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
private readonly ILogger<AuditWriteMiddleware> _logger;
|
private readonly ILogger<AuditWriteMiddleware> _logger;
|
||||||
|
private readonly IOptionsMonitor<AuditLogOptions> _options;
|
||||||
|
|
||||||
public AuditWriteMiddleware(
|
public AuditWriteMiddleware(
|
||||||
RequestDelegate next,
|
RequestDelegate next,
|
||||||
ICentralAuditWriter auditWriter,
|
ICentralAuditWriter auditWriter,
|
||||||
ILogger<AuditWriteMiddleware> logger)
|
ILogger<AuditWriteMiddleware> logger,
|
||||||
|
IOptionsMonitor<AuditLogOptions> options)
|
||||||
{
|
{
|
||||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||||
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext ctx)
|
public async Task InvokeAsync(HttpContext ctx)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
// Per-request hot read of the inbound cap — mirrors the convention used
|
||||||
|
// by DefaultAuditPayloadFilter so a live config change picks up on the
|
||||||
|
// next request without re-resolving the singleton.
|
||||||
|
var cap = _options.CurrentValue.InboundMaxBytes;
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
|
||||||
|
// ExecutionId ONCE, here at the start of the request, and stash it on
|
||||||
|
// HttpContext.Items. Two consumers share this single id:
|
||||||
|
// (a) the endpoint handler reads it to thread onto a routed
|
||||||
|
// RouteToCallRequest.ParentExecutionId, so a spawned site script
|
||||||
|
// execution points back at this inbound request;
|
||||||
|
// (b) the inbound audit row this middleware emits uses it as its own
|
||||||
|
// ExecutionId (the row stays top-level — its ParentExecutionId is
|
||||||
|
// never set).
|
||||||
|
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
|
||||||
|
|
||||||
// Buffer the request body up front so we can both audit it and let the
|
// Buffer the request body up front so we can both audit it and let the
|
||||||
// downstream handler still parse it. EnableBuffering swaps the request
|
// downstream handler still parse it. EnableBuffering swaps the request
|
||||||
// stream for a seekable wrapper that the framework rewinds at the end
|
// stream for a seekable wrapper that the framework rewinds at the end
|
||||||
// of the pipeline for us — but we also rewind to position 0 after our
|
// of the pipeline for us — but we also rewind to position 0 after our
|
||||||
// own read so the very next reader starts from the top.
|
// own read so the very next reader starts from the top.
|
||||||
ctx.Request.EnableBuffering();
|
ctx.Request.EnableBuffering();
|
||||||
var requestBody = await ReadBufferedRequestBodyAsync(ctx.Request).ConfigureAwait(false);
|
var (requestBody, requestTruncated) =
|
||||||
|
await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Response body — wrap Response.Body in a forwarding stream that mirrors
|
||||||
|
// every write to the original sink (transparent to the real client)
|
||||||
|
// while capturing AT MOST `cap + 1` bytes for the audit copy. The
|
||||||
|
// original Response.Body is restored in the finally block.
|
||||||
|
var originalResponseBody = ctx.Response.Body;
|
||||||
|
using var captureStream = new CapturedResponseStream(originalResponseBody, cap);
|
||||||
|
ctx.Response.Body = captureStream;
|
||||||
|
|
||||||
Exception? thrown = null;
|
Exception? thrown = null;
|
||||||
try
|
try
|
||||||
@@ -100,7 +154,20 @@ public sealed class AuditWriteMiddleware
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
sw.Stop();
|
sw.Stop();
|
||||||
EmitInboundAudit(ctx, sw.ElapsedMilliseconds, thrown, requestBody);
|
|
||||||
|
// Restore the original stream and resolve the captured audit copy.
|
||||||
|
// The forwarding wrapper has already written every byte to the
|
||||||
|
// original sink; this just pulls back the bounded UTF-8 string.
|
||||||
|
ctx.Response.Body = originalResponseBody;
|
||||||
|
var (responseBody, responseTruncated) = captureStream.GetCapturedBody();
|
||||||
|
|
||||||
|
EmitInboundAudit(
|
||||||
|
ctx,
|
||||||
|
sw.ElapsedMilliseconds,
|
||||||
|
thrown,
|
||||||
|
requestBody,
|
||||||
|
responseBody,
|
||||||
|
requestTruncated || responseTruncated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +180,9 @@ public sealed class AuditWriteMiddleware
|
|||||||
HttpContext ctx,
|
HttpContext ctx,
|
||||||
long durationMs,
|
long durationMs,
|
||||||
Exception? thrown,
|
Exception? thrown,
|
||||||
string? requestBody)
|
string? requestBody,
|
||||||
|
string? responseBody,
|
||||||
|
bool payloadTruncated)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -145,6 +214,18 @@ public sealed class AuditWriteMiddleware
|
|||||||
OccurredAtUtc = DateTime.UtcNow,
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
Channel = AuditChannel.ApiInbound,
|
Channel = AuditChannel.ApiInbound,
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
|
// Audit Log #23: the per-request execution id minted ONCE at the
|
||||||
|
// start of the request (InvokeAsync) and stashed on
|
||||||
|
// HttpContext.Items. The same id is threaded onto a routed
|
||||||
|
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
|
||||||
|
// so an inbound request and the site script it routes to share
|
||||||
|
// one correlation point. This inbound row stays top-level — its
|
||||||
|
// own ParentExecutionId is never set (see below).
|
||||||
|
ExecutionId = ResolveInboundExecutionId(ctx),
|
||||||
|
// CorrelationId is purely the per-operation-lifecycle id; an
|
||||||
|
// inbound request is a one-shot from the audit row's
|
||||||
|
// perspective with no multi-row operation to correlate.
|
||||||
|
CorrelationId = null,
|
||||||
Actor = actor,
|
Actor = actor,
|
||||||
Target = methodName,
|
Target = methodName,
|
||||||
Status = status,
|
Status = status,
|
||||||
@@ -152,9 +233,8 @@ public sealed class AuditWriteMiddleware
|
|||||||
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
DurationMs = (int)Math.Min(durationMs, int.MaxValue),
|
||||||
ErrorMessage = thrown?.Message,
|
ErrorMessage = thrown?.Message,
|
||||||
RequestSummary = requestBody,
|
RequestSummary = requestBody,
|
||||||
// Response body capture is deferred to M5 (see XML doc above).
|
ResponseSummary = responseBody,
|
||||||
ResponseSummary = null,
|
PayloadTruncated = payloadTruncated,
|
||||||
PayloadTruncated = false,
|
|
||||||
Extra = extra,
|
Extra = extra,
|
||||||
// Central direct-write — no site-local forwarding state.
|
// Central direct-write — no site-local forwarding state.
|
||||||
ForwardState = null,
|
ForwardState = null,
|
||||||
@@ -175,39 +255,136 @@ public sealed class AuditWriteMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the buffered request body fully into a string and rewinds the
|
/// Reads the buffered request body up to <paramref name="capBytes"/> bytes
|
||||||
/// stream so the downstream handler sees the unconsumed payload. Returns
|
/// into a string for the audit copy and rewinds the stream so the
|
||||||
/// null for empty/missing bodies so the audit row's
|
/// downstream handler sees the unconsumed payload. Returns
|
||||||
|
/// <c>(null, false)</c> for empty/missing bodies so the audit row's
|
||||||
/// <see cref="AuditEvent.RequestSummary"/> stays null rather than
|
/// <see cref="AuditEvent.RequestSummary"/> stays null rather than
|
||||||
/// containing an empty string.
|
/// containing an empty string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<string?> ReadBufferedRequestBodyAsync(HttpRequest request)
|
/// <remarks>
|
||||||
|
/// Reads AT MOST <c>cap + 1</c> bytes from the request stream into a
|
||||||
|
/// scratch buffer; if the extra byte arrives the body is over the cap and
|
||||||
|
/// the returned string is UTF-8 byte-safe truncated to exactly
|
||||||
|
/// <c>cap</c> bytes with <c>truncated = true</c>. The cap applies only to
|
||||||
|
/// the audit copy — the request stream is always rewound to position 0
|
||||||
|
/// afterwards so the framework's next reader (the endpoint handler's
|
||||||
|
/// JSON parser) sees the full body.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<(string? body, bool truncated)> ReadBufferedRequestBodyAsync(
|
||||||
|
HttpRequest request,
|
||||||
|
int capBytes)
|
||||||
{
|
{
|
||||||
if (request.ContentLength is 0)
|
if (request.ContentLength is 0)
|
||||||
{
|
{
|
||||||
return null;
|
return (null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read AT MOST cap + 1 bytes — the extra byte tells us the body was
|
||||||
|
// over the cap without forcing us to allocate the whole payload. Rent
|
||||||
|
// the scratch buffer from the shared ArrayPool so we don't allocate
|
||||||
|
// (and immediately discard) `cap + 1` bytes per request — the pool
|
||||||
|
// may hand back a buffer LARGER than `limit`, so we treat `limit`
|
||||||
|
// (not `buffer.Length`) as the read ceiling.
|
||||||
|
var limit = capBytes + 1;
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(limit);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
request.Body.Position = 0;
|
request.Body.Position = 0;
|
||||||
using var reader = new StreamReader(
|
|
||||||
request.Body,
|
var total = 0;
|
||||||
Encoding.UTF8,
|
while (total < limit)
|
||||||
detectEncodingFromByteOrderMarks: false,
|
{
|
||||||
bufferSize: 1024,
|
var read = await request.Body
|
||||||
leaveOpen: true);
|
.ReadAsync(buffer.AsMemory(total, limit - total))
|
||||||
var content = await reader.ReadToEndAsync().ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
request.Body.Position = 0;
|
if (read == 0)
|
||||||
return string.IsNullOrEmpty(content) ? null : content;
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total == 0)
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncated = total > capBytes;
|
||||||
|
var bytesForString = truncated ? capBytes : total;
|
||||||
|
var content = DecodeUtf8Bounded(buffer, bytesForString, cutAtValidBytes: truncated);
|
||||||
|
return (string.IsNullOrEmpty(content) ? null : content, truncated);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// A failed body read must not abort the request — fall through
|
// A failed body read must not abort the request — fall through
|
||||||
// with a null RequestSummary; the audit row still records the
|
// with a null RequestSummary; the audit row still records the
|
||||||
// outcome.
|
// outcome.
|
||||||
return null;
|
return (null, false);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Even on a thrown read, the downstream handler must see the full
|
||||||
|
// body from position 0 — never let a failed audit copy leak a
|
||||||
|
// truncated view. A rewind failure is swallowed: best-effort,
|
||||||
|
// same philosophy as the rest of the file.
|
||||||
|
try { request.Body.Position = 0; } catch { /* swallow */ }
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UTF-8 byte-safe decode of <paramref name="validBytes"/> bytes from
|
||||||
|
/// <paramref name="bytes"/>. When <paramref name="cutAtValidBytes"/> is
|
||||||
|
/// <c>true</c> the input is the result of a hard byte-count truncation, so
|
||||||
|
/// we walk back from <c>validBytes</c> while the byte is a continuation
|
||||||
|
/// byte (<c>byte & 0xC0 == 0x80</c>) to avoid splitting a multi-byte
|
||||||
|
/// codepoint. When <c>false</c> the caller is decoding the full payload
|
||||||
|
/// and the boundary stands as-is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mirrors the algorithm in <c>DefaultAuditPayloadFilter.TruncateUtf8</c>;
|
||||||
|
/// kept local to avoid a backwards project reference from
|
||||||
|
/// ScadaLink.AuditLog into ScadaLink.InboundAPI.
|
||||||
|
/// </remarks>
|
||||||
|
private static string DecodeUtf8Bounded(byte[] bytes, int validBytes, bool cutAtValidBytes)
|
||||||
|
{
|
||||||
|
if (validBytes <= 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
var boundary = validBytes;
|
||||||
|
if (cutAtValidBytes)
|
||||||
|
{
|
||||||
|
while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
|
||||||
|
{
|
||||||
|
boundary--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetString(bytes, 0, boundary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
|
||||||
|
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
|
||||||
|
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> if the slot is absent — for a
|
||||||
|
/// correlation feature a silently-divergent id is the worst failure mode, so we
|
||||||
|
/// fail fast rather than mint a fresh one. <see cref="EmitInboundAudit"/>'s
|
||||||
|
/// try/catch degrades the throw to a dropped best-effort audit row, never a
|
||||||
|
/// failed request.
|
||||||
|
/// </summary>
|
||||||
|
private static Guid ResolveInboundExecutionId(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
|
||||||
|
&& stashed is Guid id)
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Inbound ExecutionId invariant violated: the inbound ExecutionId must be "
|
||||||
|
+ "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -263,4 +440,153 @@ public sealed class AuditWriteMiddleware
|
|||||||
|
|
||||||
return path[(lastSlash + 1)..];
|
return path[(lastSlash + 1)..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-only forwarding <see cref="Stream"/> wrapper that mirrors every
|
||||||
|
/// write to the inner ASP.NET <see cref="HttpResponse.Body"/> (so the real
|
||||||
|
/// client receives all bytes) while capturing AT MOST <c>cap + 1</c> bytes
|
||||||
|
/// into a private bounded <see cref="MemoryStream"/> for the audit copy.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The inner sink is owned by the framework and is NOT disposed when this
|
||||||
|
/// wrapper is disposed — we only own the capture <see cref="MemoryStream"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// All Write overloads forward to the inner stream FIRST, then capture the
|
||||||
|
/// remaining quota. If the inner sink throws (e.g. the client disconnects),
|
||||||
|
/// the exception is allowed to propagate — capture is best-effort, the
|
||||||
|
/// real I/O is authoritative. The handler-throws-mid-response test
|
||||||
|
/// (<c>ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow</c>) verifies
|
||||||
|
/// that captured bytes up to the throw are still recoverable.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class CapturedResponseStream : Stream
|
||||||
|
{
|
||||||
|
private readonly Stream _inner;
|
||||||
|
private readonly int _capBytes;
|
||||||
|
private readonly MemoryStream _captured;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public CapturedResponseStream(Stream inner, int capBytes)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
_capBytes = Math.Max(0, capBytes);
|
||||||
|
// Capture up to cap + 1 bytes so we can detect the over-cap case
|
||||||
|
// without growing the buffer further.
|
||||||
|
_captured = new MemoryStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanRead => false;
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
public override bool CanWrite => true;
|
||||||
|
|
||||||
|
public override long Length =>
|
||||||
|
throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
set => throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Flush() => _inner.Flush();
|
||||||
|
|
||||||
|
public override Task FlushAsync(CancellationToken cancellationToken) =>
|
||||||
|
_inner.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) =>
|
||||||
|
throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) =>
|
||||||
|
throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
|
||||||
|
public override void SetLength(long value) =>
|
||||||
|
throw new NotSupportedException("CapturedResponseStream is write-only.");
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
// Forward to the real sink FIRST — the client must never miss
|
||||||
|
// bytes if capture throws.
|
||||||
|
_inner.Write(buffer, offset, count);
|
||||||
|
CaptureBytes(buffer.AsSpan(offset, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(ReadOnlySpan<byte> buffer)
|
||||||
|
{
|
||||||
|
_inner.Write(buffer);
|
||||||
|
CaptureBytes(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task WriteAsync(
|
||||||
|
byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
CaptureBytes(buffer.AsSpan(offset, count));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WriteAsync(
|
||||||
|
ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||||
|
CaptureBytes(buffer.Span);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capture up to <c>cap + 1</c> bytes total into the private
|
||||||
|
/// <see cref="MemoryStream"/>. Once the cap quota is reached, further
|
||||||
|
/// bytes are silently dropped from the audit copy (the real sink has
|
||||||
|
/// already received them upstream of this call).
|
||||||
|
/// </summary>
|
||||||
|
private void CaptureBytes(ReadOnlySpan<byte> span)
|
||||||
|
{
|
||||||
|
if (span.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var quota = (_capBytes + 1) - (int)_captured.Length;
|
||||||
|
if (quota <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var take = Math.Min(quota, span.Length);
|
||||||
|
_captured.Write(span[..take]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the captured response body as a UTF-8 string (byte-safe
|
||||||
|
/// truncated to <c>cap</c> bytes) and a flag indicating whether the
|
||||||
|
/// audit copy hit the cap. Returns <c>(null, false)</c> when no bytes
|
||||||
|
/// were captured, mirroring the request-body empty contract.
|
||||||
|
/// </summary>
|
||||||
|
public (string? body, bool truncated) GetCapturedBody()
|
||||||
|
{
|
||||||
|
var length = (int)_captured.Length;
|
||||||
|
if (length == 0)
|
||||||
|
{
|
||||||
|
return (null, false);
|
||||||
|
}
|
||||||
|
var truncated = length > _capBytes;
|
||||||
|
var bytes = _captured.GetBuffer();
|
||||||
|
var bytesForString = truncated ? _capBytes : length;
|
||||||
|
var content = DecodeUtf8Bounded(bytes, bytesForString, cutAtValidBytes: truncated);
|
||||||
|
return (string.IsNullOrEmpty(content) ? null : content, truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
// Own only the capture stream; the inner sink belongs to
|
||||||
|
// the framework's response pipeline.
|
||||||
|
_captured.Dispose();
|
||||||
|
}
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,22 +19,25 @@ public class RouteHelper
|
|||||||
private readonly IInstanceLocator _instanceLocator;
|
private readonly IInstanceLocator _instanceLocator;
|
||||||
private readonly IInstanceRouter _instanceRouter;
|
private readonly IInstanceRouter _instanceRouter;
|
||||||
private readonly CancellationToken _deadlineToken;
|
private readonly CancellationToken _deadlineToken;
|
||||||
|
private readonly Guid? _parentExecutionId;
|
||||||
|
|
||||||
public RouteHelper(
|
public RouteHelper(
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter)
|
IInstanceRouter instanceRouter)
|
||||||
: this(instanceLocator, instanceRouter, CancellationToken.None)
|
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private RouteHelper(
|
private RouteHelper(
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter,
|
IInstanceRouter instanceRouter,
|
||||||
CancellationToken deadlineToken)
|
CancellationToken deadlineToken,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
_instanceLocator = instanceLocator;
|
_instanceLocator = instanceLocator;
|
||||||
_instanceRouter = instanceRouter;
|
_instanceRouter = instanceRouter;
|
||||||
_deadlineToken = deadlineToken;
|
_deadlineToken = deadlineToken;
|
||||||
|
_parentExecutionId = parentExecutionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,14 +48,27 @@ public class RouteHelper
|
|||||||
/// requires.
|
/// requires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||||
new(_instanceLocator, _instanceRouter, deadlineToken);
|
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
|
||||||
|
/// routed <see cref="RouteTarget.Call"/> requests carry
|
||||||
|
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
|
||||||
|
/// For an inbound API request this is the inbound request's own per-request
|
||||||
|
/// execution id, so the routed site script records the inbound request as its
|
||||||
|
/// parent. <see cref="InboundScriptExecutor"/> calls this when it builds the
|
||||||
|
/// script context.
|
||||||
|
/// </summary>
|
||||||
|
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
|
||||||
|
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a route target for the specified instance.
|
/// Creates a route target for the specified instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RouteTarget To(string instanceCode)
|
public RouteTarget To(string instanceCode)
|
||||||
{
|
{
|
||||||
return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken);
|
return new RouteTarget(
|
||||||
|
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +81,20 @@ public class RouteTarget
|
|||||||
private readonly IInstanceLocator _instanceLocator;
|
private readonly IInstanceLocator _instanceLocator;
|
||||||
private readonly IInstanceRouter _instanceRouter;
|
private readonly IInstanceRouter _instanceRouter;
|
||||||
private readonly CancellationToken _deadlineToken;
|
private readonly CancellationToken _deadlineToken;
|
||||||
|
private readonly Guid? _parentExecutionId;
|
||||||
|
|
||||||
internal RouteTarget(
|
internal RouteTarget(
|
||||||
string instanceCode,
|
string instanceCode,
|
||||||
IInstanceLocator instanceLocator,
|
IInstanceLocator instanceLocator,
|
||||||
IInstanceRouter instanceRouter,
|
IInstanceRouter instanceRouter,
|
||||||
CancellationToken deadlineToken)
|
CancellationToken deadlineToken,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
_instanceCode = instanceCode;
|
_instanceCode = instanceCode;
|
||||||
_instanceLocator = instanceLocator;
|
_instanceLocator = instanceLocator;
|
||||||
_instanceRouter = instanceRouter;
|
_instanceRouter = instanceRouter;
|
||||||
_deadlineToken = deadlineToken;
|
_deadlineToken = deadlineToken;
|
||||||
|
_parentExecutionId = parentExecutionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -96,8 +115,13 @@ public class RouteTarget
|
|||||||
var siteId = await ResolveSiteAsync(token);
|
var siteId = await ResolveSiteAsync(token);
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
// Audit Log #23 (ParentExecutionId): stamp the spawning execution's id
|
||||||
|
// (the inbound API request's ExecutionId) so the routed site script
|
||||||
|
// records this call's parent. CorrelationId above is a separate concern
|
||||||
|
// — the per-operation lifecycle id, freshly minted per routed call.
|
||||||
var request = new RouteToCallRequest(
|
var request = new RouteToCallRequest(
|
||||||
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
|
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
|
||||||
|
DateTimeOffset.UtcNow, _parentExecutionId);
|
||||||
|
|
||||||
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
|
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||||
|
<!-- AuditWriteMiddleware reads AuditLogOptions.InboundMaxBytes to bound
|
||||||
|
per-request request/response audit capture at the source. -->
|
||||||
|
<ProjectReference Include="../ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -395,6 +395,20 @@ public static class AuditEndpoints
|
|||||||
correlationId = parsedCorr;
|
correlationId = parsedCorr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Guid? executionId = null;
|
||||||
|
if (query.TryGetValue("executionId", out var execValues)
|
||||||
|
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
|
||||||
|
{
|
||||||
|
executionId = parsedExec;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? parentExecutionId = null;
|
||||||
|
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
|
||||||
|
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
|
||||||
|
{
|
||||||
|
parentExecutionId = parsedParentExec;
|
||||||
|
}
|
||||||
|
|
||||||
return new AuditLogQueryFilter(
|
return new AuditLogQueryFilter(
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Kinds: kinds,
|
Kinds: kinds,
|
||||||
@@ -403,6 +417,8 @@ public static class AuditEndpoints
|
|||||||
Target: TrimToNullable(query, "target"),
|
Target: TrimToNullable(query, "target"),
|
||||||
Actor: TrimToNullable(query, "actor"),
|
Actor: TrimToNullable(query, "actor"),
|
||||||
CorrelationId: correlationId,
|
CorrelationId: correlationId,
|
||||||
|
ExecutionId: executionId,
|
||||||
|
ParentExecutionId: parentExecutionId,
|
||||||
FromUtc: ParseUtcDate(query, "fromUtc"),
|
FromUtc: ParseUtcDate(query, "fromUtc"),
|
||||||
ToUtc: ParseUtcDate(query, "toUtc"));
|
ToUtc: ParseUtcDate(query, "toUtc"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
private const int FallbackMaxRetries = 10;
|
private const int FallbackMaxRetries = 10;
|
||||||
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan FallbackRetryDelay = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit <c>Actor</c> stamped on central-dispatch (<c>NotifyDeliver</c>) rows.
|
||||||
|
/// The Actor-column spec assigns central-originated audit rows a system
|
||||||
|
/// identity — there is no per-call authenticated user at dispatch time.
|
||||||
|
/// </summary>
|
||||||
|
private const string SystemActor = "system";
|
||||||
|
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly NotificationOutboxOptions _options;
|
private readonly NotificationOutboxOptions _options;
|
||||||
private readonly ICentralAuditWriter _auditWriter;
|
private readonly ICentralAuditWriter _auditWriter;
|
||||||
@@ -482,6 +489,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
/// parses the notification's id as a Guid; sites generate the id with
|
/// parses the notification's id as a Guid; sites generate the id with
|
||||||
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
|
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
|
||||||
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
|
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
|
||||||
|
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
|
||||||
|
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
|
||||||
|
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
|
||||||
|
/// <c>NotifySend</c> row (Audit Log #23); <see cref="AuditEvent.ParentExecutionId"/>
|
||||||
|
/// is likewise copied from <see cref="Notification.OriginParentExecutionId"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static AuditEvent BuildNotifyDeliverEvent(
|
private static AuditEvent BuildNotifyDeliverEvent(
|
||||||
Notification notification,
|
Notification notification,
|
||||||
@@ -500,12 +512,25 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
Channel = AuditChannel.Notification,
|
Channel = AuditChannel.Notification,
|
||||||
Kind = AuditKind.NotifyDeliver,
|
Kind = AuditKind.NotifyDeliver,
|
||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
// Central dispatch — no authenticated actor (the originating
|
// Central dispatch — a system identity per the Actor-column spec;
|
||||||
// script's identity is captured on the upstream NotifySend row).
|
// there is no per-call authenticated user here. The originating
|
||||||
Actor = null,
|
// script is still captured on SourceScript (and on the upstream
|
||||||
|
// NotifySend row).
|
||||||
|
Actor = SystemActor,
|
||||||
SourceSiteId = notification.SourceSiteId,
|
SourceSiteId = notification.SourceSiteId,
|
||||||
SourceInstanceId = notification.SourceInstanceId,
|
SourceInstanceId = notification.SourceInstanceId,
|
||||||
SourceScript = notification.SourceScript,
|
SourceScript = notification.SourceScript,
|
||||||
|
// ExecutionId (Audit Log #23): the originating script execution's id,
|
||||||
|
// carried from the site on NotificationSubmit and persisted on the
|
||||||
|
// Notification row. Echoing it here links the central NotifyDeliver
|
||||||
|
// rows to the site-emitted NotifySend row for the same run. Null when
|
||||||
|
// the notification was raised outside a script execution.
|
||||||
|
ExecutionId = notification.OriginExecutionId,
|
||||||
|
// ParentExecutionId (Audit Log #23): the originating routed run's
|
||||||
|
// parent ExecutionId, carried from the site on NotificationSubmit and
|
||||||
|
// persisted on the Notification row. Echoing it here links the central
|
||||||
|
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
|
||||||
|
ParentExecutionId = notification.OriginParentExecutionId,
|
||||||
Target = notification.ListName,
|
Target = notification.ListName,
|
||||||
Status = status,
|
Status = status,
|
||||||
ErrorMessage = errorMessage,
|
ErrorMessage = errorMessage,
|
||||||
@@ -932,6 +957,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
SourceInstanceId = msg.SourceInstanceId,
|
SourceInstanceId = msg.SourceInstanceId,
|
||||||
SourceScript = msg.SourceScript,
|
SourceScript = msg.SourceScript,
|
||||||
|
// OriginExecutionId (Audit Log #23): the originating script execution's id,
|
||||||
|
// carried from the site so the dispatcher can echo it onto NotifyDeliver rows.
|
||||||
|
OriginExecutionId = msg.OriginExecutionId,
|
||||||
|
// OriginParentExecutionId (Audit Log #23): the originating routed run's parent
|
||||||
|
// ExecutionId, carried from the site so the dispatcher can echo it onto
|
||||||
|
// NotifyDeliver rows.
|
||||||
|
OriginParentExecutionId = msg.OriginParentExecutionId,
|
||||||
SiteEnqueuedAt = msg.SiteEnqueuedAt,
|
SiteEnqueuedAt = msg.SiteEnqueuedAt,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
// Status stays at its Pending default for the dispatch sweep to claim.
|
// Status stays at its Pending default for the dispatch sweep to claim.
|
||||||
|
|||||||
@@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
|
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
|
||||||
{
|
{
|
||||||
// Convert to ScriptCallRequest and Ask the Instance Actor
|
// Convert to ScriptCallRequest and Ask the Instance Actor.
|
||||||
|
// Audit Log #23 (ParentExecutionId): carry the inbound request's
|
||||||
|
// ExecutionId down as ParentExecutionId so the routed script
|
||||||
|
// execution can record its spawner.
|
||||||
var scriptCall = new ScriptCallRequest(
|
var scriptCall = new ScriptCallRequest(
|
||||||
request.ScriptName, request.Parameters, 0, request.CorrelationId);
|
request.ScriptName, request.Parameters, 0, request.CorrelationId,
|
||||||
|
ParentExecutionId: request.ParentExecutionId);
|
||||||
var sender = Sender;
|
var sender = Sender;
|
||||||
instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
|
instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
|
||||||
.ContinueWith(t =>
|
.ContinueWith(t =>
|
||||||
|
|||||||
@@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
|
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
|
||||||
{
|
{
|
||||||
// Forward the request to the Script Actor, preserving the original sender
|
// Forward the request to the Script Actor, preserving the original
|
||||||
|
// sender. The whole record is forwarded unchanged, so any
|
||||||
|
// ParentExecutionId (Audit Log #23) set by an inbound-API-routed
|
||||||
|
// call is carried through to the Script Actor verbatim.
|
||||||
scriptActor.Forward(request);
|
scriptActor.Forward(request);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId);
|
// Audit Log #23 (ParentExecutionId): carry any inbound-routed
|
||||||
|
// ParentExecutionId through to the ScriptExecutionActor so the routed
|
||||||
|
// script's ScriptRuntimeContext can record its spawner. Null for normal
|
||||||
|
// (tag-change / timer) runs and nested Script.Call invocations.
|
||||||
|
SpawnExecution(
|
||||||
|
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
|
||||||
|
request.ParentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
IReadOnlyDictionary<string, object?>? parameters,
|
IReadOnlyDictionary<string, object?>? parameters,
|
||||||
int callDepth,
|
int callDepth,
|
||||||
IActorRef replyTo,
|
IActorRef replyTo,
|
||||||
string correlationId)
|
string correlationId,
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
|
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
|
||||||
|
|
||||||
@@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
_logger,
|
_logger,
|
||||||
_scope,
|
_scope,
|
||||||
_healthCollector,
|
_healthCollector,
|
||||||
_serviceProvider));
|
_serviceProvider,
|
||||||
|
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
|
||||||
|
// an inbound-API-routed call supplies the inbound request's id.
|
||||||
|
parentExecutionId));
|
||||||
|
|
||||||
Context.ActorOf(props, executionId);
|
Context.ActorOf(props, executionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
ILogger logger,
|
ILogger logger,
|
||||||
Commons.Types.Scripts.ScriptScope scope,
|
Commons.Types.Scripts.ScriptScope scope,
|
||||||
ISiteHealthCollector? healthCollector = null,
|
ISiteHealthCollector? healthCollector = null,
|
||||||
IServiceProvider? serviceProvider = null)
|
IServiceProvider? serviceProvider = null,
|
||||||
|
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||||
|
// ExecutionId for an inbound-API-routed call. Null for normal
|
||||||
|
// (tag-change / timer) runs and nested Script.Call invocations.
|
||||||
|
Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
// Immediately begin execution
|
// Immediately begin execution
|
||||||
var self = Self;
|
var self = Self;
|
||||||
@@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
ExecuteScript(
|
ExecuteScript(
|
||||||
scriptName, instanceName, compiledScript, parameters, callDepth,
|
scriptName, instanceName, compiledScript, parameters, callDepth,
|
||||||
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
||||||
self, parent, logger, scope, healthCollector, serviceProvider);
|
self, parent, logger, scope, healthCollector, serviceProvider,
|
||||||
|
parentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ExecuteScript(
|
private static void ExecuteScript(
|
||||||
@@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
ILogger logger,
|
ILogger logger,
|
||||||
Commons.Types.Scripts.ScriptScope scope,
|
Commons.Types.Scripts.ScriptScope scope,
|
||||||
ISiteHealthCollector? healthCollector,
|
ISiteHealthCollector? healthCollector,
|
||||||
IServiceProvider? serviceProvider)
|
IServiceProvider? serviceProvider,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
||||||
|
|
||||||
@@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
// emission. Best-effort: null degrades the helpers to a
|
// emission. Best-effort: null degrades the helpers to a
|
||||||
// no-emission path; the S&F handoff and TrackedOperationId
|
// no-emission path; the S&F handoff and TrackedOperationId
|
||||||
// return are unaffected.
|
// return are unaffected.
|
||||||
cachedForwarder: cachedForwarder);
|
cachedForwarder: cachedForwarder,
|
||||||
|
// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||||
|
// id for an inbound-API-routed call. The routed script still
|
||||||
|
// mints its own fresh ExecutionId — this records the spawner.
|
||||||
|
// Null for normal (tag-change / timer) runs.
|
||||||
|
parentExecutionId: parentExecutionId);
|
||||||
|
|
||||||
var globals = new ScriptGlobals
|
var globals = new ScriptGlobals
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user