Compare commits
166 Commits
feature/au
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6731845473 | ||
|
|
f6cd097c62 | ||
|
|
f6f7cb8b36 | ||
|
|
1361a39770 | ||
|
|
438f59e74e | ||
|
|
901fd58a32 | ||
|
|
f1c3019eca | ||
|
|
ae4169b4cc | ||
|
|
bae75be2d2 | ||
|
|
6bdada7549 | ||
|
|
6299743a35 | ||
|
|
f3a571b664 | ||
|
|
80497d1332 | ||
|
|
77eb188a2c | ||
|
|
4951e6f81b | ||
|
|
f63d379048 | ||
|
|
0ee914e36c | ||
|
|
9d5b814f9b | ||
|
|
4316aacd44 | ||
|
|
c252a80f9d | ||
|
|
63d1a96557 | ||
|
|
9c6abc6517 | ||
|
|
4b797c9f69 | ||
|
|
e66fee0d26 | ||
|
|
2fd3426fc2 | ||
|
|
a7141c704f | ||
|
|
624cf255a4 | ||
|
|
8e73e60f4a | ||
|
|
cef77e1378 | ||
|
|
79d74ee59c | ||
|
|
e6706c26e6 | ||
|
|
a2b8b69281 | ||
|
|
9f1bb81993 | ||
|
|
623aa8d061 | ||
|
|
ef025a325d | ||
|
|
39f994f9bc | ||
|
|
acadb83712 | ||
|
|
0dbc0c02f9 | ||
|
|
01f4eeaef5 | ||
|
|
e099ed2038 | ||
|
|
9a3f5231db | ||
|
|
cda80cf821 | ||
|
|
2c34f12a6f | ||
|
|
90baa4d6d5 | ||
|
|
b1daf9abb8 | ||
|
|
268a847ef3 | ||
|
|
2400249453 | ||
|
|
5fc6790c36 | ||
|
|
7c70ce0dbf | ||
|
|
901d9affdf | ||
|
|
06c2b20178 | ||
|
|
550ab0e034 | ||
|
|
ee76b84b0f | ||
|
|
447bf84b13 | ||
|
|
dc669a119b | ||
|
|
c5bd5418ad | ||
|
|
7e51274812 | ||
|
|
f32b59a557 | ||
|
|
233e0f996e | ||
|
|
33f7b3979d | ||
|
|
ee10eba04c | ||
|
|
9442c9a92c | ||
|
|
7e89f2092f | ||
|
|
1bc98e10a1 | ||
|
|
1b02f33829 | ||
|
|
d630e2646b | ||
|
|
f973f49254 | ||
|
|
e66b01a849 | ||
|
|
c754666a3d | ||
|
|
8bf84fb7f3 | ||
|
|
d18a6e6fa0 | ||
|
|
b9c017136d | ||
|
|
bb29d65a94 | ||
|
|
466e1454fe | ||
|
|
06ed0acead | ||
|
|
d1fcab490c | ||
|
|
e6341580b3 | ||
|
|
974a36826a | ||
|
|
479870e40c | ||
|
|
277882d230 | ||
|
|
f3cb8c0791 | ||
|
|
8fb9eb0ce7 | ||
|
|
1a77bc5f38 | ||
|
|
16b685b96b | ||
|
|
552d7832a3 | ||
|
|
dfaa416ebe | ||
|
|
990eb02fe0 | ||
|
|
354f8792bf | ||
|
|
ad625eb36d | ||
|
|
2e10cbe42d | ||
|
|
9e5e32d0f2 | ||
|
|
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 | ||
|
|
e36f0bf9c8 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,3 +36,7 @@ data/
|
|||||||
# Claude Code local files
|
# Claude Code local files
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/scheduled_tasks.lock
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Docker env2 runtime data
|
||||||
|
docker-env2/*/logs/
|
||||||
|
docker-env2/*/data/
|
||||||
|
|||||||
30
CLAUDE.md
30
CLAUDE.md
@@ -1,17 +1,22 @@
|
|||||||
# ScadaLink Design Documentation Project
|
# ScadaLink Implementation Project
|
||||||
|
|
||||||
This project contains design documentation for a distributed SCADA system built on Akka.NET. The documents describe a hub-and-spoke architecture with a central cluster and multiple site clusters.
|
This is the full **implementation** project for ScadaLink — a distributed SCADA system built on Akka.NET in a hub-and-spoke architecture (one central cluster + multiple site clusters). It contains source code, tests, deployable docker topology, and the design documentation that the code implements. The design docs are the spec; `src/` is the binary.
|
||||||
|
|
||||||
|
When a change is requested, the default assumption is: update the design doc *and* the code *and* the tests *and* (if it ships) the docker deploy — together, in one session, with `git diff` review before committing.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
- `src/` — C#/.NET implementation, one project per component (e.g. `ScadaLink.AuditLog`, `ScadaLink.NotificationOutbox`, `ScadaLink.SiteCallAudit`, `ScadaLink.CentralUI`, `ScadaLink.Host`, …). Solution file: `ScadaLink.slnx`.
|
||||||
|
- `tests/` — Test projects (unit + integration).
|
||||||
|
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
|
||||||
|
- `docker-env2/` — Minimal second cluster topology (2 central + 1 site × 2 nodes), runs concurrently with `docker/` on host ports 91XX. Built specifically for testing the Transport (#24) feature with two real environments. See [`docker-env2/README.md`](docker-env2/README.md). Rebuild + redeploy with `bash docker-env2/deploy.sh`.
|
||||||
|
- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik).
|
||||||
- `README.md` — Master index with component table and architecture diagrams.
|
- `README.md` — Master index with component table and architecture diagrams.
|
||||||
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
|
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
|
||||||
- `docs/requirements/Component-*.md` — Individual component design documents (one per component).
|
- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements.
|
||||||
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik).
|
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik).
|
||||||
- `docs/plans/` — Design decision documents from refinement sessions.
|
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
|
||||||
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
|
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
|
||||||
- `infra/` — Docker Compose and config files for local test services.
|
|
||||||
- `docker/` — Docker infrastructure for the 8-node cluster topology (2 central + 3 sites). See [`docker/README.md`](docker/README.md) for cluster setup, port allocation, and management commands.
|
|
||||||
|
|
||||||
## Document Conventions
|
## Document Conventions
|
||||||
|
|
||||||
@@ -31,12 +36,13 @@ This project contains design documentation for a distributed SCADA system built
|
|||||||
|
|
||||||
## Editing Rules
|
## Editing Rules
|
||||||
|
|
||||||
- Edit documents in place. Do not create copies or backup files.
|
- Edit documents and code in place. Do not create copies or backup files.
|
||||||
- When a change affects multiple documents, update all affected documents in the same session.
|
- When a change affects multiple documents or projects, update them all in the same session — design doc, entities/repos, actors/services, UI, tests, migrations, and deploy config travel together.
|
||||||
- Use `git diff` to review changes before committing.
|
- Use `git diff` to review changes before committing.
|
||||||
- Commit related changes together with a descriptive message summarizing the design decision.
|
- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice.
|
||||||
|
- After non-trivial code changes, build (`dotnet build ScadaLink.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`.
|
||||||
|
|
||||||
## Current Component List (23 components)
|
## Current Component List (24 components)
|
||||||
|
|
||||||
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
|
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
|
||||||
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
|
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
|
||||||
@@ -61,6 +67,7 @@ This project contains design documentation for a distributed SCADA system built
|
|||||||
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
|
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
|
||||||
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
|
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
|
||||||
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
|
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
|
||||||
|
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration. Per-conflict resolution. Correlated audit via `BundleImportId`. No site involvement.
|
||||||
|
|
||||||
## Key Design Decisions (for context across sessions)
|
## Key Design Decisions (for context across sessions)
|
||||||
|
|
||||||
@@ -132,12 +139,15 @@ 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.
|
||||||
- Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative.
|
- Audit-write failure NEVER aborts the user-facing action — audit is best-effort, the action's own success/failure path is authoritative.
|
||||||
- 365-day central retention with monthly partition-switch purge; 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled).
|
- 365-day central retention with monthly partition-switch purge; 7-day site SQLite retention with a hard `ForwardState` invariant (no row purged until forwarded or reconciled).
|
||||||
- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence and Parquet archival are deferred to v1.x.
|
- Append-only enforced via DB roles (writer role has INSERT only, no UPDATE/DELETE); hash-chain tamper evidence and Parquet archival are deferred to v1.x.
|
||||||
|
- Node-of-origin is captured alongside site-of-origin: `SourceNode` (`varchar(64)` NULL) on `AuditLog`, `Notifications`, and `SiteCalls` — `node-a`/`node-b` for site rows (qualified by `SourceSiteId`/`SourceSite`), `central-a`/`central-b` for central direct-write rows. Stamped at the writing node, carried verbatim through telemetry + reconciliation, and indexed via `IX_AuditLog_Node_Occurred (SourceNode, OccurredAtUtc)` on `AuditLog`.
|
||||||
- Central UI: new top-level **Audit** nav group + Audit Log page, with drill-ins from Notifications, Site Calls, External Systems, Inbound API Keys, Sites, and Instances.
|
- Central UI: new top-level **Audit** nav group + Audit Log page, with drill-ins from Notifications, Site Calls, External Systems, Inbound API Keys, Sites, and Instances.
|
||||||
|
|
||||||
### Security & Auth
|
### Security & Auth
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -25,6 +25,15 @@ This document serves as the master index for the SCADA system design. The system
|
|||||||
- Central cluster: 2-node active/standby behind a load balancer.
|
- Central cluster: 2-node active/standby behind a load balancer.
|
||||||
- Site clusters: 2-node active/standby, headless (no UI).
|
- Site clusters: 2-node active/standby, headless (no UI).
|
||||||
|
|
||||||
|
## Local Test Environments
|
||||||
|
|
||||||
|
Two Docker-based cluster topologies are available for local development and testing:
|
||||||
|
|
||||||
|
- **Primary** ([`docker/`](docker/)) — Full topology (2 central + 3 sites × 2 nodes + Traefik). Default development target.
|
||||||
|
- **Env2** ([`docker-env2/`](docker-env2/)) — Minimal sibling stack (2 central + 1 site × 2 nodes + Traefik), runs concurrently with primary on host ports 91XX. Purpose: exercise the Transport (#24) bundle export/import feature against a real second environment.
|
||||||
|
|
||||||
|
Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDAP, SMTP, OPC UA, REST API).
|
||||||
|
|
||||||
## Document Map
|
## Document Map
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
@@ -57,6 +66,7 @@ This document serves as the master index for the SCADA system design. The system
|
|||||||
| 21 | Notification Outbox | [docs/requirements/Component-NotificationOutbox.md](docs/requirements/Component-NotificationOutbox.md) | Central component ingesting store-and-forwarded notifications into the `Notifications` audit table, with `NotificationOutboxActor` singleton dispatcher, per-type delivery adapters, retry/parking, status tracking, daily purge, and delivery KPIs. |
|
| 21 | Notification Outbox | [docs/requirements/Component-NotificationOutbox.md](docs/requirements/Component-NotificationOutbox.md) | Central component ingesting store-and-forwarded notifications into the `Notifications` audit table, with `NotificationOutboxActor` singleton dispatcher, per-type delivery adapters, retry/parking, status tracking, daily purge, and delivery KPIs. |
|
||||||
| 22 | Site Call Audit | [docs/requirements/Component-SiteCallAudit.md](docs/requirements/Component-SiteCallAudit.md) | Central component auditing site cached calls (`ExternalSystem.CachedCall`/`Database.CachedWrite`) into the `SiteCalls` audit table, with `SiteCallAuditActor` singleton, telemetry ingest, periodic reconciliation, point-in-time KPIs, daily purge, and central→site Retry/Discard relay for parked calls. |
|
| 22 | Site Call Audit | [docs/requirements/Component-SiteCallAudit.md](docs/requirements/Component-SiteCallAudit.md) | Central component auditing site cached calls (`ExternalSystem.CachedCall`/`Database.CachedWrite`) into the `SiteCalls` audit table, with `SiteCallAuditActor` singleton, telemetry ingest, periodic reconciliation, point-in-time KPIs, daily purge, and central→site Retry/Discard relay for parked calls. |
|
||||||
| 23 | Audit Log | [docs/requirements/Component-AuditLog.md](docs/requirements/Component-AuditLog.md) | New central append-only AuditLog spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site-local SQLite hot-path append + gRPC telemetry + central reconciliation; combined telemetry packet with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API middleware; monthly partitioning, 365-day default retention. |
|
| 23 | Audit Log | [docs/requirements/Component-AuditLog.md](docs/requirements/Component-AuditLog.md) | New central append-only AuditLog spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site-local SQLite hot-path append + gRPC telemetry + central reconciliation; combined telemetry packet with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API middleware; monthly partitioning, 365-day default retention. |
|
||||||
|
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
|
||||||
|
|
||||||
### Reference Documentation
|
### Reference Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<Project Path="src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
<Project Path="src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||||
<Project Path="src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
<Project Path="src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj" />
|
||||||
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
||||||
|
<Project Path="src/ScadaLink.Transport/ScadaLink.Transport.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj" />
|
<Project Path="tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj" />
|
||||||
@@ -48,5 +49,7 @@
|
|||||||
<Project Path="tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj" />
|
<Project Path="tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj" />
|
||||||
<Project Path="tests/ScadaLink.PerformanceTests/ScadaLink.PerformanceTests.csproj" />
|
<Project Path="tests/ScadaLink.PerformanceTests/ScadaLink.PerformanceTests.csproj" />
|
||||||
<Project Path="tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj" />
|
<Project Path="tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj" />
|
||||||
|
<Project Path="tests/ScadaLink.Transport.Tests/ScadaLink.Transport.Tests.csproj" />
|
||||||
|
<Project Path="tests/ScadaLink.Transport.IntegrationTests/ScadaLink.Transport.IntegrationTests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
140
docker-env2/README.md
Normal file
140
docker-env2/README.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# ScadaLink Env2 Docker Infrastructure
|
||||||
|
|
||||||
|
A second Docker deployment of a minimal ScadaLink cluster topology, designed to run **concurrently with** the primary `docker/` stack so the Transport (#24) feature can be exercised end-to-end across two real environments.
|
||||||
|
|
||||||
|
See [`docs/plans/2026-05-24-second-environment-design.md`](../docs/plans/2026-05-24-second-environment-design.md) for the design rationale.
|
||||||
|
|
||||||
|
## Cluster Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────┐
|
||||||
|
│ Traefik LB :9100 │ ◄── CLI / Browser
|
||||||
|
│ Dashboard :8181 │
|
||||||
|
└────────┬──────────┘
|
||||||
|
│ routes to active node
|
||||||
|
┌──────────────────────┼──────────────────────────────┐
|
||||||
|
│ Env2 Central Cluster │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ env2-central-a │◄──►│ env2-central-b │ │
|
||||||
|
│ │ Web UI :9101 │ │ Web UI :9102 │ │
|
||||||
|
│ │ Akka :9111 │ │ Akka :9112 │ │
|
||||||
|
│ └────────┬─────────┘ └─────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└───────────┼─────────────────────────────────────────┘
|
||||||
|
│ Akka.NET Remoting
|
||||||
|
▼
|
||||||
|
┌────────────────────┐
|
||||||
|
│ Env2 Site-X │
|
||||||
|
│ (Env2 Site X) │
|
||||||
|
│ │
|
||||||
|
│ node-a ◄──► node-b│
|
||||||
|
│ Akka :9121 :9122 │
|
||||||
|
│ gRPC :9123 :9124 │
|
||||||
|
└────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port Allocation
|
||||||
|
|
||||||
|
Env2 host ports are the primary's ports + 100. Both stacks can run simultaneously.
|
||||||
|
|
||||||
|
| Node | Container Name | Host Web | Host Akka | Host gRPC | Internal |
|
||||||
|
|------|----------------|----------|-----------|-----------|----------|
|
||||||
|
| Traefik LB | `scadalink-env2-traefik` | 9100 | — | — | 80 (proxy), 8080 (dashboard host:8181) |
|
||||||
|
| Central A | `scadalink-env2-central-a` | 9101 | 9111 | — | 5000 (web), 8081 (Akka) |
|
||||||
|
| Central B | `scadalink-env2-central-b` | 9102 | 9112 | — | 5000 (web), 8081 (Akka) |
|
||||||
|
| Site-X A | `scadalink-env2-site-x-a` | — | 9121 | 9123 | 8082 (Akka), 8083 (gRPC) |
|
||||||
|
| Site-X B | `scadalink-env2-site-x-b` | — | 9122 | 9124 | 8082 (Akka), 8083 (gRPC) |
|
||||||
|
|
||||||
|
## Shared Infrastructure
|
||||||
|
|
||||||
|
Env2 attaches to the existing `scadalink-net` Docker bridge network and reuses these primary infra containers:
|
||||||
|
|
||||||
|
| Service | Container | What env2 uses it for |
|
||||||
|
|---------|-----------|-----------------------|
|
||||||
|
| MS SQL | `scadalink-mssql` | Env2-specific databases `ScadaLinkConfig2` / `ScadaLinkMachineData2` |
|
||||||
|
| LDAP | `scadalink-ldap` | Authentication (same test users) |
|
||||||
|
| SMTP | `scadalink-smtp` | Notification capture in Mailpit (env2 emails distinguishable by `FromAddress`) |
|
||||||
|
| OPC UA | `scadalink-opcua` | Simulated tags for site-x data connections |
|
||||||
|
| REST API | `scadalink-restapi` | External REST API testing |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### First-Time Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Make sure primary infra is up (creates scadalink-net, scadalink-mssql, etc.)
|
||||||
|
cd infra && docker compose up -d && cd ..
|
||||||
|
|
||||||
|
# 2. Build image + create env2 databases + deploy env2 containers
|
||||||
|
bash docker-env2/deploy.sh
|
||||||
|
|
||||||
|
# 3. Seed env2's single test site (first-time only)
|
||||||
|
bash docker-env2/seed-sites.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Code Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker-env2/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker build is shared with the primary stack — if you've just run `docker/deploy.sh`, the env2 build hits a fully cached image.
|
||||||
|
|
||||||
|
### Running Both Stacks Concurrently
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker/deploy.sh # primary
|
||||||
|
bash docker-env2/deploy.sh # env2
|
||||||
|
```
|
||||||
|
|
||||||
|
Both UIs are now reachable:
|
||||||
|
- Primary: http://localhost:9000
|
||||||
|
- Env2: http://localhost:9100
|
||||||
|
|
||||||
|
### Teardown
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker-env2/teardown.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Containers stop, volumes (data + logs) preserved. To also drop the env2 databases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
-Q "DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/ScadaLink.CLI -- \
|
||||||
|
--url http://localhost:9100 \
|
||||||
|
--username multi-role --password password \
|
||||||
|
template list
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-env2/docker-compose.yml logs -f
|
||||||
|
docker logs -f scadalink-env2-central-a
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Users
|
||||||
|
|
||||||
|
Same as primary (env2 shares LDAP). See `infra/glauth/config.toml` and primary `docker/README.md`.
|
||||||
|
|
||||||
|
## Transport Testing Workflow
|
||||||
|
|
||||||
|
See [`docs/plans/2026-05-24-second-environment-verification.md`](../docs/plans/2026-05-24-second-environment-verification.md) for the manual golden-path checklist.
|
||||||
|
|
||||||
|
## What's Different from Primary
|
||||||
|
|
||||||
|
- Single site (`site-x`) instead of three (`site-a/b/c`).
|
||||||
|
- Host port range 91XX vs primary 90XX.
|
||||||
|
- Container names prefixed `scadalink-env2-`.
|
||||||
|
- Databases `ScadaLinkConfig2` / `ScadaLinkMachineData2` on the **shared** `scadalink-mssql`.
|
||||||
|
- `Transport.SourceEnvironment` = `"docker-cluster-env2"` (stamped into exported bundle manifests).
|
||||||
|
- Distinct `Security.JwtSigningKey` (sessions cannot cross envs).
|
||||||
68
docker-env2/central-node-a/appsettings.Central.json
Normal file
68
docker-env2/central-node-a/appsettings.Central.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"ScadaLink": {
|
||||||
|
"Node": {
|
||||||
|
"Role": "Central",
|
||||||
|
"NodeName": "central-a",
|
||||||
|
"NodeHostname": "scadalink-env2-central-a",
|
||||||
|
"RemotingPort": 8081
|
||||||
|
},
|
||||||
|
"Cluster": {
|
||||||
|
"SeedNodes": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-b:8081"
|
||||||
|
],
|
||||||
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
|
"StableAfter": "00:00:15",
|
||||||
|
"HeartbeatInterval": "00:00:02",
|
||||||
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
"MinNrOfMembers": 1
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"ConfigurationDb": "Server=scadalink-mssql,1433;Database=ScadaLinkConfig2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
|
||||||
|
"MachineDataDb": "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"LdapServer": "scadalink-ldap",
|
||||||
|
"LdapPort": 3893,
|
||||||
|
"LdapUseTls": false,
|
||||||
|
"AllowInsecureLdap": true,
|
||||||
|
"LdapSearchBase": "dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountPassword": "password",
|
||||||
|
"JwtSigningKey": "scadalink-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||||
|
"JwtExpiryMinutes": 15,
|
||||||
|
"IdleTimeoutMinutes": 30,
|
||||||
|
"RequireHttpsCookie": false
|
||||||
|
},
|
||||||
|
"Communication": {
|
||||||
|
"DeploymentTimeout": "00:02:00",
|
||||||
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
"QueryTimeout": "00:00:30",
|
||||||
|
"TransportHeartbeatInterval": "00:00:05",
|
||||||
|
"TransportFailureThreshold": "00:00:15"
|
||||||
|
},
|
||||||
|
"HealthMonitoring": {
|
||||||
|
"ReportInterval": "00:00:30",
|
||||||
|
"OfflineTimeout": "00:01:00"
|
||||||
|
},
|
||||||
|
"InboundApi": {
|
||||||
|
"DefaultMethodTimeout": "00:00:30"
|
||||||
|
},
|
||||||
|
"Notification": {
|
||||||
|
"SmtpServer": "scadalink-smtp",
|
||||||
|
"SmtpPort": 1025,
|
||||||
|
"AuthMode": "None",
|
||||||
|
"FromAddress": "scada-notifications-env2@company.com"
|
||||||
|
},
|
||||||
|
"NotificationOutbox": {
|
||||||
|
"DispatchInterval": "00:00:05",
|
||||||
|
"DispatchBatchSize": 1000
|
||||||
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster-env2"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"MinimumLevel": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
docker-env2/central-node-b/appsettings.Central.json
Normal file
68
docker-env2/central-node-b/appsettings.Central.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"ScadaLink": {
|
||||||
|
"Node": {
|
||||||
|
"Role": "Central",
|
||||||
|
"NodeName": "central-b",
|
||||||
|
"NodeHostname": "scadalink-env2-central-b",
|
||||||
|
"RemotingPort": 8081
|
||||||
|
},
|
||||||
|
"Cluster": {
|
||||||
|
"SeedNodes": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-b:8081"
|
||||||
|
],
|
||||||
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
|
"StableAfter": "00:00:15",
|
||||||
|
"HeartbeatInterval": "00:00:02",
|
||||||
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
"MinNrOfMembers": 1
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"ConfigurationDb": "Server=scadalink-mssql,1433;Database=ScadaLinkConfig2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
|
||||||
|
"MachineDataDb": "Server=scadalink-mssql,1433;Database=ScadaLinkMachineData2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"LdapServer": "scadalink-ldap",
|
||||||
|
"LdapPort": 3893,
|
||||||
|
"LdapUseTls": false,
|
||||||
|
"AllowInsecureLdap": true,
|
||||||
|
"LdapSearchBase": "dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountPassword": "password",
|
||||||
|
"JwtSigningKey": "scadalink-env2-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||||
|
"JwtExpiryMinutes": 15,
|
||||||
|
"IdleTimeoutMinutes": 30,
|
||||||
|
"RequireHttpsCookie": false
|
||||||
|
},
|
||||||
|
"Communication": {
|
||||||
|
"DeploymentTimeout": "00:02:00",
|
||||||
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
"QueryTimeout": "00:00:30",
|
||||||
|
"TransportHeartbeatInterval": "00:00:05",
|
||||||
|
"TransportFailureThreshold": "00:00:15"
|
||||||
|
},
|
||||||
|
"HealthMonitoring": {
|
||||||
|
"ReportInterval": "00:00:30",
|
||||||
|
"OfflineTimeout": "00:01:00"
|
||||||
|
},
|
||||||
|
"InboundApi": {
|
||||||
|
"DefaultMethodTimeout": "00:00:30"
|
||||||
|
},
|
||||||
|
"Notification": {
|
||||||
|
"SmtpServer": "scadalink-smtp",
|
||||||
|
"SmtpPort": 1025,
|
||||||
|
"AuthMode": "None",
|
||||||
|
"FromAddress": "scada-notifications-env2@company.com"
|
||||||
|
},
|
||||||
|
"NotificationOutbox": {
|
||||||
|
"DispatchInterval": "00:00:05",
|
||||||
|
"DispatchBatchSize": 1000
|
||||||
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster-env2"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"MinimumLevel": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
docker-env2/deploy.sh
Executable file
34
docker-env2/deploy.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== ScadaLink Env2 Docker Deploy ==="
|
||||||
|
|
||||||
|
# Reuse the primary build (same scadalink:latest image, same network creation)
|
||||||
|
"$SCRIPT_DIR/../docker/build.sh"
|
||||||
|
|
||||||
|
# Ensure env2 databases exist on the shared scadalink-mssql
|
||||||
|
"$SCRIPT_DIR/init-db.sh"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Deploying env2 containers..."
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Container status:"
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Access points:"
|
||||||
|
echo " Central (Traefik LB): http://localhost:9100"
|
||||||
|
echo " Central UI (node A): http://localhost:9101"
|
||||||
|
echo " Central UI (node B): http://localhost:9102"
|
||||||
|
echo " Health check: http://localhost:9101/health/ready"
|
||||||
|
echo " Active node check: http://localhost:9101/health/active"
|
||||||
|
echo " Traefik dashboard: http://localhost:8181"
|
||||||
|
echo ""
|
||||||
|
echo "To seed test site (first-time setup):"
|
||||||
|
echo " docker-env2/seed-sites.sh"
|
||||||
|
echo ""
|
||||||
|
echo "Logs: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f"
|
||||||
83
docker-env2/docker-compose.yml
Normal file
83
docker-env2/docker-compose.yml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
services:
|
||||||
|
central-a:
|
||||||
|
image: scadalink:latest
|
||||||
|
container_name: scadalink-env2-central-a
|
||||||
|
environment:
|
||||||
|
SCADALINK_CONFIG: Central
|
||||||
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
|
ASPNETCORE_URLS: "http://+:5000"
|
||||||
|
ports:
|
||||||
|
- "9101:5000" # Web UI + Inbound API
|
||||||
|
- "9111:8081" # Akka remoting
|
||||||
|
volumes:
|
||||||
|
- ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro
|
||||||
|
- ./central-node-a/logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
central-b:
|
||||||
|
image: scadalink:latest
|
||||||
|
container_name: scadalink-env2-central-b
|
||||||
|
environment:
|
||||||
|
SCADALINK_CONFIG: Central
|
||||||
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
|
ASPNETCORE_URLS: "http://+:5000"
|
||||||
|
ports:
|
||||||
|
- "9102:5000" # Web UI + Inbound API
|
||||||
|
- "9112:8081" # Akka remoting
|
||||||
|
volumes:
|
||||||
|
- ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro
|
||||||
|
- ./central-node-b/logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
site-x-a:
|
||||||
|
image: scadalink:latest
|
||||||
|
container_name: scadalink-env2-site-x-a
|
||||||
|
environment:
|
||||||
|
SCADALINK_CONFIG: Site
|
||||||
|
ports:
|
||||||
|
- "9121:8082" # Akka remoting
|
||||||
|
- "9123:8083" # gRPC streaming
|
||||||
|
volumes:
|
||||||
|
- ./site-x-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||||
|
- ./site-x-node-a/data:/app/data
|
||||||
|
- ./site-x-node-a/logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
site-x-b:
|
||||||
|
image: scadalink:latest
|
||||||
|
container_name: scadalink-env2-site-x-b
|
||||||
|
environment:
|
||||||
|
SCADALINK_CONFIG: Site
|
||||||
|
ports:
|
||||||
|
- "9122:8082" # Akka remoting
|
||||||
|
- "9124:8083" # gRPC streaming
|
||||||
|
volumes:
|
||||||
|
- ./site-x-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||||
|
- ./site-x-node-b/data:/app/data
|
||||||
|
- ./site-x-node-b/logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.4
|
||||||
|
container_name: scadalink-env2-traefik
|
||||||
|
ports:
|
||||||
|
- "9100:80" # Env2 central load-balanced entrypoint
|
||||||
|
- "8181:8080" # Env2 Traefik dashboard
|
||||||
|
volumes:
|
||||||
|
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
|
||||||
|
- ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
|
networks:
|
||||||
|
- scadalink-net
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
scadalink-net:
|
||||||
|
external: true
|
||||||
15
docker-env2/init-db.sh
Executable file
15
docker-env2/init-db.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q '^scadalink-mssql$'; then
|
||||||
|
echo "ERROR: scadalink-mssql is not running. Start it: cd infra && docker compose up -d" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying env2 database setup..."
|
||||||
|
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
< "$SCRIPT_DIR/../infra/mssql/setup-env2.sql"
|
||||||
|
|
||||||
|
echo "Env2 databases ready."
|
||||||
60
docker-env2/seed-sites.sh
Executable file
60
docker-env2/seed-sites.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Seed env2's single test site with Akka and gRPC addresses.
|
||||||
|
# Run after deploy.sh once the env2 central cluster is healthy.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Infrastructure services running (infra/docker-compose up -d)
|
||||||
|
# - Env2 application containers running (docker-env2/deploy.sh)
|
||||||
|
# - Env2 central cluster healthy (curl http://localhost:9100/health/ready)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker-env2/seed-sites.sh
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
CLI="dotnet run --project $PROJECT_ROOT/src/ScadaLink.CLI --"
|
||||||
|
AUTH="--username multi-role --password password"
|
||||||
|
URL="--url http://localhost:9100"
|
||||||
|
|
||||||
|
echo "=== Seeding ScadaLink Env2 Sites ==="
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Creating Site-X (Env2 Site X)..."
|
||||||
|
$CLI $URL $AUTH site create \
|
||||||
|
--name "Env2 Site X" \
|
||||||
|
--identifier "site-x" \
|
||||||
|
--description "Env2 test site - two-node cluster" \
|
||||||
|
--node-a-address "akka.tcp://scadalink@scadalink-env2-site-x-a:8082" \
|
||||||
|
--node-b-address "akka.tcp://scadalink@scadalink-env2-site-x-b:8082" \
|
||||||
|
--grpc-node-a-address "http://scadalink-env2-site-x-a:8083" \
|
||||||
|
--grpc-node-b-address "http://scadalink-env2-site-x-b:8083" \
|
||||||
|
|| echo " (Site-X may already exist)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Seeding LDAP group mappings (Design + Deployment)..."
|
||||||
|
# SecurityConfiguration.HasData declares 4 mappings but the InitialSchema
|
||||||
|
# migration only inserts the Admin row, so a fresh ScadaLinkConfig2 starts
|
||||||
|
# with multi-role getting Admin only -- no Design and no Deployment access.
|
||||||
|
# Insert the missing three idempotently. (Same divergence exists on the
|
||||||
|
# primary, but primary's database has the rows from earlier history.)
|
||||||
|
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
-d ScadaLinkConfig2 -Q "
|
||||||
|
SET IDENTITY_INSERT LdapGroupMappings ON;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM LdapGroupMappings WHERE Id = 2)
|
||||||
|
INSERT INTO LdapGroupMappings (Id, LdapGroupName, Role) VALUES (2, 'SCADA-Designers', 'Design');
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM LdapGroupMappings WHERE Id = 3)
|
||||||
|
INSERT INTO LdapGroupMappings (Id, LdapGroupName, Role) VALUES (3, 'SCADA-Deploy-All', 'Deployment');
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM LdapGroupMappings WHERE Id = 4)
|
||||||
|
INSERT INTO LdapGroupMappings (Id, LdapGroupName, Role) VALUES (4, 'SCADA-Deploy-SiteA', 'Deployment');
|
||||||
|
SET IDENTITY_INSERT LdapGroupMappings OFF;
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Env2 site seeding complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Verify with: $CLI $URL $AUTH site list"
|
||||||
|
echo "Multi-role test user now has Admin + Design + Deployment in env2."
|
||||||
|
echo "Sign out and back in to refresh session role claims."
|
||||||
59
docker-env2/site-x-node-a/appsettings.Site.json
Normal file
59
docker-env2/site-x-node-a/appsettings.Site.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"ScadaLink": {
|
||||||
|
"Node": {
|
||||||
|
"Role": "Site",
|
||||||
|
"NodeName": "node-a",
|
||||||
|
"NodeHostname": "scadalink-env2-site-x-a",
|
||||||
|
"SiteId": "site-x",
|
||||||
|
"RemotingPort": 8082,
|
||||||
|
"GrpcPort": 8083
|
||||||
|
},
|
||||||
|
"Cluster": {
|
||||||
|
"SeedNodes": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-site-x-a:8082",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-site-x-b:8082"
|
||||||
|
],
|
||||||
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
|
"StableAfter": "00:00:15",
|
||||||
|
"HeartbeatInterval": "00:00:02",
|
||||||
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
"MinNrOfMembers": 1
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"SiteDbPath": "/app/data/scadalink.db"
|
||||||
|
},
|
||||||
|
"DataConnection": {
|
||||||
|
"ReconnectInterval": "00:00:05",
|
||||||
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
|
"WriteTimeout": "00:00:30"
|
||||||
|
},
|
||||||
|
"StoreAndForward": {
|
||||||
|
"SqliteDbPath": "/app/data/store-and-forward.db",
|
||||||
|
"ReplicationEnabled": true
|
||||||
|
},
|
||||||
|
"Communication": {
|
||||||
|
"CentralContactPoints": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-b:8081"
|
||||||
|
],
|
||||||
|
"DeploymentTimeout": "00:02:00",
|
||||||
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
"QueryTimeout": "00:00:30",
|
||||||
|
"TransportHeartbeatInterval": "00:00:05",
|
||||||
|
"TransportFailureThreshold": "00:00:15"
|
||||||
|
},
|
||||||
|
"HealthMonitoring": {
|
||||||
|
"ReportInterval": "00:00:30",
|
||||||
|
"OfflineTimeout": "00:01:00"
|
||||||
|
},
|
||||||
|
"SiteEventLog": {
|
||||||
|
"RetentionDays": 30,
|
||||||
|
"MaxStorageMb": 1024,
|
||||||
|
"PurgeScheduleCron": "0 2 * * *"
|
||||||
|
},
|
||||||
|
"Notification": {},
|
||||||
|
"Logging": {
|
||||||
|
"MinimumLevel": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
docker-env2/site-x-node-b/appsettings.Site.json
Normal file
59
docker-env2/site-x-node-b/appsettings.Site.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"ScadaLink": {
|
||||||
|
"Node": {
|
||||||
|
"Role": "Site",
|
||||||
|
"NodeName": "node-b",
|
||||||
|
"NodeHostname": "scadalink-env2-site-x-b",
|
||||||
|
"SiteId": "site-x",
|
||||||
|
"RemotingPort": 8082,
|
||||||
|
"GrpcPort": 8083
|
||||||
|
},
|
||||||
|
"Cluster": {
|
||||||
|
"SeedNodes": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-site-x-a:8082",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-site-x-b:8082"
|
||||||
|
],
|
||||||
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
|
"StableAfter": "00:00:15",
|
||||||
|
"HeartbeatInterval": "00:00:02",
|
||||||
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
|
"MinNrOfMembers": 1
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"SiteDbPath": "/app/data/scadalink.db"
|
||||||
|
},
|
||||||
|
"DataConnection": {
|
||||||
|
"ReconnectInterval": "00:00:05",
|
||||||
|
"TagResolutionRetryInterval": "00:00:10",
|
||||||
|
"WriteTimeout": "00:00:30"
|
||||||
|
},
|
||||||
|
"StoreAndForward": {
|
||||||
|
"SqliteDbPath": "/app/data/store-and-forward.db",
|
||||||
|
"ReplicationEnabled": true
|
||||||
|
},
|
||||||
|
"Communication": {
|
||||||
|
"CentralContactPoints": [
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-a:8081",
|
||||||
|
"akka.tcp://scadalink@scadalink-env2-central-b:8081"
|
||||||
|
],
|
||||||
|
"DeploymentTimeout": "00:02:00",
|
||||||
|
"LifecycleTimeout": "00:00:30",
|
||||||
|
"QueryTimeout": "00:00:30",
|
||||||
|
"TransportHeartbeatInterval": "00:00:05",
|
||||||
|
"TransportFailureThreshold": "00:00:15"
|
||||||
|
},
|
||||||
|
"HealthMonitoring": {
|
||||||
|
"ReportInterval": "00:00:30",
|
||||||
|
"OfflineTimeout": "00:01:00"
|
||||||
|
},
|
||||||
|
"SiteEventLog": {
|
||||||
|
"RetentionDays": 30,
|
||||||
|
"MaxStorageMb": 1024,
|
||||||
|
"PurgeScheduleCron": "0 2 * * *"
|
||||||
|
},
|
||||||
|
"Notification": {},
|
||||||
|
"Logging": {
|
||||||
|
"MinimumLevel": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
docker-env2/teardown.sh
Executable file
19
docker-env2/teardown.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== ScadaLink Env2 Docker Teardown ==="
|
||||||
|
|
||||||
|
echo "Stopping env2 application containers..."
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" down
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Teardown complete."
|
||||||
|
echo "Site data (SQLite DBs) and logs are preserved in node directories."
|
||||||
|
echo ""
|
||||||
|
echo "Env2 databases (ScadaLinkConfig2 / ScadaLinkMachineData2) remain on"
|
||||||
|
echo "the shared scadalink-mssql. To drop them:"
|
||||||
|
echo " docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \\"
|
||||||
|
echo " -S localhost -U sa -P 'ScadaLink_Dev1#' -C \\"
|
||||||
|
echo " -Q \"DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;\""
|
||||||
18
docker-env2/traefik/dynamic.yml
Normal file
18
docker-env2/traefik/dynamic.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
http:
|
||||||
|
routers:
|
||||||
|
central:
|
||||||
|
rule: "PathPrefix(`/`)"
|
||||||
|
service: central
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
|
||||||
|
services:
|
||||||
|
central:
|
||||||
|
loadBalancer:
|
||||||
|
healthCheck:
|
||||||
|
path: /health/active
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
servers:
|
||||||
|
- url: "http://scadalink-env2-central-a:5000"
|
||||||
|
- url: "http://scadalink-env2-central-b:5000"
|
||||||
11
docker-env2/traefik/traefik.yml
Normal file
11
docker-env2/traefik/traefik.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
providers:
|
||||||
|
file:
|
||||||
|
filename: /etc/traefik/dynamic.yml
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Central",
|
"Role": "Central",
|
||||||
|
"NodeName": "central-a",
|
||||||
"NodeHostname": "scadalink-central-a",
|
"NodeHostname": "scadalink-central-a",
|
||||||
"RemotingPort": 8081
|
"RemotingPort": 8081
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
"DispatchInterval": "00:00:05",
|
"DispatchInterval": "00:00:05",
|
||||||
"DispatchBatchSize": 1000
|
"DispatchBatchSize": 1000
|
||||||
},
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Central",
|
"Role": "Central",
|
||||||
|
"NodeName": "central-b",
|
||||||
"NodeHostname": "scadalink-central-b",
|
"NodeHostname": "scadalink-central-b",
|
||||||
"RemotingPort": 8081
|
"RemotingPort": 8081
|
||||||
},
|
},
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
"DispatchInterval": "00:00:05",
|
"DispatchInterval": "00:00:05",
|
||||||
"DispatchBatchSize": 1000
|
"DispatchBatchSize": 1000
|
||||||
},
|
},
|
||||||
|
"Transport": {
|
||||||
|
"SourceEnvironment": "docker-cluster"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"MinimumLevel": "Information"
|
"MinimumLevel": "Information"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-a",
|
||||||
"NodeHostname": "scadalink-site-a-a",
|
"NodeHostname": "scadalink-site-a-a",
|
||||||
"SiteId": "site-a",
|
"SiteId": "site-a",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-b",
|
||||||
"NodeHostname": "scadalink-site-a-b",
|
"NodeHostname": "scadalink-site-a-b",
|
||||||
"SiteId": "site-a",
|
"SiteId": "site-a",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-a",
|
||||||
"NodeHostname": "scadalink-site-b-a",
|
"NodeHostname": "scadalink-site-b-a",
|
||||||
"SiteId": "site-b",
|
"SiteId": "site-b",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-b",
|
||||||
"NodeHostname": "scadalink-site-b-b",
|
"NodeHostname": "scadalink-site-b-b",
|
||||||
"SiteId": "site-b",
|
"SiteId": "site-b",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-a",
|
||||||
"NodeHostname": "scadalink-site-c-a",
|
"NodeHostname": "scadalink-site-c-a",
|
||||||
"SiteId": "site-c",
|
"SiteId": "site-c",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
|
"NodeName": "node-b",
|
||||||
"NodeHostname": "scadalink-site-c-b",
|
"NodeHostname": "scadalink-site-c-b",
|
||||||
"SiteId": "site-c",
|
"SiteId": "site-c",
|
||||||
"RemotingPort": 8082,
|
"RemotingPort": 8082,
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
940
docs/plans/2026-05-23-audit-source-node.md
Normal file
940
docs/plans/2026-05-23-audit-source-node.md
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
# Audit `SourceNode` Stamping — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Capture the cluster node of origin (`node-a` / `node-b` for site rows, `central-a` / `central-b` for central direct-write rows) on every `AuditLog`, `Notifications`, and `SiteCalls` row, end-to-end from the writing node through telemetry / reconciliation to the Central UI.
|
||||||
|
|
||||||
|
**Architecture:** Introduce a single `INodeIdentityProvider` exposing the local node name from `NodeOptions.NodeName` (new config key, bound from `ScadaLink:Node:NodeName`). Stamp `SourceNode` at the *writing node* — site `SqliteAuditWriter` for site rows, `CentralAuditWriter` for central direct-writes — and carry it verbatim through the existing gRPC `AuditEventDto` / `SiteCallOperationalDto` envelopes (additive new field), the `NotificationSubmit` S&F payload, and the `CachedCallTelemetry` packet. EF Core migrations add the column to all three central tables; site-side SQLite schemas use the existing idempotent `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` pattern. UI gets a new "Node" column + filter on the three grids.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8 / C# 12 sealed records, EF Core 8 (MS SQL on central, SQLite at sites), Akka.NET, Grpc.AspNetCore, Blazor Server, xUnit + Akka.TestKit + NSubstitute + Playwright. Migration naming: `YYYYMMDDHHmmss_DescriptiveTitle`.
|
||||||
|
|
||||||
|
**Out of scope (deferred):**
|
||||||
|
- Cross-table `SourceNode`-aware KPIs (e.g., "per-node stuck count"). The data is captured; dashboards stay site-keyed until a real ask lands.
|
||||||
|
- Backfilling existing rows with a best-guess `SourceNode`. New rows get the column; legacy rows stay `NULL`.
|
||||||
|
- Renaming `NodeHostname` (the Docker container hostname) — it stays as the diagnostic hostname; `NodeName` is the new semantic role-within-cluster name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- All file paths are relative to repo root (`/Users/dohertj2/Desktop/scadalink-design`).
|
||||||
|
- TDD: red → green → commit. Failing test first, then implementation, then verify, then commit.
|
||||||
|
- One coherent change per commit. Commit messages prefix with the affected slice: `feat(audit):`, `feat(notif-outbox):`, `feat(sitecall-audit):`, `chore(docker):`, etc.
|
||||||
|
- After each task, run the targeted test set (`dotnet test <project> --filter <name>`) and the full solution build (`dotnet build ScadaLink.slnx`) before the commit.
|
||||||
|
- For tasks whose migration name needs a timestamp, use `date -u +%Y%m%d%H%M%S` at the moment of execution. The exact name doesn't matter for correctness; just keep them monotonically ordered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Branch + Snapshot
|
||||||
|
|
||||||
|
**Files:** none (git only)
|
||||||
|
|
||||||
|
**Step 1: Create feature branch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/audit-source-node
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: branch created, working tree shows only the prior design-doc edits + the modified `appsettings.*.json` files already in `git status`.
|
||||||
|
|
||||||
|
**Step 2: Stash unrelated dirty files**
|
||||||
|
|
||||||
|
`docker/central-node-{a,b}/appsettings.Central.json` and `src/ScadaLink.CentralUI/Components/Pages/Login.razor` are dirty from prior unrelated work — do **NOT** include them in this feature's commits. Verify with `git diff --stat` what's already modified, and either revert or stash:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --stat
|
||||||
|
git stash push -- docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json src/ScadaLink.CentralUI/Components/Pages/Login.razor
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean diff against the prior commit + the design-doc edits from this session.
|
||||||
|
|
||||||
|
**Step 3: Baseline build + tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: green. If any are red on `main`, **STOP** and surface that to the user — the plan assumes a green starting point.
|
||||||
|
|
||||||
|
**Step 4: Commit the in-progress design-doc edits from this session**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md docs/requirements/Component-AuditLog.md docs/requirements/Component-NotificationOutbox.md docs/requirements/Component-SiteCallAudit.md docs/plans/2026-05-23-audit-source-node.md
|
||||||
|
git commit -m "docs(audit): add SourceNode column to AuditLog/Notifications/SiteCalls design + plan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: NodeOptions + INodeIdentityProvider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Host/NodeOptions.cs`
|
||||||
|
- Create: `src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs`
|
||||||
|
- Create: `src/ScadaLink.Host/NodeIdentityProvider.cs`
|
||||||
|
- Modify: `src/ScadaLink.Host/SiteServiceRegistration.cs` (and central registration if separate — check `CentralServiceRegistration.cs` if it exists)
|
||||||
|
- Create test: `tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs` (if Host.Tests doesn't exist, place under `tests/ScadaLink.AuditLog.Tests/Configuration/NodeIdentityProviderTests.cs` instead)
|
||||||
|
|
||||||
|
**Step 1: Failing test — provider returns configured NodeName**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void NodeIdentityProvider_returns_configured_NodeName()
|
||||||
|
{
|
||||||
|
var opts = Options.Create(new NodeOptions { NodeName = "central-a", Role = "Central" });
|
||||||
|
var provider = new NodeIdentityProvider(opts);
|
||||||
|
Assert.Equal("central-a", provider.NodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NodeIdentityProvider_returns_null_when_NodeName_unset()
|
||||||
|
{
|
||||||
|
var opts = Options.Create(new NodeOptions { NodeName = "", Role = "Central" });
|
||||||
|
var provider = new NodeIdentityProvider(opts);
|
||||||
|
Assert.Null(provider.NodeName);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `dotnet test tests/<project> --filter NodeIdentityProvider` → expected FAIL (types don't exist yet).
|
||||||
|
|
||||||
|
**Step 2: Add `NodeName` to `NodeOptions`**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class NodeOptions
|
||||||
|
{
|
||||||
|
public string Role { get; set; } = string.Empty;
|
||||||
|
public string NodeHostname { get; set; } = string.Empty;
|
||||||
|
public string NodeName { get; set; } = string.Empty; // <— new
|
||||||
|
public string? SiteId { get; set; }
|
||||||
|
public int RemotingPort { get; set; } = 8081;
|
||||||
|
public int GrpcPort { get; set; } = 8083;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Create `INodeIdentityProvider` + implementation**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
public interface INodeIdentityProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Semantic role-within-cluster name of the local node — `node-a` / `node-b`
|
||||||
|
/// for site nodes, `central-a` / `central-b` for central nodes. NULL when
|
||||||
|
/// unconfigured (development/legacy hosts).
|
||||||
|
/// </summary>
|
||||||
|
string? NodeName { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// src/ScadaLink.Host/NodeIdentityProvider.cs
|
||||||
|
internal sealed class NodeIdentityProvider : INodeIdentityProvider
|
||||||
|
{
|
||||||
|
public NodeIdentityProvider(IOptions<NodeOptions> options)
|
||||||
|
{
|
||||||
|
var name = options.Value.NodeName;
|
||||||
|
NodeName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? NodeName { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Register the singleton in DI**
|
||||||
|
|
||||||
|
In `SiteServiceRegistration.cs` and `CentralServiceRegistration.cs` (or wherever `NodeOptions` is bound):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
services.AddSingleton<INodeIdentityProvider, NodeIdentityProvider>();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Run tests + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/<project> --filter NodeIdentityProvider -v n
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS, solution builds.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ScadaLink.Host/NodeOptions.cs \
|
||||||
|
src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs \
|
||||||
|
src/ScadaLink.Host/NodeIdentityProvider.cs \
|
||||||
|
src/ScadaLink.Host/SiteServiceRegistration.cs \
|
||||||
|
tests/<project>/NodeIdentityProviderTests.cs
|
||||||
|
git commit -m "feat(host): add NodeName to NodeOptions + INodeIdentityProvider"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add `SourceNode` to `AuditEvent` record
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs`
|
||||||
|
- Modify: any direct constructors of `AuditEvent` (compile errors will surface them)
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` (or the closest equivalent) — add a SourceNode round-trip assertion.
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void AuditEvent_carries_SourceNode_through_with_init()
|
||||||
|
{
|
||||||
|
var ev = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
SourceSiteId = "site-a",
|
||||||
|
SourceNode = "node-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("node-a", ev.SourceNode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL (property doesn't exist).
|
||||||
|
|
||||||
|
**Step 2: Add `SourceNode` to the record**
|
||||||
|
|
||||||
|
In `AuditEvent.cs`, add between `SourceSiteId` and `SourceInstanceId` (mirroring the design doc):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Resolve compile errors**
|
||||||
|
|
||||||
|
The record is `sealed` and used widely. Most usages will be init-only and won't break; positional constructors (if any) will. Fix them by adding `SourceNode = …` initializers OR by leaving `SourceNode = null` where the caller doesn't know the node yet (writer-level stamping happens in Task 9 + 10).
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests --filter SourceNode -v n
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
git add src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
|
||||||
|
git commit -m "feat(audit): add SourceNode property to AuditEvent record"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add `SourceNode` to `SiteCallOperational` + `SiteCall` entity
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Types/SiteCallOperational.cs`
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs`
|
||||||
|
- Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/` that build a `SiteCall` — extend at least one to assert SourceNode is carried.
|
||||||
|
|
||||||
|
**Step 1: Failing test — `SiteCallOperational` constructed with SourceNode**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void SiteCallOperational_carries_SourceNode()
|
||||||
|
{
|
||||||
|
var op = new SiteCallOperational(
|
||||||
|
TrackedOperationId: TrackedOperationId.New(),
|
||||||
|
Channel: "ApiOutbound",
|
||||||
|
Target: "ERP.GetOrder",
|
||||||
|
SourceSite: "site-a",
|
||||||
|
SourceNode: "node-a", // new positional arg
|
||||||
|
Status: "Submitted",
|
||||||
|
RetryCount: 0,
|
||||||
|
LastError: null,
|
||||||
|
HttpStatus: null,
|
||||||
|
CreatedAtUtc: DateTime.UtcNow,
|
||||||
|
UpdatedAtUtc: DateTime.UtcNow,
|
||||||
|
TerminalAtUtc: null);
|
||||||
|
|
||||||
|
Assert.Equal("node-a", op.SourceNode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL.
|
||||||
|
|
||||||
|
**Step 2: Add `SourceNode` to `SiteCallOperational`**
|
||||||
|
|
||||||
|
Insert `string? SourceNode` between `SourceSite` and `Status`. **Update all callers** — the C# compiler will list every site that constructs the record. Most are in:
|
||||||
|
- `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs`
|
||||||
|
- `src/ScadaLink.DataConnectionLayer/...` (cached DB write site)
|
||||||
|
- `src/ScadaLink.Communication/...` (mappers)
|
||||||
|
- `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs`
|
||||||
|
- All `tests/ScadaLink.SiteCallAudit.Tests/*` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs`
|
||||||
|
|
||||||
|
For now, pass `SourceNode: null` at every existing call site — actual stamping comes in Task 11.
|
||||||
|
|
||||||
|
**Step 3: Add `SourceNode` to `SiteCall` entity**
|
||||||
|
|
||||||
|
Mirror in `src/ScadaLink.Commons/Entities/Audit/SiteCall.cs` as `public string? SourceNode { get; init; }`.
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
dotnet test tests/ScadaLink.SiteCallAudit.Tests --filter SourceNode -v n
|
||||||
|
git add src/ScadaLink.Commons/Types/SiteCallOperational.cs \
|
||||||
|
src/ScadaLink.Commons/Entities/Audit/SiteCall.cs \
|
||||||
|
<updated callers>
|
||||||
|
git commit -m "feat(sitecall-audit): add SourceNode to SiteCallOperational + SiteCall entity"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add `SourceNode` to `Notification` entity + `NotificationSubmit` message
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs`
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs`
|
||||||
|
- Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void NotificationSubmit_carries_SourceNode()
|
||||||
|
{
|
||||||
|
var submit = new NotificationSubmit(
|
||||||
|
NotificationId: Guid.NewGuid().ToString("D"),
|
||||||
|
ListName: "ops-team",
|
||||||
|
Subject: "x",
|
||||||
|
Body: "y",
|
||||||
|
SourceSiteId: "site-a",
|
||||||
|
SourceInstanceId: "instance-1",
|
||||||
|
SourceScript: "OnAlarm",
|
||||||
|
SourceNode: "node-a", // new
|
||||||
|
SiteEnqueuedAt: DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
Assert.Equal("node-a", submit.SourceNode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL.
|
||||||
|
|
||||||
|
**Step 2: Add `SourceNode` to both**
|
||||||
|
|
||||||
|
In `Notification.cs`: `public string? SourceNode { get; set; }`.
|
||||||
|
|
||||||
|
In `NotificationMessages.cs`, extend the record additively (defaulted optional positional arg so existing callers compile — keep behind the optional `OriginExecutionId`/`OriginParentExecutionId` to preserve the existing tail):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record NotificationSubmit(
|
||||||
|
string NotificationId,
|
||||||
|
string ListName,
|
||||||
|
string Subject,
|
||||||
|
string Body,
|
||||||
|
string SourceSiteId,
|
||||||
|
string? SourceInstanceId,
|
||||||
|
string? SourceScript,
|
||||||
|
DateTimeOffset SiteEnqueuedAt,
|
||||||
|
Guid? OriginExecutionId = null,
|
||||||
|
Guid? OriginParentExecutionId = null,
|
||||||
|
string? SourceNode = null); // new, tail
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
dotnet test tests/ScadaLink.NotificationOutbox.Tests --filter SourceNode -v n
|
||||||
|
git add src/ScadaLink.Commons/Entities/Notifications/Notification.cs \
|
||||||
|
src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs \
|
||||||
|
tests/ScadaLink.NotificationOutbox.Tests/<modified>.cs
|
||||||
|
git commit -m "feat(notif-outbox): add SourceNode to Notification entity + NotificationSubmit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add `source_node` to proto + update DTO mappers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto`
|
||||||
|
- Modify: `src/ScadaLink.Communication/AuditEventDtoMapper.cs` (or wherever `ToDto` / `FromDto` live)
|
||||||
|
- Modify: `src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs` (likewise)
|
||||||
|
- Modify: `tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs`
|
||||||
|
- Modify: equivalent SiteCallOperational proto round-trip tests
|
||||||
|
|
||||||
|
**Step 1: Failing test — proto round-trip preserves SourceNode**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void AuditEventDto_round_trip_preserves_SourceNode()
|
||||||
|
{
|
||||||
|
var ev = new AuditEvent { /* … */ SourceNode = "node-a", SourceSiteId = "site-a" };
|
||||||
|
var dto = AuditEventDtoMapper.ToDto(ev);
|
||||||
|
var back = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
Assert.Equal("node-a", back.SourceNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditEventDto_round_trip_preserves_null_SourceNode()
|
||||||
|
{
|
||||||
|
var ev = new AuditEvent { /* … */ SourceNode = null };
|
||||||
|
var dto = AuditEventDtoMapper.ToDto(ev);
|
||||||
|
var back = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
Assert.Null(back.SourceNode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL (compile error on `SourceNode` until mapper handles it).
|
||||||
|
|
||||||
|
**Step 2: Extend proto additively**
|
||||||
|
|
||||||
|
In `sitestream.proto`, **field numbers must be new and not reused**:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message AuditEventDto {
|
||||||
|
// … existing fields 1..21 unchanged …
|
||||||
|
string source_node = 22; // empty string represents null
|
||||||
|
}
|
||||||
|
|
||||||
|
message SiteCallOperationalDto {
|
||||||
|
// … existing fields 1..11 unchanged …
|
||||||
|
string source_node = 12; // empty string represents null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Regenerate + update mappers**
|
||||||
|
|
||||||
|
`Grpc.Tools` regenerates on build. Update `AuditEventDtoMapper.ToDto`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SourceNode = ev.SourceNode ?? string.Empty,
|
||||||
|
```
|
||||||
|
|
||||||
|
And `FromDto`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SourceNode = string.IsNullOrEmpty(dto.SourceNode) ? null : dto.SourceNode,
|
||||||
|
```
|
||||||
|
|
||||||
|
Same pattern for `SiteCallOperationalDtoMapper`.
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
dotnet test tests/ScadaLink.Communication.Tests --filter SourceNode -v n
|
||||||
|
git add src/ScadaLink.Communication/Protos/sitestream.proto \
|
||||||
|
src/ScadaLink.Communication/AuditEventDtoMapper.cs \
|
||||||
|
src/ScadaLink.Communication/SiteCallOperationalDtoMapper.cs \
|
||||||
|
tests/ScadaLink.Communication.Tests/Protos/AuditEventProtoTests.cs \
|
||||||
|
tests/ScadaLink.Communication.Tests/<sitecall mapper tests>.cs
|
||||||
|
git commit -m "feat(comm): add source_node field to AuditEventDto + SiteCallOperationalDto proto"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: EF migration — add `SourceNode` to `AuditLog` + index
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_AddAuditLogSourceNode.cs` + `.Designer.cs`
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs`
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs`
|
||||||
|
- Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test (migration apply produces `SourceNode` column + index)**
|
||||||
|
|
||||||
|
Pattern from existing `AddAuditLogTableMigrationTests.cs`. Apply migration against a fresh MS SQL test fixture; assert `INFORMATION_SCHEMA.COLUMNS` contains `SourceNode varchar(64) NULL` and `sys.indexes` contains `IX_AuditLog_Node_Occurred` with columns `(SourceNode, OccurredAtUtc)`.
|
||||||
|
|
||||||
|
Run: expected FAIL.
|
||||||
|
|
||||||
|
**Step 2: Add migration via EF CLI**
|
||||||
|
|
||||||
|
From repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet ef migrations add AddAuditLogSourceNode \
|
||||||
|
--project src/ScadaLink.ConfigurationDatabase \
|
||||||
|
--startup-project src/ScadaLink.Host \
|
||||||
|
--context ScadaLinkDbContext
|
||||||
|
```
|
||||||
|
|
||||||
|
Hand-edit the generated `Up()` / `Down()` to verify shape:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "SourceNode",
|
||||||
|
table: "AuditLog",
|
||||||
|
type: "varchar(64)",
|
||||||
|
unicode: false,
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// IMPORTANT: AuditLog is partitioned on ps_AuditLog_Month(OccurredAtUtc).
|
||||||
|
// `migrationBuilder.CreateIndex(...)` lands the index on [PRIMARY], which breaks
|
||||||
|
// `ALTER TABLE … SWITCH PARTITION` (the purge mechanism). Match the pattern used
|
||||||
|
// by the other `IX_AuditLog_*` indexes (see 20260520142214_AddAuditLogTable.cs
|
||||||
|
// and 20260521184044_AddAuditLogExecutionId.cs) — raw SQL with the partition
|
||||||
|
// scheme spelled out. Keep the fluent `HasIndex(...).HasDatabaseName(...)` in
|
||||||
|
// the EF configuration so the model snapshot stays in sync.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred
|
||||||
|
ON dbo.AuditLog (SourceNode, OccurredAtUtc)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
```
|
||||||
|
|
||||||
|
`Down()` drops the index (`IF EXISTS DROP INDEX … ON dbo.AuditLog`, raw SQL) then the column.
|
||||||
|
|
||||||
|
You will *also* need to extend `AuditLogRepository.SwitchOutPartitionAsync`'s staging-table CREATE to include `SourceNode varchar(64) NULL` in the final ordinal position. `SWITCH PARTITION` rejects schema mismatches between live and staging — without this, the PartitionPurge integration tests fail.
|
||||||
|
|
||||||
|
**Step 3: Update EF configuration**
|
||||||
|
|
||||||
|
In `AuditLogEntityTypeConfiguration.cs`, mirror the design doc:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Property(e => e.SourceNode).HasColumnType("varchar(64)").HasMaxLength(64);
|
||||||
|
builder.HasIndex(e => new { e.SourceNode, e.OccurredAtUtc })
|
||||||
|
.HasDatabaseName("IX_AuditLog_Node_Occurred");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
dotnet test tests/ScadaLink.ConfigurationDatabase.Tests --filter AuditLogSourceNode -v n
|
||||||
|
git add src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_AddAuditLogSourceNode.cs \
|
||||||
|
src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_AddAuditLogSourceNode.Designer.cs \
|
||||||
|
src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs \
|
||||||
|
src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs \
|
||||||
|
tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogSourceNodeMigrationTests.cs
|
||||||
|
git commit -m "feat(db): add SourceNode column + IX_AuditLog_Node_Occurred index to AuditLog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: EF migration — add `SourceNode` to `Notifications`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<ts>_AddNotificationSourceNode.cs` + `.Designer.cs`
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs`
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs`
|
||||||
|
- Create test: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationSourceNodeMigrationTests.cs`
|
||||||
|
|
||||||
|
**Step 1–4:** Mirror Task 6 for the `Notifications` table. No new index (the spec says index only on `AuditLog`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(db): add SourceNode column to Notifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: EF migration — add `SourceNode` to `SiteCalls`
|
||||||
|
|
||||||
|
**Files:** equivalents under `SiteCalls`. No new index.
|
||||||
|
|
||||||
|
**Step 1–4:** Mirror Task 6. Configuration file is `SiteCallEntityTypeConfiguration.cs`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(db): add SourceNode column to SiteCalls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Site SQLite `AuditLog` — add `SourceNode` column (idempotent upgrade)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs`
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test — schema includes SourceNode AND old DBs are upgraded**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_creates_AuditLog_with_SourceNode_column()
|
||||||
|
{
|
||||||
|
using var writer = new SqliteAuditWriter(/*…in-memory…*/);
|
||||||
|
var cols = await ReadColumnsAsync("AuditLog");
|
||||||
|
Assert.Contains("SourceNode", cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Initialize_adds_SourceNode_to_pre_existing_schema()
|
||||||
|
{
|
||||||
|
// 1. open a SQLite file and create the OLD schema (no SourceNode)
|
||||||
|
// 2. open SqliteAuditWriter against the same file
|
||||||
|
// 3. assert SourceNode column now exists via PRAGMA table_info
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL.
|
||||||
|
|
||||||
|
**Step 2: Update `InitializeSchema`**
|
||||||
|
|
||||||
|
Add `SourceNode TEXT NULL` to the `CREATE TABLE IF NOT EXISTS AuditLog (...)` DDL. Add a second `PRAGMA table_info`-based upgrade block matching the existing `ExecutionId` / `ParentExecutionId` pattern:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (!columns.Contains("SourceNode", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "ALTER TABLE AuditLog ADD COLUMN SourceNode TEXT NULL;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update INSERT statement to include SourceNode**
|
||||||
|
|
||||||
|
In the parameterized batch insert SQL (lines ~270-284), add the column + parameter.
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs --filter SourceNode -v n
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
git add src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs \
|
||||||
|
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs \
|
||||||
|
tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs
|
||||||
|
git commit -m "feat(audit): add SourceNode column to site SQLite AuditLog (idempotent upgrade)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Site SQLite `OperationTracking` — add `SourceNode` column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Tracking/OperationTrackingStore.cs`
|
||||||
|
- Modify: tests under `tests/ScadaLink.SiteRuntime.Tests/Tracking/` (or closest)
|
||||||
|
|
||||||
|
**Step 1: Failing test** — same pattern as Task 9, asserting the `OperationTracking` table grows a `SourceNode TEXT NULL` column on both fresh and pre-existing DBs.
|
||||||
|
|
||||||
|
**Step 2: Add column to CREATE TABLE + idempotent PRAGMA-based ALTER**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS OperationTracking (
|
||||||
|
-- ...existing columns...
|
||||||
|
SourceNode TEXT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus the same PRAGMA upgrade block.
|
||||||
|
|
||||||
|
**Step 3: Update `RecordEnqueueAsync` signature**
|
||||||
|
|
||||||
|
Accept `string? sourceNode` and pass through to INSERT.
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(site-runtime): add SourceNode column to OperationTracking + thread through RecordEnqueueAsync"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Stamp `SourceNode` at the site `SqliteAuditWriter`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs`
|
||||||
|
- Modify: DI registration that wires `SqliteAuditWriter` (likely `src/ScadaLink.AuditLog/AuditLogServiceCollectionExtensions.cs` or equivalent)
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterWriteTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_stamps_SourceNode_from_INodeIdentityProvider_when_event_has_none()
|
||||||
|
{
|
||||||
|
var nodeId = Substitute.For<INodeIdentityProvider>();
|
||||||
|
nodeId.NodeName.Returns("node-a");
|
||||||
|
using var writer = new SqliteAuditWriter(/*…*/, nodeId);
|
||||||
|
await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = null });
|
||||||
|
var rows = await ReadAllAsync();
|
||||||
|
Assert.Equal("node-a", rows.Single().SourceNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_preserves_caller_provided_SourceNode()
|
||||||
|
{
|
||||||
|
var nodeId = Substitute.For<INodeIdentityProvider>();
|
||||||
|
nodeId.NodeName.Returns("node-a");
|
||||||
|
using var writer = new SqliteAuditWriter(/*…*/, nodeId);
|
||||||
|
await writer.WriteAsync(new AuditEvent { /*…*/ SourceNode = "node-z" });
|
||||||
|
var rows = await ReadAllAsync();
|
||||||
|
Assert.Equal("node-z", rows.Single().SourceNode); // caller wins (e.g. reconciliation)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: expected FAIL.
|
||||||
|
|
||||||
|
**Step 2: Implementation**
|
||||||
|
|
||||||
|
Inject `INodeIdentityProvider` into `SqliteAuditWriter`. In `WriteAsync` / batch flush, **before** binding parameters:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var stamped = ev.SourceNode is null ? ev with { SourceNode = _nodeIdentity.NodeName } : ev;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(audit): stamp SourceNode at site SqliteAuditWriter from INodeIdentityProvider"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: Stamp `SourceNode` at central `CentralAuditWriter`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Central/CentralAuditWriter.cs`
|
||||||
|
- Modify: `tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriterTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test** — mirror Task 11 but for the central writer (Inbound API / Notification Outbox dispatcher path).
|
||||||
|
|
||||||
|
**Step 2: Implementation** — inject `INodeIdentityProvider`, stamp before `repo.InsertIfNotExistsAsync`. Same "caller wins" semantics.
|
||||||
|
|
||||||
|
**Step 3: Update `AuditLogRepository.InsertIfNotExistsAsync` to persist `SourceNode`**
|
||||||
|
|
||||||
|
This is in `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — extend the parameterized INSERT.
|
||||||
|
|
||||||
|
**Step 4: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(audit): stamp SourceNode at CentralAuditWriter + persist via AuditLogRepository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 13: Site → central — carry `SourceNode` on `NotificationSubmit`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: site code that constructs `NotificationSubmit` (likely under `src/ScadaLink.SiteRuntime/Scripts/` or `src/ScadaLink.NotificationService/Site/...`)
|
||||||
|
- Modify: S&F buffer schema if NotificationSubmit is serialized to SQLite (check `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs` for a notification-specific column — likely the whole DTO is serialized as a blob, no schema change needed)
|
||||||
|
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` `HandleSubmit` to copy `SourceNode` into the `Notification` row
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs` (or wherever) `InsertIfNotExistsAsync` SQL
|
||||||
|
- Modify: tests in `tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test — central persists SourceNode from NotificationSubmit**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleSubmit_persists_SourceNode_from_payload()
|
||||||
|
{
|
||||||
|
var submit = new NotificationSubmit(/*…*/, SourceNode: "node-b");
|
||||||
|
actor.Tell(submit);
|
||||||
|
await ExpectMsgAsync<NotificationSubmitAck>(m => m.Accepted);
|
||||||
|
var row = await repo.GetByIdAsync(submit.NotificationId);
|
||||||
|
Assert.Equal("node-b", row.SourceNode);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implementation**
|
||||||
|
|
||||||
|
- Site: inject `INodeIdentityProvider` into the call site that builds `NotificationSubmit`, pass `SourceNode = nodeIdentity.NodeName`.
|
||||||
|
- Central: extend `HandleSubmit` to copy `submit.SourceNode` onto the `Notification` row; extend the repo INSERT to persist.
|
||||||
|
|
||||||
|
**Step 3: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(notif-outbox): carry + persist SourceNode end-to-end via NotificationSubmit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 14: Site → central — carry `SourceNode` on `SiteCallOperational` + cached telemetry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: site emitters of `SiteCallOperational` — `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs`, `src/ScadaLink.DataConnectionLayer/...` (cached DB write), and the S&F retry path that emits `Attempted` packets
|
||||||
|
- Modify: `src/ScadaLink.SiteCallAudit/SiteCallAuditActor.cs` (and any `SiteCallAuditIngestActor` that maps `SiteCallOperational` → `SiteCall`)
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/SiteCallAuditRepository.cs` — extend monotonic upsert SQL to include `SourceNode`
|
||||||
|
- Modify: tests in `tests/ScadaLink.SiteCallAudit.Tests/SiteCallAuditActorTests.cs` and `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/SiteCallAuditRepositoryTests.cs`
|
||||||
|
|
||||||
|
**Step 1: Failing test — central persists `SourceNode` on upsert; subsequent upsert with same id keeps SourceNode set even if newer packet has it null**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Upsert_persists_SourceNode_on_first_insert_and_preserves_on_status_advance()
|
||||||
|
{
|
||||||
|
var id = TrackedOperationId.New();
|
||||||
|
await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = "node-a", Status = "Submitted" });
|
||||||
|
await repo.UpsertAsync(new SiteCall { TrackedOperationId = id, /*…*/ SourceNode = null, Status = "Delivered" });
|
||||||
|
var row = await repo.GetByIdAsync(id);
|
||||||
|
Assert.Equal("node-a", row.SourceNode);
|
||||||
|
Assert.Equal("Delivered", row.Status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implementation**
|
||||||
|
|
||||||
|
- Site emitters: inject `INodeIdentityProvider`, pass `SourceNode = nodeIdentity.NodeName` on construction.
|
||||||
|
- Repository: include `SourceNode` in the INSERT branch. In the conditional monotonic UPDATE branch, use `SourceNode = COALESCE(@SourceNode, SourceNode)` so later packets with a null don't blank out a previously-stamped value.
|
||||||
|
|
||||||
|
**Step 3: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(sitecall-audit): carry + persist SourceNode end-to-end via cached telemetry"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 15: Central UI — add Node column + filter to AuditLog grid
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` + `.razor.cs` (add column entry to `AllColumns`)
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (add a text or multi-select Node filter)
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs` (extend the query/filter model)
|
||||||
|
- Modify: tests in `tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs`
|
||||||
|
- Modify: `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs` — assert the Node column renders
|
||||||
|
|
||||||
|
**Step 1: Failing test — query service supports `SourceNode` filter**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task Query_filters_by_SourceNode()
|
||||||
|
{
|
||||||
|
// arrange 2 rows: one with SourceNode=node-a, one with node-b
|
||||||
|
var rows = await service.QueryAsync(new AuditLogQuery { SourceNodes = new[] { "node-a" } });
|
||||||
|
Assert.Single(rows);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implementation**
|
||||||
|
|
||||||
|
- Extend `AuditLogQuery` with `IReadOnlyList<string>? SourceNodes` (multi-select like `Sites`).
|
||||||
|
- Extend the LINQ filter (`q.SourceNodes is { Count: > 0 } ? source.Where(r => q.SourceNodes.Contains(r.SourceNode)) : source`).
|
||||||
|
- Add a new column descriptor `("Node", row => row.SourceNode ?? "—")` between `Site` and `Channel` in `AllColumns`.
|
||||||
|
- Add a multi-select filter chip in `AuditFilterBar.razor`. Populate node options by `SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL` (cached for 60s).
|
||||||
|
|
||||||
|
**Step 3: Run + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(ui): add Node column + filter to AuditLog grid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 16: Central UI — add Node column + filter to Notifications grid
|
||||||
|
|
||||||
|
**Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor` and its query service. No filter populate-from-DB required if scope is per-site already; a free-text Node filter is acceptable for v1.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(ui): add Node column + filter to NotificationOutbox grid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 17: Central UI — add Node column + filter to SiteCalls grid
|
||||||
|
|
||||||
|
**Files:** mirror Task 15 for `src/ScadaLink.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(ui): add Node column + filter to SiteCalls grid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 18: Docker `appsettings` — set `NodeName` on all 8 nodes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docker/central-node-a/appsettings.Central.json` → `"NodeName": "central-a"`
|
||||||
|
- `docker/central-node-b/appsettings.Central.json` → `"NodeName": "central-b"`
|
||||||
|
- `docker/site-a-node-a/appsettings.Site.json` → `"NodeName": "node-a"`
|
||||||
|
- `docker/site-a-node-b/appsettings.Site.json` → `"NodeName": "node-b"`
|
||||||
|
- `docker/site-b-node-a/appsettings.Site.json` → `"NodeName": "node-a"`
|
||||||
|
- `docker/site-b-node-b/appsettings.Site.json` → `"NodeName": "node-b"`
|
||||||
|
- `docker/site-c-node-a/appsettings.Site.json` → `"NodeName": "node-a"`
|
||||||
|
- `docker/site-c-node-b/appsettings.Site.json` → `"NodeName": "node-b"`
|
||||||
|
|
||||||
|
Add `"NodeName": "<value>"` to the existing `"Node": { … }` object in each file. **Do not** disturb the two `central-*` files that show up dirty in pre-existing `git status` from before this branch — re-apply this change cleanly after stashing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/central-node-a/appsettings.Central.json docker/central-node-b/appsettings.Central.json docker/site-a-node-a/appsettings.Site.json docker/site-a-node-b/appsettings.Site.json docker/site-b-node-a/appsettings.Site.json docker/site-b-node-b/appsettings.Site.json docker/site-c-node-a/appsettings.Site.json docker/site-c-node-b/appsettings.Site.json
|
||||||
|
git commit -m "chore(docker): set NodeName on all 8 cluster nodes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 19: Full-solution build + targeted test sweep
|
||||||
|
|
||||||
|
**Step 1: Build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build ScadaLink.slnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors, 0 warnings.
|
||||||
|
|
||||||
|
**Step 2: Run the touched test projects**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.SiteCallAudit.Tests/ScadaLink.SiteCallAudit.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj
|
||||||
|
dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all green. If anything fails, fix it before proceeding.
|
||||||
|
|
||||||
|
**Step 3: Full solution test (sanity)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test ScadaLink.slnx --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 20: Docker redeploy + smoke verify
|
||||||
|
|
||||||
|
**Step 1: Rebuild + redeploy the cluster**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash docker/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: image rebuilt, all 8 containers up. Watch for migration apply on central startup.
|
||||||
|
|
||||||
|
**Step 2: CLI smoke — generate one inbound, one notification, one cached call; verify SourceNode populated**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trigger something that emits each kind. The exact CLI commands depend on
|
||||||
|
# scripts deployed at the time; at minimum:
|
||||||
|
# 1. POST to inbound API → produces an InboundRequest row
|
||||||
|
# 2. Run a script that calls Notify.Send → produces NotifySend + NotifyDeliver
|
||||||
|
# 3. Run a script that calls ExternalSystem.CachedCall → produces CachedSubmit/Forwarded/Attempted/Delivered
|
||||||
|
|
||||||
|
# Then check SourceNode is populated:
|
||||||
|
docker exec scadalink-mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'YourStrong!Passw0rd' -Q "SELECT TOP 20 Channel, Kind, SourceSiteId, SourceNode, Status FROM scadalink.dbo.AuditLog ORDER BY OccurredAtUtc DESC"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- `InboundRequest` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`.
|
||||||
|
- `NotifyDeliver` rows: `SourceSiteId IS NULL`, `SourceNode IN ('central-a','central-b')`.
|
||||||
|
- Site-originated rows: `SourceSiteId = 'site-a'`, `SourceNode IN ('node-a','node-b')`.
|
||||||
|
|
||||||
|
**Step 3: UI smoke**
|
||||||
|
|
||||||
|
Open `http://localhost:9000`, log in as `multi-role` / `password`, hit each of:
|
||||||
|
- Audit Log → confirm "Node" column shows values, confirm Node filter narrows results.
|
||||||
|
- Notifications → same.
|
||||||
|
- Site Calls → same.
|
||||||
|
|
||||||
|
**Step 4: Final commit (if anything had to be tweaked during smoke)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git status # verify nothing surprising
|
||||||
|
git commit -m "chore(audit): smoke-verify SourceNode end-to-end across cluster"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (the whole-plan checklist)
|
||||||
|
|
||||||
|
- [ ] Every audit row written from this commit forward carries `SourceNode` populated (`central-a/b` for central direct-write, `node-a/b` for site rows).
|
||||||
|
- [ ] Every new `Notifications` and `SiteCalls` row carries `SourceNode` from the site.
|
||||||
|
- [ ] `IX_AuditLog_Node_Occurred` exists in central MS SQL.
|
||||||
|
- [ ] Site SQLite `AuditLog` and `OperationTracking` tables both have `SourceNode TEXT NULL`; existing site DBs are upgraded idempotently on startup.
|
||||||
|
- [ ] Proto `AuditEventDto.source_node = 22` and `SiteCallOperationalDto.source_node = 12` exist; no field numbers reused.
|
||||||
|
- [ ] Central UI Audit Log, Notifications, and Site Calls pages all display a "Node" column and support filtering by it.
|
||||||
|
- [ ] All test projects green.
|
||||||
|
- [ ] Cluster comes up clean via `bash docker/deploy.sh`; CLI smoke confirms expected node names land in the central tables.
|
||||||
|
- [ ] Design docs (already committed in Task 0) match the implementation.
|
||||||
27
docs/plans/2026-05-23-audit-source-node.md.tasks.json
Normal file
27
docs/plans/2026-05-23-audit-source-node.md.tasks.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-23-audit-source-node.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 1, "subject": "Task 0: Branch + Snapshot", "status": "pending"},
|
||||||
|
{"id": 2, "subject": "Task 1: NodeOptions.NodeName + INodeIdentityProvider", "status": "pending", "blockedBy": [1]},
|
||||||
|
{"id": 3, "subject": "Task 2: Add SourceNode to AuditEvent record", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 4, "subject": "Task 3: Add SourceNode to SiteCallOperational + SiteCall entity", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 5, "subject": "Task 4: Add SourceNode to Notification entity + NotificationSubmit", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 6, "subject": "Task 5: Add source_node to proto + update DTO mappers", "status": "pending", "blockedBy": [3, 4]},
|
||||||
|
{"id": 7, "subject": "Task 6: EF migration — SourceNode on AuditLog + IX_AuditLog_Node_Occurred", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 8, "subject": "Task 7: EF migration — SourceNode on Notifications", "status": "pending", "blockedBy": [5]},
|
||||||
|
{"id": 9, "subject": "Task 8: EF migration — SourceNode on SiteCalls", "status": "pending", "blockedBy": [4]},
|
||||||
|
{"id": 10, "subject": "Task 9: Site SQLite AuditLog — add SourceNode (idempotent upgrade)", "status": "pending", "blockedBy": [3]},
|
||||||
|
{"id": 11, "subject": "Task 10: Site SQLite OperationTracking — add SourceNode", "status": "pending", "blockedBy": [4]},
|
||||||
|
{"id": 12, "subject": "Task 11: Stamp SourceNode at site SqliteAuditWriter", "status": "pending", "blockedBy": [2, 10]},
|
||||||
|
{"id": 13, "subject": "Task 12: Stamp SourceNode at CentralAuditWriter + persist via repo", "status": "pending", "blockedBy": [2, 7]},
|
||||||
|
{"id": 14, "subject": "Task 13: Carry SourceNode through Notifications S&F handoff", "status": "pending", "blockedBy": [2, 5, 8]},
|
||||||
|
{"id": 15, "subject": "Task 14: Carry SourceNode through cached-call telemetry → SiteCalls", "status": "pending", "blockedBy": [2, 9, 11]},
|
||||||
|
{"id": 16, "subject": "Task 15: UI — Node column + filter on AuditLog grid", "status": "pending", "blockedBy": [7]},
|
||||||
|
{"id": 17, "subject": "Task 16: UI — Node column + filter on Notifications grid", "status": "pending", "blockedBy": [7]},
|
||||||
|
{"id": 18, "subject": "Task 17: UI — Node column + filter on SiteCalls grid", "status": "pending", "blockedBy": [7]},
|
||||||
|
{"id": 19, "subject": "Task 18: Docker appsettings — NodeName on all 8 nodes", "status": "pending", "blockedBy": [2]},
|
||||||
|
{"id": 20, "subject": "Task 19: Full build + targeted test sweep", "status": "pending", "blockedBy": [12, 13, 14, 15, 16, 17, 18, 19]},
|
||||||
|
{"id": 21, "subject": "Task 20: Docker redeploy + smoke verify", "status": "pending", "blockedBy": [20]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-23T00:00:00Z"
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
276
docs/plans/2026-05-24-second-environment-design.md
Normal file
276
docs/plans/2026-05-24-second-environment-design.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# Second Docker Environment (`env2`) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-24
|
||||||
|
**Status:** Approved — ready for implementation plan
|
||||||
|
**Purpose:** Stand up a second, concurrently-running ScadaLink cluster on the same machine so the new Transport (#24) feature can be exercised end-to-end against a real second environment (export from one UI, import into the other).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A sibling `docker-env2/` directory with `deploy.sh` / `teardown.sh` / `seed-sites.sh` / `init-db.sh` that brings up a minimal but fully-functional second cluster — its own central + site, its own ConfigurationDB — alongside the existing `docker/` stack. Both environments run concurrently and share the commodity infra services (MSSQL container, LDAP, SMTP, OPC UA, REST API). No application code changes; this is purely deploy tooling.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Not a fully air-gapped twin (LDAP/SMTP/OPC UA/REST API are shared).
|
||||||
|
- Not a full mirror of primary's three-site topology — env2 has one site (`site-x`).
|
||||||
|
- Not a multi-tenant abstraction or `--env` flag retrofit on `docker/deploy.sh` — kept as two independent script trees for clarity.
|
||||||
|
- No new automated tests — env2 enables manual verification via [`2026-05-24-second-environment-verification.md`](2026-05-24-second-environment-verification.md) (created during implementation).
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
(host machine)
|
||||||
|
|
||||||
|
Primary stack (already existing — unchanged) Env2 stack (new)
|
||||||
|
┌────────────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ Traefik :9000 ◄── 9001/9002 UI │ │ Traefik :9100 ◄── 9101/9102 UI│
|
||||||
|
│ Central A/B (9011/9012 Akka) │ │ Central A/B (9111/9112 Akka) │
|
||||||
|
│ Site-A/B/C (9021..9044) │ │ Site-X (9121/9122 Akka, │
|
||||||
|
└─────────────┬──────────────────────┘ │ 9123/9124 gRPC) │
|
||||||
|
│ └──────────┬───────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ scadalink-net (shared bridge network) ◄──────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ scadalink-mssql ScadaLinkConfig (primary DB) │
|
||||||
|
│ ScadaLinkMachineData (primary DB) │
|
||||||
|
│ ScadaLinkConfig2 (env2 DB) ← new │
|
||||||
|
│ ScadaLinkMachineData2(env2 DB) ← new │
|
||||||
|
│ scadalink-ldap (shared — same test users) │
|
||||||
|
│ scadalink-smtp (shared Mailpit) │
|
||||||
|
│ scadalink-opcua (shared) │
|
||||||
|
│ scadalink-restapi (shared) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Both stacks attach to the same `scadalink-net` Docker bridge so env2's app containers can reach the infra services by container hostname (`scadalink-mssql`, `scadalink-ldap`, etc.). Akka clusters are independent — each side's `SeedNodes` lists only its own central nodes, so they never gossip-merge despite sharing the network.
|
||||||
|
|
||||||
|
## Topology & Port Allocation
|
||||||
|
|
||||||
|
| Role | Container name | Host Web | Host Akka | Host gRPC | Notes |
|
||||||
|
|----------------|-----------------------------|----------|-----------|-----------|-------|
|
||||||
|
| Traefik LB | `scadalink-env2-traefik` | 9100 | — | — | Dashboard on host 8181 |
|
||||||
|
| Central A | `scadalink-env2-central-a` | 9101 | 9111 | — | |
|
||||||
|
| Central B | `scadalink-env2-central-b` | 9102 | 9112 | — | |
|
||||||
|
| Site-X A | `scadalink-env2-site-x-a` | — | 9121 | 9123 | |
|
||||||
|
| Site-X B | `scadalink-env2-site-x-b` | — | 9122 | 9124 | |
|
||||||
|
|
||||||
|
Pattern: env2 host ports are primary + 100 (e.g. primary central-a 9001 → env2 central-a 9101). Confirmed free at design time. Identifier `site-x` distinguishes env2's single site from primary's `site-a/b/c` in logs/UI (technically not required — each central has its own ConfigurationDB — but clearer for operators).
|
||||||
|
|
||||||
|
## Infrastructure & Databases
|
||||||
|
|
||||||
|
**Shared `scadalink-net` + shared `scadalink-mssql` container, separate logical databases:**
|
||||||
|
|
||||||
|
- New databases: `ScadaLinkConfig2`, `ScadaLinkMachineData2`.
|
||||||
|
- Reuse the existing `scadalink_app` SQL login with `db_owner` on both — one credential to manage.
|
||||||
|
- DB creation handled by a new `infra/mssql/setup-env2.sql` (idempotent, `IF NOT EXISTS`-guarded).
|
||||||
|
- Two activation paths:
|
||||||
|
1. **Fresh MSSQL volume** — mount `setup-env2.sql` alongside the existing `setup.sql` in `/docker-entrypoint-initdb.d/` so it runs automatically on first startup.
|
||||||
|
2. **Already-running MSSQL** — `docker-env2/init-db.sh` exec's `sqlcmd` inside the container to apply the same script. No MSSQL restart.
|
||||||
|
- EF Core migrations auto-apply on env2 central startup (matches the primary's `Development` env var pattern) → tables created on first deploy.
|
||||||
|
|
||||||
|
**Reused as-is:** LDAP (same `multi-role`/`admin`/`designer`/`deployer` test users), SMTP (Mailpit — env2's emails appear in the same inbox at http://localhost:8025, distinguishable by env2's `FromAddress`), OPC UA, REST API. All stateless commodities; isolation gains nothing by duplicating them.
|
||||||
|
|
||||||
|
Connection strings in env2 central appsettings:
|
||||||
|
```
|
||||||
|
ConfigurationDb: Server=scadalink-mssql,1433;Database=ScadaLinkConfig2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true
|
||||||
|
MachineDataDb: Server=scadalink-mssql,1433;Database=ScadaLinkMachineData2;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-env2/
|
||||||
|
├── docker-compose.yml # 5 services: 2 central + 2 site + Traefik
|
||||||
|
├── deploy.sh # build (reuses docker/build.sh) + init-db + compose up
|
||||||
|
├── teardown.sh # compose down (preserves data + logs)
|
||||||
|
├── seed-sites.sh # CLI creates site-x against http://localhost:9100
|
||||||
|
├── init-db.sh # sqlcmd exec against scadalink-mssql
|
||||||
|
├── central-node-a/
|
||||||
|
│ └── appsettings.Central.json
|
||||||
|
├── central-node-b/
|
||||||
|
│ └── appsettings.Central.json
|
||||||
|
├── site-x-node-a/
|
||||||
|
│ ├── appsettings.Site.json
|
||||||
|
│ ├── data/ # gitignored — SQLite store-and-forward + site-event-log
|
||||||
|
│ └── logs/ # gitignored
|
||||||
|
├── site-x-node-b/
|
||||||
|
│ ├── appsettings.Site.json
|
||||||
|
│ ├── data/
|
||||||
|
│ └── logs/
|
||||||
|
└── traefik/
|
||||||
|
├── traefik.yml # dashboard on :8080 (host 8181)
|
||||||
|
└── dynamic.yml # service URLs → scadalink-env2-central-a/b
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirrors `docker/`'s shape exactly so operator muscle memory carries over.
|
||||||
|
|
||||||
|
## Per-Node Appsettings — Key Differences from Primary
|
||||||
|
|
||||||
|
Each env2 appsettings file is a near-clone of the primary equivalent with these targeted overrides:
|
||||||
|
|
||||||
|
| Field | Primary | Env2 |
|
||||||
|
|--------------------------------------------|-----------------------------------------------------|-----------------------------------------------------|
|
||||||
|
| `Node.NodeHostname` | `scadalink-central-a` / `scadalink-site-a-a` / ... | `scadalink-env2-central-a` / `scadalink-env2-site-x-a` / ... |
|
||||||
|
| `Cluster.SeedNodes` | primary central hostnames | env2 central hostnames |
|
||||||
|
| `Communication.CentralContactPoints` (site)| primary central hostnames | env2 central hostnames |
|
||||||
|
| `Node.SiteId` (site) | `site-a` / `site-b` / `site-c` | `site-x` |
|
||||||
|
| `Database.ConfigurationDb` | `ScadaLinkConfig` | `ScadaLinkConfig2` |
|
||||||
|
| `Database.MachineDataDb` | `ScadaLinkMachineData` | `ScadaLinkMachineData2` |
|
||||||
|
| `Notification.FromAddress` | `scada-notifications@company.com` | `scada-notifications-env2@company.com` |
|
||||||
|
| `Security.JwtSigningKey` | primary signing key | a distinct env2 signing key |
|
||||||
|
| `Transport.SourceEnvironment` | `docker-cluster` | `docker-cluster-env2` |
|
||||||
|
|
||||||
|
`Transport.SourceEnvironment` is the field that ends up stamped into exported bundle manifests, so a bundle visibly self-identifies which environment produced it.
|
||||||
|
|
||||||
|
## Lifecycle Scripts
|
||||||
|
|
||||||
|
### `docker-env2/deploy.sh`
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== ScadaLink Env2 Docker Deploy ==="
|
||||||
|
|
||||||
|
# Reuse the primary build (same scadalink:latest image)
|
||||||
|
"$SCRIPT_DIR/../docker/build.sh"
|
||||||
|
|
||||||
|
# Ensure env2 databases exist on the shared scadalink-mssql
|
||||||
|
"$SCRIPT_DIR/init-db.sh"
|
||||||
|
|
||||||
|
echo "Deploying env2 containers..."
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d --force-recreate
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" ps
|
||||||
|
|
||||||
|
echo "Access points:"
|
||||||
|
echo " Central (Traefik LB): http://localhost:9100"
|
||||||
|
echo " Central UI (node A): http://localhost:9101"
|
||||||
|
echo " Central UI (node B): http://localhost:9102"
|
||||||
|
echo " Traefik dashboard: http://localhost:8181"
|
||||||
|
echo "Seed site: docker-env2/seed-sites.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `docker-env2/init-db.sh`
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q '^scadalink-mssql$'; then
|
||||||
|
echo "ERROR: scadalink-mssql is not running. Start it: cd infra && docker compose up -d" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying env2 database setup..."
|
||||||
|
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
< "$SCRIPT_DIR/../infra/mssql/setup-env2.sql"
|
||||||
|
|
||||||
|
echo "Env2 databases ready."
|
||||||
|
```
|
||||||
|
|
||||||
|
### `docker-env2/seed-sites.sh`
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
CLI="dotnet run --project $PROJECT_ROOT/src/ScadaLink.CLI --"
|
||||||
|
AUTH="--username multi-role --password password"
|
||||||
|
URL="--url http://localhost:9100"
|
||||||
|
|
||||||
|
echo "Creating Site-X on env2..."
|
||||||
|
$CLI $URL $AUTH site create \
|
||||||
|
--name "Env2 Site X" \
|
||||||
|
--identifier "site-x" \
|
||||||
|
--description "Env2 test site - two-node cluster" \
|
||||||
|
--node-a-address "akka.tcp://scadalink@scadalink-env2-site-x-a:8082" \
|
||||||
|
--node-b-address "akka.tcp://scadalink@scadalink-env2-site-x-b:8082" \
|
||||||
|
--grpc-node-a-address "http://scadalink-env2-site-x-a:8083" \
|
||||||
|
--grpc-node-b-address "http://scadalink-env2-site-x-b:8083" \
|
||||||
|
|| echo " (Site-X may already exist)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `docker-env2/teardown.sh`
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operator Workflow
|
||||||
|
|
||||||
|
| Action | Command(s) |
|
||||||
|
|----------------------------------|-----------|
|
||||||
|
| First-time env2 bring-up | `bash docker-env2/deploy.sh && bash docker-env2/seed-sites.sh` |
|
||||||
|
| Iterate on env2 after code edit | `bash docker-env2/deploy.sh` |
|
||||||
|
| Iterate on both envs | `bash docker/deploy.sh && bash docker-env2/deploy.sh` (build cached on 2nd) |
|
||||||
|
| Wipe env2 DB for clean re-import | `docker exec scadalink-mssql sqlcmd ... DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;` then `bash docker-env2/deploy.sh` |
|
||||||
|
| Stop env2 only | `bash docker-env2/teardown.sh` |
|
||||||
|
|
||||||
|
## Transport Testing Workflow — The Whole Point of env2
|
||||||
|
|
||||||
|
**Golden-path demo:**
|
||||||
|
|
||||||
|
1. Set up primary with at least a few templates + one deployed instance.
|
||||||
|
2. `bash docker-env2/deploy.sh && bash docker-env2/seed-sites.sh` — env2 ConfigurationDB is empty.
|
||||||
|
3. Browser → http://localhost:9000 → `multi-role` login → **Design → Export Bundle** → select templates → review → set passphrase → download `.scadabundle`.
|
||||||
|
4. Browser → http://localhost:9101 → log in → **Admin → Import Bundle** → upload file → enter passphrase → review diff (all "Create" rows) → confirm.
|
||||||
|
5. Verify: env2's `Design → Templates` shows imported items; `Audit → Configuration Audit Log` shows rows tagged with the matching `BundleImportId`.
|
||||||
|
6. Deploy an imported template to env2's `site-x` to prove runtime-validity end-to-end.
|
||||||
|
|
||||||
|
**Manual tests env2 enables that mock-based tests cannot:**
|
||||||
|
- Conflict-resolution UI on re-import (Skip / Overwrite / Rename per row).
|
||||||
|
- Cross-environment audit correlation via `BundleImportId` chip.
|
||||||
|
- Schema-version gating (`SchemaVersionMajor` mismatch).
|
||||||
|
- Wrong-passphrase rejection + `MaxUnlockAttemptsPerSession=3` lockout.
|
||||||
|
- Round-trip parity: export from primary → import into env2 → export from env2 → re-import into primary with Skip-on-conflict. Revision hashes should match.
|
||||||
|
|
||||||
|
**What env2 does NOT test:**
|
||||||
|
- Multi-site Transport scenarios (env2 has one site by design).
|
||||||
|
- Site-clustered Transport flows (Transport is central-only).
|
||||||
|
- True air-gapped network isolation (env2 shares MSSQL/LDAP/SMTP — out of scope).
|
||||||
|
|
||||||
|
## Error Handling & Edge Cases
|
||||||
|
|
||||||
|
- **`init-db.sh`** fails fast with a clear message if `scadalink-mssql` isn't running.
|
||||||
|
- **`deploy.sh`** runs with `set -euo pipefail` so any failed step halts cleanly.
|
||||||
|
- **MSSQL volume reset** — both the docker-entrypoint mount and the exec-based `init-db.sh` apply the same idempotent script; either path leaves env2 DBs ready.
|
||||||
|
- **Cluster cross-talk** — primary and env2 use the same Akka system name `scadalink` but disjoint seed-node hostnames, so the gossip protocols cannot merge. Defensive: env2 appsettings are written from scratch, not sed'd from primary.
|
||||||
|
- **gRPC streaming** — env2 central uses container-name DNS (`http://scadalink-env2-site-x-a:8083`) for site-x streams, populated by `seed-sites.sh`.
|
||||||
|
- **Cookie/JWT bleed** — different `JwtSigningKey` + different host origins (`localhost:9000` vs `localhost:9100`) mean sessions cannot cross envs.
|
||||||
|
- **Port collision** — host port range `91XX` non-overlapping with primary's `90XX`; confirmed all 10 ports free at design time. If an operator later remaps, Compose surfaces `bind: address already in use`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**No new automated tests are added.** This is infrastructure tooling — the Transport feature already has 39 unit + 26 integration tests. The deliverable is a manual verification checklist at `docs/plans/2026-05-24-second-environment-verification.md` mirroring the Transport manual checklist, walking through the Section 5 golden path.
|
||||||
|
|
||||||
|
**First-deploy smoke test:**
|
||||||
|
1. `docker ps` shows 5 new `scadalink-env2-*` containers.
|
||||||
|
2. `curl http://localhost:9101/health/ready` returns green.
|
||||||
|
3. `curl http://localhost:9100/health/active` Traefik routes to active node.
|
||||||
|
4. Browser to http://localhost:9100 → `multi-role` login → Dashboard renders, Sites page is empty.
|
||||||
|
5. Run `docker-env2/seed-sites.sh` → site-x appears; health turns green within ~30s.
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
- New: `docker-env2/README.md` — operator quick-start, copying the structure of `docker/README.md`.
|
||||||
|
- Update: `README.md` (project root) — add a "Second Environment" callout pointing to `docker-env2/README.md` with a one-sentence purpose statement.
|
||||||
|
- Update: `CLAUDE.md` — add `docker-env2/` to the Project Structure section so future sessions discover it.
|
||||||
|
- Update: `infra/README.md` — note that `setup-env2.sql` is mounted alongside `setup.sql`.
|
||||||
|
|
||||||
|
## Out-of-Scope Future Extensions
|
||||||
|
|
||||||
|
- A third / fourth environment by the same pattern (just bump prefix + port offset).
|
||||||
|
- `--env` flag retrofit on `docker/deploy.sh` if the directory duplication grows painful — not worth doing for just two environments.
|
||||||
|
- Air-gapped twin with its own MSSQL/LDAP/SMTP — straightforward extension of the same pattern if isolation requirements ever tighten.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `bash docker-env2/deploy.sh` brings up 5 containers cleanly on a machine where primary is already running.
|
||||||
|
- [ ] `bash docker-env2/seed-sites.sh` registers `site-x` and the site cluster reaches healthy state.
|
||||||
|
- [ ] http://localhost:9100 serves the env2 Central UI through Traefik with failover between 9101/9102.
|
||||||
|
- [ ] env2 reads/writes only `ScadaLinkConfig2` / `ScadaLinkMachineData2`; primary's DBs untouched after env2 deploy.
|
||||||
|
- [ ] `bash docker/deploy.sh && bash docker-env2/deploy.sh` succeeds in sequence; both stacks run concurrently.
|
||||||
|
- [ ] A bundle exported from primary can be imported into env2, with audit rows tagged by `BundleImportId` and visible in env2's Configuration Audit Log.
|
||||||
|
- [ ] Manual verification checklist completes end-to-end.
|
||||||
91
docs/plans/2026-05-24-second-environment-verification.md
Normal file
91
docs/plans/2026-05-24-second-environment-verification.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Env2 + Transport Manual Verification Checklist
|
||||||
|
|
||||||
|
**Date created:** 2026-05-24
|
||||||
|
**Companion to:** [`2026-05-24-second-environment-design.md`](2026-05-24-second-environment-design.md), [`Component-Transport.md`](../requirements/Component-Transport.md)
|
||||||
|
**Goal:** Exercise the Transport (#24) bundle export/import flow against two real running environments (primary + env2).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `infra/` services running: `cd infra && docker compose up -d`
|
||||||
|
- Primary stack running and healthy: `bash docker/deploy.sh && bash docker/seed-sites.sh`
|
||||||
|
- `scadalink-mssql` reachable (`docker ps | grep scadalink-mssql`)
|
||||||
|
|
||||||
|
## Step 0: Bring up env2
|
||||||
|
|
||||||
|
- [ ] `bash docker-env2/deploy.sh` completes without error
|
||||||
|
- [ ] `docker ps --format '{{.Names}}'` shows 5 new `scadalink-env2-*` containers
|
||||||
|
- [ ] `curl -s http://localhost:9101/health/ready` returns 200 with `"status": "Healthy"`
|
||||||
|
- [ ] `curl -s http://localhost:9100/health/active` is routed through Traefik
|
||||||
|
- [ ] http://localhost:9100 loads the env2 login page in a browser
|
||||||
|
- [ ] Login as `multi-role` / `password` succeeds
|
||||||
|
- [ ] Env2 dashboard renders, Sites page is empty
|
||||||
|
|
||||||
|
## Step 1: Seed env2's test site
|
||||||
|
|
||||||
|
- [ ] `bash docker-env2/seed-sites.sh` completes without error
|
||||||
|
- [ ] Env2 UI → Sites page shows `site-x` (Env2 Site X)
|
||||||
|
- [ ] Within ~30s, site-x health turns green
|
||||||
|
|
||||||
|
## Step 2: Prepare bundle source on primary
|
||||||
|
|
||||||
|
- [ ] Primary UI (http://localhost:9000) → Design → Templates contains at least one template (create one if empty)
|
||||||
|
- [ ] Optional: prepare at least one shared script and one external system for fuller round-trip coverage
|
||||||
|
|
||||||
|
## Step 3: Export from primary
|
||||||
|
|
||||||
|
- [ ] Primary UI → Design → **Export Bundle**
|
||||||
|
- [ ] Selection step: tick at least one template (folder + items)
|
||||||
|
- [ ] Review step: confirm selected entity counts
|
||||||
|
- [ ] Encrypt step: set passphrase `test-passphrase-1`
|
||||||
|
- [ ] Download step: receive a `.scadabundle` file (note exact filename)
|
||||||
|
- [ ] Verify the manifest's `SourceEnvironment` field equals `docker-cluster` (peek inside the zip; `manifest.json` is plaintext)
|
||||||
|
|
||||||
|
## Step 4: Import into env2
|
||||||
|
|
||||||
|
- [ ] Env2 UI (http://localhost:9101) → Admin → **Import Bundle**
|
||||||
|
- [ ] Upload step: select the bundle file from Step 3
|
||||||
|
- [ ] Passphrase step: enter `test-passphrase-1` → diff loads successfully
|
||||||
|
- [ ] Diff step: all rows are "Create" (env2 was empty for these entities)
|
||||||
|
- [ ] Confirm step: import succeeds; result step shows the per-row outcome
|
||||||
|
- [ ] Env2 audit log row(s) are tagged with the bundle's `BundleImportId`
|
||||||
|
|
||||||
|
## Step 5: Verify imported artifacts in env2
|
||||||
|
|
||||||
|
- [ ] Env2 UI → Design → Templates shows the imported template(s)
|
||||||
|
- [ ] Env2 UI → Audit → Configuration Audit Log filtered by `BundleImportId` shows the import events
|
||||||
|
- [ ] (Optional) Deploy an imported template to env2's `site-x` to prove runtime validity
|
||||||
|
|
||||||
|
## Step 6: Conflict-resolution probe
|
||||||
|
|
||||||
|
- [ ] Re-upload the same `.scadabundle` to env2 with the same passphrase
|
||||||
|
- [ ] Diff step: all rows are now "Update"
|
||||||
|
- [ ] Pick mixed Skip / Overwrite / Rename per row; confirm
|
||||||
|
- [ ] Verify the chosen resolutions land correctly (Skip rows unchanged, Overwrite rows match the bundle, Rename rows present under the new name)
|
||||||
|
|
||||||
|
## Negative tests
|
||||||
|
|
||||||
|
- [ ] Wrong passphrase: try `wrong-passphrase` → friendly error, no diff loaded
|
||||||
|
- [ ] Wrong passphrase 3 times in one session → session lockout enforced (`MaxUnlockAttemptsPerSession`)
|
||||||
|
- [ ] Bump primary's `Transport.SchemaVersionMajor` to 99, export, attempt env2 import → schema-version mismatch error
|
||||||
|
- [ ] Tamper with `manifest.json` content hash (unzip → edit → re-zip) → content-hash mismatch error
|
||||||
|
|
||||||
|
## Round-trip parity
|
||||||
|
|
||||||
|
- [ ] Export the same templates from env2 (with `Transport.SourceEnvironment` = `docker-cluster-env2`)
|
||||||
|
- [ ] Re-import into primary with Skip-on-conflict for all rows
|
||||||
|
- [ ] Primary's audit log shows no actual mutations
|
||||||
|
- [ ] Primary's template revision hashes are unchanged after the no-op import
|
||||||
|
|
||||||
|
## Cleanup (optional)
|
||||||
|
|
||||||
|
- [ ] `bash docker-env2/teardown.sh` stops env2 containers
|
||||||
|
- [ ] Drop env2 databases if needed:
|
||||||
|
```bash
|
||||||
|
docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
-Q "DROP DATABASE ScadaLinkConfig2; DROP DATABASE ScadaLinkMachineData2;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pass criteria
|
||||||
|
|
||||||
|
All checkboxes ticked, no defects found, both stacks remain healthy after the run.
|
||||||
1238
docs/plans/2026-05-24-second-environment.md
Normal file
1238
docs/plans/2026-05-24-second-environment.md
Normal file
File diff suppressed because it is too large
Load Diff
17
docs/plans/2026-05-24-second-environment.md.tasks.json
Normal file
17
docs/plans/2026-05-24-second-environment.md.tasks.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-24-second-environment.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 47, "subject": "T0: MSSQL setup-env2.sql + mount", "status": "completed"},
|
||||||
|
{"id": 48, "subject": "T1: Traefik config for env2", "status": "completed"},
|
||||||
|
{"id": 49, "subject": "T2: Central node appsettings (a + b)", "status": "completed"},
|
||||||
|
{"id": 50, "subject": "T3: Site-x node appsettings (a + b)", "status": "completed"},
|
||||||
|
{"id": 51, "subject": "T4: docker-env2/docker-compose.yml", "status": "completed"},
|
||||||
|
{"id": 52, "subject": "T5: Lifecycle scripts (init-db, deploy, teardown, seed-sites)", "status": "completed", "blockedBy": [47, 51]},
|
||||||
|
{"id": 53, "subject": "T6: .gitignore updates for env2 data/logs", "status": "completed"},
|
||||||
|
{"id": 54, "subject": "T7: docker-env2/README.md", "status": "completed"},
|
||||||
|
{"id": 55, "subject": "T8: Cross-reference doc updates (root README + CLAUDE.md + infra/README.md)", "status": "completed"},
|
||||||
|
{"id": 56, "subject": "T9: Verification checklist file", "status": "completed"},
|
||||||
|
{"id": 57, "subject": "T10: First-deploy smoke test (manual)", "status": "completed", "blockedBy": [47, 48, 49, 50, 51, 52, 53, 54, 55, 56]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-24T07:25:00Z"
|
||||||
|
}
|
||||||
423
docs/plans/2026-05-24-transport-design.md
Normal file
423
docs/plans/2026-05-24-transport-design.md
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
# Transport — Bundle Export / Import (Component #24) — Design
|
||||||
|
|
||||||
|
**Status:** Approved (brainstorming complete, awaiting implementation plan).
|
||||||
|
**Author session date:** 2026-05-24.
|
||||||
|
**Target component number:** #24 (Transport).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Provide a file-based, encrypted, environment-agnostic way to **promote configuration artifacts from one ScadaLink cluster to another** (e.g., dev → staging → prod) through the Central UI.
|
||||||
|
|
||||||
|
A user with the `Design` role on the source cluster exports a selected set of templates and supporting artifacts to a `.scadabundle` file. A user with the `Admin` role on the target cluster uploads the bundle, reviews a diff, resolves conflicts, and applies it. Import is **config-only**: it updates the central configuration database and marks affected instances stale; the user redeploys to sites via the existing Deployments page.
|
||||||
|
|
||||||
|
Transport does **not** touch site nodes, does not move runtime state, and does not move site-scoped artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Location
|
||||||
|
|
||||||
|
- New project: `src/ScadaLink.Transport/`
|
||||||
|
- New tests: `tests/ScadaLink.Transport.Tests/`, `tests/ScadaLink.Transport.IntegrationTests/`
|
||||||
|
- New design doc: `docs/requirements/Component-Transport.md` (created as part of implementation).
|
||||||
|
- Central UI pages: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor`, `TransportImport.razor`.
|
||||||
|
- EF migration in `src/ScadaLink.ConfigurationDatabase/Migrations/` (adds `BundleImportId` column to `ConfigurationAuditLog`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Scope
|
||||||
|
|
||||||
|
### 3.1 In scope — transportable artifact groups
|
||||||
|
|
||||||
|
| Group | Entities |
|
||||||
|
|---|---|
|
||||||
|
| Templates | `Template`, `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, `TemplateFolder` |
|
||||||
|
| System artifacts | `SharedScript`, `ExternalSystemDefinition` + `ExternalSystemMethod`, `DatabaseConnectionDefinition` |
|
||||||
|
| Central-only | `NotificationList` + recipients, `SmtpConfiguration`, `ApiKey`, `ApiMethod` |
|
||||||
|
|
||||||
|
### 3.2 Out of scope (never bundled)
|
||||||
|
|
||||||
|
- **Site-scoped:** `Site`, `Area`, `Instance`, `InstanceAttributeOverride`, `InstanceAlarmOverride`, `InstanceConnectionBinding`, `DataConnection`.
|
||||||
|
- **Operational state:** `DeploymentRecord`, `SystemArtifactDeploymentRecord`, `DeployedConfigSnapshot`, all audit tables, S&F buffers.
|
||||||
|
- **Cluster identity / auth state:** LDAP mappings, site-scope rules.
|
||||||
|
|
||||||
|
Rationale: environments do not share stable site identities (dev "Site-Dev-1" vs. prod "Plant-North"). Migrating instances would require a name-mapping subsystem, deferred until there is concrete user demand.
|
||||||
|
|
||||||
|
### 3.3 Non-goals (explicit v1 exclusions)
|
||||||
|
|
||||||
|
- Direct cluster-to-cluster pull (file-based only).
|
||||||
|
- Auto-redeploy on import.
|
||||||
|
- Bundle signing (separate keypair); manifest SHA-256 is sufficient for v1 tamper detection.
|
||||||
|
- Differential / incremental bundles — every export is a full snapshot of selected artifacts.
|
||||||
|
- Server-side bundle library / browsing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bundle Format
|
||||||
|
|
||||||
|
### 4.1 File layout
|
||||||
|
|
||||||
|
`.scadabundle` is a renamed `.zip`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bundle.scadabundle
|
||||||
|
├── manifest.json # required, NOT encrypted
|
||||||
|
├── content.json # plaintext artifact data (when no passphrase)
|
||||||
|
├── content.enc # encrypted artifact data (when passphrase set)
|
||||||
|
└── scripts/ # optional: large script bodies as files
|
||||||
|
├── template-{id}-{name}.cs
|
||||||
|
└── shared-{id}-{name}.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one of `content.json` or `content.enc` is present.
|
||||||
|
|
||||||
|
### 4.2 `manifest.json` (plaintext)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bundleFormatVersion": 1,
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"createdAtUtc": "2026-05-24T12:34:56Z",
|
||||||
|
"sourceEnvironment": "dev-cluster-a",
|
||||||
|
"exportedBy": "alice@corp.example",
|
||||||
|
"scadaLinkVersion": "1.4.2",
|
||||||
|
"contentHash": "sha256:...",
|
||||||
|
"encryption": {
|
||||||
|
"algorithm": "AES-256-GCM",
|
||||||
|
"kdf": "PBKDF2-SHA256",
|
||||||
|
"iterations": 600000,
|
||||||
|
"saltB64": "...",
|
||||||
|
"ivB64": "..."
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
|
||||||
|
"externalSystems": 2, "dbConnections": 1,
|
||||||
|
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5
|
||||||
|
},
|
||||||
|
"contents": [
|
||||||
|
{ "type": "Template", "name": "Pump", "version": 5,
|
||||||
|
"dependsOn": ["SharedScript:PumpUtils"] },
|
||||||
|
{ "type": "Template", "name": "Pump.WaterPump", "version": 3,
|
||||||
|
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is plaintext so the import wizard can preview bundle contents and source provenance **before** the user supplies a passphrase.
|
||||||
|
|
||||||
|
### 4.3 `content.json` / `content.enc`
|
||||||
|
|
||||||
|
- One top-level array per entity type, POCO shapes serialized via `System.Text.Json`.
|
||||||
|
- Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested `secrets` block on each affected entity.
|
||||||
|
- The whole `content` blob is AES-256-GCM encrypted with a key derived via PBKDF2-SHA256 (600 000 iterations) from the user's passphrase, with per-bundle random salt and per-encryption random IV.
|
||||||
|
- Unencrypted bundles are allowed but UI warns and audit-tags them `UnencryptedBundleExport`.
|
||||||
|
|
||||||
|
### 4.4 Forward compatibility
|
||||||
|
|
||||||
|
- Unknown top-level entity types in `contents[]` surface in the import preview as "skipped — unsupported in this version" rather than failing the whole import.
|
||||||
|
- `bundleFormatVersion` newer than what the importer supports → hard refusal at upload with a clear upgrade message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture (Option A — new component)
|
||||||
|
|
||||||
|
```
|
||||||
|
ScadaLink.Transport
|
||||||
|
├── IBundleExporter
|
||||||
|
│ ExportAsync(ExportSelection, Passphrase?, ct) → Stream
|
||||||
|
├── IBundleImporter
|
||||||
|
│ LoadAsync(stream, Passphrase?, ct) → BundleSession
|
||||||
|
│ PreviewAsync(sessionId, ct) → ImportPreview
|
||||||
|
│ ApplyAsync(sessionId, resolutions, ct) → ImportResult
|
||||||
|
├── DependencyResolver
|
||||||
|
├── BundleSerializer (manifest + content JSON; ZIP packer)
|
||||||
|
├── SecretEncryptor (AES-256-GCM + PBKDF2)
|
||||||
|
├── BundleSessionStore (in-memory, TTL'd)
|
||||||
|
└── ManifestValidator (schema/version gating, hash check)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Component is **central-only**. Registered in `ScadaLink.Host` for central roles, never for site roles.
|
||||||
|
- All persistence goes through **existing audited repository interfaces** in `ScadaLink.ConfigurationDatabase` — no raw `DbContext.SaveChangesAsync` from this component.
|
||||||
|
- `BundleSessionStore` is in-process on the active central node (matches Blazor Server circuit affinity). 30-minute TTL, GC on expiry, 3-strike passphrase lockout per session.
|
||||||
|
|
||||||
|
Rejected alternatives:
|
||||||
|
- **Option B (extend Template Engine + Deployment Manager):** spreads bundle format and encryption knowledge across components with no shared owner. Cross-artifact dependency resolution has no clean home.
|
||||||
|
- **Option C (Central-UI-only service):** CLI cannot reuse it (CLI talks to the Management Service). Violates the codebase pattern of keeping UI thin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Export Flow
|
||||||
|
|
||||||
|
### 6.1 UI — 4-step wizard under the Design nav group
|
||||||
|
|
||||||
|
**Step 1 — Select artifacts.** Templates rendered as a **tree** matching the existing Templates page (reuse the tree component from `docs/plans/2026-03-23-treeview-component.md`, refactored into a shared `TemplateFolderTree.razor` with a new "checkbox selection" mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, API keys, API methods) are flat lists — no folder hierarchy in their data model.
|
||||||
|
|
||||||
|
**Step 2 — Review dependencies.** The resolver expands the user's selection along these edges:
|
||||||
|
|
||||||
|
- `Template A` composes `Template B` → include `B`.
|
||||||
|
- `Template` references `SharedScript` (by name) → include the script.
|
||||||
|
- `Template` references `ExternalSystem` → include the def and its methods.
|
||||||
|
- `ApiMethod` references `SharedScript` → include the script.
|
||||||
|
- `NotificationList` references `SmtpConfiguration` → include the SMTP config.
|
||||||
|
- Any folder containing a selected template is included so the structure is reproducible on import.
|
||||||
|
|
||||||
|
User can toggle "include all dependencies" off (with warning that the bundle may produce an invalid import).
|
||||||
|
|
||||||
|
**Step 3 — Encryption.** Passphrase + confirm. Strength meter. Explicit warning lists every secret field being encrypted. Optional "Export without encryption" path is logged with the `UnencryptedBundleExport` audit flag.
|
||||||
|
|
||||||
|
**Step 4 — Download.** Generated filename pattern: `scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle`. SHA-256 displayed for out-of-band verification.
|
||||||
|
|
||||||
|
### 6.2 Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
User (Design role) ─► Central UI Export wizard
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IBundleExporter
|
||||||
|
│
|
||||||
|
├─► DependencyResolver ─► repositories (read)
|
||||||
|
├─► EntitySerializer ─► content.json
|
||||||
|
├─► SecretEncryptor ─► content.enc (if passphrase)
|
||||||
|
├─► ManifestBuilder ─► manifest.json
|
||||||
|
▼
|
||||||
|
ZIP packer → temp file → browser download
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IAuditService.LogAsync(BundleExported …)
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit event: `BundleExported` — caller, artifact count, content hash, encrypted yes/no, bundle filename.
|
||||||
|
|
||||||
|
Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.ExportAsync` (defense in depth).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Import Flow
|
||||||
|
|
||||||
|
### 7.1 UI — 5-step wizard under the Design nav group
|
||||||
|
|
||||||
|
**Step 1 — Upload.** Drag-and-drop or browse. On selection, manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). Manifest hash validated against `content` blob.
|
||||||
|
|
||||||
|
**Step 2 — Passphrase** (skipped if bundle is unencrypted). 3-wrong-attempt lockout invalidates the session.
|
||||||
|
|
||||||
|
**Step 3 — Diff & resolve conflicts.** For each artifact in the bundle, compare to existing by name:
|
||||||
|
|
||||||
|
- **Identical** (field-by-field) → marked, auto-skipped, cannot be selected.
|
||||||
|
- **Modified** → shows side-by-side or `+/-/~` line diff. User picks **Skip / Overwrite / Rename**.
|
||||||
|
- **New** → marked `+ Add`. User can opt to skip individually.
|
||||||
|
|
||||||
|
Bulk "Apply to all" at the top (Overwrite / Skip / Rename), overridable per row. Summary line at bottom: "5 add · 2 overwrite · 1 skip · 0 rename".
|
||||||
|
|
||||||
|
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that's neither in the bundle nor pre-existing) appear as **blocker rows** — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies).
|
||||||
|
|
||||||
|
**Step 4 — Confirm.** Final summary plus a **"3 instances will become stale"** warning enumerating affected instances. User types the **source environment name** to confirm (typo-resistant gate at the prod boundary).
|
||||||
|
|
||||||
|
**Step 5 — Result.** Counts, link to `BundleImported` audit row, link to Deployments page filtered to the newly stale instances.
|
||||||
|
|
||||||
|
### 7.2 Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
User (Admin role) ─► uploads bundle
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IBundleImporter.LoadAsync
|
||||||
|
· verify SHA-256 (manifest vs content)
|
||||||
|
· check bundleFormatVersion supported
|
||||||
|
· decrypt content.enc with passphrase (if encrypted)
|
||||||
|
· deserialize entities
|
||||||
|
· open BundleSession (30-min TTL)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PreviewAsync → diff vs target DB → ImportPreview
|
||||||
|
│
|
||||||
|
▼ (user reviews + resolves conflicts)
|
||||||
|
│
|
||||||
|
ApplyAsync (single EF transaction)
|
||||||
|
· run pre-deployment semantic validator (Template Engine)
|
||||||
|
· apply resolutions (add / overwrite / skip / rename)
|
||||||
|
· upsert TemplateFolder hierarchy
|
||||||
|
· stale-mark affected Instances
|
||||||
|
· IAuditService.LogAsync(BundleImported …)
|
||||||
|
· commit
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ImportResult → UI step 5
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
"View on Deployments →" (existing page)
|
||||||
|
```
|
||||||
|
|
||||||
|
Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.
|
||||||
|
|
||||||
|
### 7.3 Stale-instance signaling
|
||||||
|
|
||||||
|
When an applied conflict overwrites a template (or a template composed by other templates), every `Instance` whose direct or composed template was overwritten gets its `DeployedRevisionHash` set to `stale` (reusing the existing revision-drift mechanism in Deployment Manager). The Deployments page already surfaces stale instances and offers redeploy; no new UI is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Error Handling
|
||||||
|
|
||||||
|
| Where | Failure | Surfaced as |
|
||||||
|
|---|---|---|
|
||||||
|
| Upload | Not a zip / missing `manifest.json` | Step 1 error: "Not a valid ScadaLink bundle" |
|
||||||
|
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaLink v{x}; upgrade this cluster" |
|
||||||
|
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
|
||||||
|
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
|
||||||
|
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in Step 3; cannot Apply until resolved |
|
||||||
|
| Apply | Semantic validation fails (call target type mismatch, etc.) | Modal: "Validation failed — N errors", per-error list, no DB writes |
|
||||||
|
| Apply | DB transaction fails | Rollback; full transactional guarantee — nothing partial lands; `BundleImportFailed` audit row written outside the rolled-back transaction |
|
||||||
|
| Session | TTL expired | Step 3+ refresh prompts re-upload |
|
||||||
|
|
||||||
|
Imports are **all-or-nothing per bundle.** A bundle either applies fully or not at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Security
|
||||||
|
|
||||||
|
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. GCM auth tag verified before decryption — wrong passphrase fails cleanly.
|
||||||
|
- **Passphrase never persisted.** Lives only inside the export/import service call path; discarded after use. No environment variable, no log line.
|
||||||
|
- **Failed-unlock rate limit:** per-session 3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row.
|
||||||
|
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
|
||||||
|
- **In-transit:** existing HTTPS to Central UI; no new channel.
|
||||||
|
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
|
||||||
|
- **Defense in depth on authorization:** `RequireDesign` (export) and `RequireAdmin` (import) enforced both on the Razor page and inside `ScadaLink.Transport` service entrypoints. UI is not the only gate.
|
||||||
|
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (import).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Configuration Audit Trail
|
||||||
|
|
||||||
|
Import flows through the **same audited repository methods** the UI and CLI use, so every artifact mutated by `ApplyAsync` emits the existing per-entity `ConfigurationAuditLog` row:
|
||||||
|
|
||||||
|
| Action during import | `ConfigurationAuditLog` rows emitted |
|
||||||
|
|---|---|
|
||||||
|
| Template added | `TemplateCreated` + `TemplateAttributeCreated` (×N) + `TemplateScriptCreated` (×N) + … |
|
||||||
|
| Template overwritten | `TemplateUpdated` + per-field rows (`TemplateAttributeAdded`, `TemplateScriptUpdated`, …) |
|
||||||
|
| Template skipped | (no rows) |
|
||||||
|
| Template renamed-on-import | `TemplateCreated` with the new name (existing row untouched) |
|
||||||
|
| External system overwritten | `ExternalSystemDefinitionUpdated` + per-method rows |
|
||||||
|
| Notification list added | `NotificationListCreated` + per-recipient rows |
|
||||||
|
| API key added | `ApiKeyCreated` |
|
||||||
|
| Instance marked stale | `InstanceMarkedStale` (existing event) |
|
||||||
|
|
||||||
|
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing `ConfigurationAuditLog` Central UI page gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.
|
||||||
|
|
||||||
|
**Schema change:** one EF migration adds:
|
||||||
|
|
||||||
|
- `BundleImportId uniqueidentifier NULL` on `ConfigurationAuditLog`.
|
||||||
|
- Non-clustered index `IX_ConfigurationAuditLog_BundleImportId`.
|
||||||
|
|
||||||
|
**Transactional guarantee:** the EF transaction wraps both the entity writes and the audit log writes (existing pattern). A rollback removes both. The `BundleImported` summary row is the final write inside the transaction, so partial audit trails are impossible. A failed import emits a single `BundleImportFailed` row outside the rolled-back transaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Observability
|
||||||
|
|
||||||
|
- Structured log events: `transport.export.completed`, `transport.import.applied`, `transport.import.failed`, `transport.import.unlock_failed`.
|
||||||
|
- Counters: bundle size (bytes), artifact count, decryption failures per hour, sessions opened/expired.
|
||||||
|
- New `BundleImported` and `BundleImportFailed` audit row types appear in the existing audit log page filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Authorization Summary
|
||||||
|
|
||||||
|
| Operation | Required role | Enforced at |
|
||||||
|
|---|---|---|
|
||||||
|
| Open Export page | `RequireDesign` | Razor page authorize attribute |
|
||||||
|
| `IBundleExporter.ExportAsync` | `RequireDesign` | Service entrypoint |
|
||||||
|
| Open Import page | `RequireAdmin` | Razor page authorize attribute |
|
||||||
|
| `IBundleImporter.LoadAsync` / `PreviewAsync` / `ApplyAsync` | `RequireAdmin` | Service entrypoint |
|
||||||
|
| `ConfigurationAuditLog` "Bundle Import" filter | `RequireAdmin` or `Audit` | Existing audit page logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. CLI (Deferred)
|
||||||
|
|
||||||
|
The `ScadaLink.Transport` library is callable from both Razor pages and `ScadaLink.CLI`. CLI commands are **not** built in v1 but the design leaves a clean path:
|
||||||
|
|
||||||
|
```
|
||||||
|
scadalink transport export \
|
||||||
|
--templates Pump,Pump.WaterPump \
|
||||||
|
--shared-scripts PumpUtils \
|
||||||
|
--out bundle.scadabundle \
|
||||||
|
--passphrase-file /run/secrets/p
|
||||||
|
|
||||||
|
scadalink transport import bundle.scadabundle \
|
||||||
|
--passphrase-file /run/secrets/p \
|
||||||
|
--on-conflict overwrite|skip|rename \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Same auth model via the Management API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Test Plan
|
||||||
|
|
||||||
|
| Layer | Project | Coverage |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit | `tests/ScadaLink.Transport.Tests` (new) | `BundleSerializer` round-trip, `DependencyResolver` topology + cycle handling, `SecretEncryptor` (encrypt → decrypt round-trip, wrong passphrase fails, tampered ciphertext fails GCM auth tag), `ManifestBuilder` schema-version gating, `ImportPreview` diff for each entity type (identical / modified / new), per-conflict resolution merge logic, stale-instance cascade through composed templates |
|
||||||
|
| Integration | `tests/ScadaLink.Transport.IntegrationTests` (new) | End-to-end against in-memory + real SQL Server: export → import on same DB (no-op idempotency), export → wipe → import (full restore), export → modify-target → import under each conflict resolution, validation failure rolls back cleanly, audit rows correlated by `BundleImportId` |
|
||||||
|
| UI | `tests/ScadaLink.CentralUI.Tests` | Razor page authorization (`RequireDesign` for export, `RequireAdmin` for import), wizard step navigation, session TTL expiry, tri-state checkbox tree behavior |
|
||||||
|
| Manual | docker cluster | Cross-cluster: bring up two clusters, export from one, import to the other, verify stale instances surface on Deployments page, redeploy works |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Deployment
|
||||||
|
|
||||||
|
- New component registered in `ScadaLink.Host` for central roles only.
|
||||||
|
- No new ports, no new database (uses existing central MS SQL).
|
||||||
|
- One EF migration: `BundleImportId` nullable `uniqueidentifier` column on `ConfigurationAuditLog` + supporting index.
|
||||||
|
- `bash docker/deploy.sh` picks up the change with the standard rebuild + restart — no compose changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Documentation Deliverables
|
||||||
|
|
||||||
|
- **New:** `docs/requirements/Component-Transport.md` — full component design doc following the standard structure (Purpose, Location, Responsibilities, detailed design sections, Dependencies, Interactions).
|
||||||
|
- **Updated:** `README.md` component table — add row #24: "Transport — Bundle export/import for templates, shared scripts, external systems, central-only artifacts; AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail."
|
||||||
|
- **Updated cross-references in:**
|
||||||
|
- `Component-TemplateEngine.md` — mention Transport as a consumer of template + composition + script entities.
|
||||||
|
- `Component-DeploymentManager.md` — mention stale-instance signaling from Transport.
|
||||||
|
- `Component-SecurityAuth.md` — record the role mapping (`RequireDesign` export, `RequireAdmin` import).
|
||||||
|
- `Component-ConfigurationDatabase.md` — record the new `BundleImportId` column + index.
|
||||||
|
- `Component-CentralUI.md` — record the two new pages under the Design nav group.
|
||||||
|
- `Component-Transport.md` includes the bundle JSON schema in an appendix for downstream tooling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Rollout
|
||||||
|
|
||||||
|
- Ship in **one PR / branch**: design doc + component code + UI pages + EF migration + tests + README + cross-reference updates.
|
||||||
|
- Backward compatible — no behavior change for users who never open the new pages.
|
||||||
|
- **No feature flag** (per `CLAUDE.md` guidance — don't add flags for hypothetical toggling).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Open Questions / Future Work
|
||||||
|
|
||||||
|
- **Site-scoped artifact transport** (Instances, Areas, bindings, DataConnections). Requires a name-mapping subsystem (source-env-site → target-env-site). Deferred until concrete demand.
|
||||||
|
- **Direct cluster-to-cluster pull** as an alternative to file handoff. Same `ScadaLink.Transport` library can back it; needs cross-env auth design.
|
||||||
|
- **Bundle signing** with an asymmetric keypair (separate from passphrase encryption) for stronger non-repudiation. Manifest content hash is sufficient for v1 tamper detection.
|
||||||
|
- **CLI commands** (`scadalink transport export/import`). Shape pre-decided in §13; not built in v1.
|
||||||
|
- **Differential bundles** ("only what changed since last export"). YAGNI for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Brainstorming Decisions Captured
|
||||||
|
|
||||||
|
(For traceability to the brainstorming session that produced this doc.)
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Transport model | File-based export/import (.scadabundle) |
|
||||||
|
| Artifact groups | Templates + composition; system artifacts; central-only — site-scoped excluded |
|
||||||
|
| Secrets | Encrypted in bundle via passphrase (AES-256-GCM + PBKDF2 600k) |
|
||||||
|
| Selection granularity | Per-artifact with auto-included dependencies |
|
||||||
|
| Selection UI | Tree view for templates (reuse Templates-page tree); flat lists for other groups |
|
||||||
|
| Conflict resolution | Interactive per-artifact diff + Skip / Overwrite / Rename |
|
||||||
|
| Post-import behavior | Config-only; user redeploys via existing Deployments page |
|
||||||
|
| Authorization | `Design` to export, `Admin` to import |
|
||||||
|
| Audit | Per-entity rows via existing audited repositories, correlated by `BundleImportId` |
|
||||||
|
| Architecture | New component `ScadaLink.Transport` (Option A) |
|
||||||
25
docs/plans/2026-05-24-transport-manual-verification.md
Normal file
25
docs/plans/2026-05-24-transport-manual-verification.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Transport Manual Verification
|
||||||
|
|
||||||
|
This document is a one-time manual verification to be run against the docker cluster after the Transport feature is fully built and `bash docker/deploy.sh` has rebuilt the image. Each step is sequential and assumes the previous step succeeded. The entire flow takes approximately 15 minutes.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- `bash docker/deploy.sh` succeeded (image rebuilt with Transport feature).
|
||||||
|
- `cd infra && docker compose up -d` (LDAP and MS SQL are ready).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Log in to http://localhost:9000 as `multi-role` / `password`.
|
||||||
|
2. Navigate to Design → Export Bundle.
|
||||||
|
3. Select 1 template + 1 shared script. Verify dependency expansion in Step 2.
|
||||||
|
4. Set passphrase "test123". Export. Verify a `.scadabundle` file downloads.
|
||||||
|
5. Log out, log in as `admin` / `password`.
|
||||||
|
6. Navigate to Design → Import Bundle. Upload the bundle from step 4.
|
||||||
|
7. Enter passphrase "test123". Verify the diff page shows the artifacts.
|
||||||
|
8. Apply with Add for all. Confirm by retyping the source environment name.
|
||||||
|
9. Verify Step 5 result page shows counts + links.
|
||||||
|
10. Open Audit → Configuration Audit Log → filter by the `BundleImported` row's id.
|
||||||
|
Verify all per-entity rows from the import are listed and carry the same `BundleImportId`.
|
||||||
|
11. Open the Deployments page → verify any instance that referenced an overwritten
|
||||||
|
template appears stale (revision-hash mismatch).
|
||||||
|
12. Verify wrong passphrase fails cleanly (3 attempts → re-upload required).
|
||||||
1665
docs/plans/2026-05-24-transport.md
Normal file
1665
docs/plans/2026-05-24-transport.md
Normal file
File diff suppressed because it is too large
Load Diff
36
docs/plans/2026-05-24-transport.md.tasks.json
Normal file
36
docs/plans/2026-05-24-transport.md.tasks.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-24-transport.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 7, "subject": "T0: Bundle DTOs in Commons", "status": "pending"},
|
||||||
|
{"id": 8, "subject": "T1: Transport interfaces", "status": "pending"},
|
||||||
|
{"id": 9, "subject": "T2: BundleImportId column on AuditLogEntry", "status": "pending"},
|
||||||
|
{"id": 10, "subject": "T3: EF migration AddBundleImportIdToAuditLog", "status": "pending", "blockedBy": [9]},
|
||||||
|
{"id": 11, "subject": "T4: IAuditCorrelationContext scoped service", "status": "pending"},
|
||||||
|
{"id": 12, "subject": "T5: AuditService reads correlation context", "status": "pending", "blockedBy": [9, 11]},
|
||||||
|
{"id": 13, "subject": "T6: ScadaLink.Transport project skeleton", "status": "pending"},
|
||||||
|
{"id": 14, "subject": "T7: TransportOptions", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 15, "subject": "T8: BundleSecretEncryptor (AES-256-GCM + PBKDF2)", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 16, "subject": "T9: ManifestBuilder + ManifestValidator", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 17, "subject": "T10: EntitySerializer + DTOs + secret carving", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 18, "subject": "T11: BundleSerializer (ZIP packer + reader)", "status": "pending", "blockedBy": [15, 16, 17]},
|
||||||
|
{"id": 19, "subject": "T12: DependencyResolver", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 20, "subject": "T13: BundleSessionStore", "status": "pending", "blockedBy": [13]},
|
||||||
|
{"id": 21, "subject": "T14: BundleExporter.ExportAsync", "status": "pending", "blockedBy": [18, 19]},
|
||||||
|
{"id": 22, "subject": "T15: BundleImporter.LoadAsync", "status": "pending", "blockedBy": [18, 20]},
|
||||||
|
{"id": 23, "subject": "T16: BundleImporter.PreviewAsync (diff engine)", "status": "pending", "blockedBy": [22]},
|
||||||
|
{"id": 24, "subject": "T17: BundleImporter.ApplyAsync (transactional)", "status": "pending", "blockedBy": [12, 23]},
|
||||||
|
{"id": 25, "subject": "T18: Host registration + appsettings binding", "status": "pending", "blockedBy": [24]},
|
||||||
|
{"id": 26, "subject": "T19: TreeView checkbox-selection mode", "status": "pending"},
|
||||||
|
{"id": 27, "subject": "T20: TemplateFolderTree wrapper + Templates.razor refactor", "status": "pending", "blockedBy": [26]},
|
||||||
|
{"id": 28, "subject": "T21: TransportExport.razor wizard", "status": "pending", "blockedBy": [21, 27]},
|
||||||
|
{"id": 29, "subject": "T22: TransportImport.razor wizard", "status": "pending", "blockedBy": [24, 27]},
|
||||||
|
{"id": 30, "subject": "T23: NavMenu Export/Import entries", "status": "pending", "blockedBy": [28, 29]},
|
||||||
|
{"id": 31, "subject": "T24: Audit log Bundle Import filter", "status": "pending", "blockedBy": [9]},
|
||||||
|
{"id": 32, "subject": "T25: Integration round-trip test", "status": "pending", "blockedBy": [24]},
|
||||||
|
{"id": 33, "subject": "T26: Integration conflict + rollback tests", "status": "pending", "blockedBy": [24]},
|
||||||
|
{"id": 34, "subject": "T27: Component-Transport.md design doc", "status": "pending"},
|
||||||
|
{"id": 35, "subject": "T28: README + cross-reference updates", "status": "pending"},
|
||||||
|
{"id": 36, "subject": "T29: Manual cluster verification checklist", "status": "pending", "blockedBy": [25, 29, 30, 31, 32, 33, 34, 35]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-24T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -83,7 +83,10 @@ 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. |
|
||||||
|
| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. |
|
||||||
| `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. |
|
||||||
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
||||||
@@ -93,8 +96,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 +105,10 @@ 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_Node_Occurred (SourceNode, OccurredAtUtc)` — per-node filters ("everything `central-a` did in window X", or pinning a misbehaving site node).
|
||||||
|
- `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,12 +132,51 @@ 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
|
||||||
buffer. Same schema as central minus `IngestedAtUtc` (irrelevant at the source),
|
buffer. Same schema as central minus `IngestedAtUtc` (irrelevant at the source),
|
||||||
plus a `ForwardState` column with values `Pending | Forwarded | Reconciled` that
|
plus a `ForwardState` column with values `Pending | Forwarded | Reconciled` that
|
||||||
drives the telemetry loop and reconciliation pull.
|
drives the telemetry loop and reconciliation pull. `SourceNode` is stamped by the
|
||||||
|
writing node itself (`node-a` / `node-b`) at append time and travels with the row
|
||||||
|
through telemetry and reconciliation unchanged.
|
||||||
|
|
||||||
**Site SQLite retention rule (hard invariant):**
|
**Site SQLite retention rule (hard invariant):**
|
||||||
|
|
||||||
@@ -192,7 +237,9 @@ instead. The Notification Outbox dispatcher writes
|
|||||||
`Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and
|
`Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and
|
||||||
`Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on
|
`Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on
|
||||||
terminal status. Central direct-writes use the same insert-if-not-exists
|
terminal status. Central direct-writes use the same insert-if-not-exists
|
||||||
semantics keyed on `EventId`.
|
semantics keyed on `EventId`. `SourceSiteId` is NULL on all central direct-write
|
||||||
|
rows; `SourceNode` is stamped to the local central node's role name
|
||||||
|
(`central-a` / `central-b`).
|
||||||
|
|
||||||
## Cached Operations — Combined Telemetry
|
## Cached Operations — Combined Telemetry
|
||||||
|
|
||||||
@@ -221,6 +268,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 +435,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`,
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ Central cluster only. Sites have no user interface.
|
|||||||
- Manage inbound API keys (create, enable / disable, delete) and define API methods (name, parameters, return values, approved keys, implementation script).
|
- Manage inbound API keys (create, enable / disable, delete) and define API methods (name, parameters, return values, approved keys, implementation script).
|
||||||
- The API key detail page includes a **"Recent calls"** link that opens the Audit Log page pre-filtered to `Actor = <key name>` and `Channel = ApiInbound` — surfacing the key's recent inbound-call audit history.
|
- The API key detail page includes a **"Recent calls"** link that opens the Audit Log page pre-filtered to `Actor = <key name>` and `Channel = ApiInbound` — surfacing the key's recent inbound-call audit history.
|
||||||
|
|
||||||
|
### Bundle Export / Import (Design Role for export, Admin Role for import)
|
||||||
|
- `Export Bundle` (`/design/transport/export`) — multi-step wizard for exporting a `.scadabundle` artifact containing templates, shared scripts, external systems, and central-only configuration. Visible to users with `RequireDesign`.
|
||||||
|
- `Import Bundle` (`/design/transport/import`) — multi-step wizard for uploading and applying a `.scadabundle` into the current environment, with per-artifact diff review and conflict resolution. Visible to users with `RequireAdmin`.
|
||||||
|
|
||||||
### Area Management (Admin Role)
|
### Area Management (Admin Role)
|
||||||
- Define hierarchical area structures per site.
|
- Define hierarchical area structures per site.
|
||||||
- Parent-child area relationships.
|
- Parent-child area relationships.
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ Template Engine: Update Template
|
|||||||
| **EntityId** | String | Unique identifier of the entity. |
|
| **EntityId** | String | Unique identifier of the entity. |
|
||||||
| **EntityName** | String | Human-readable name (for display without deserializing state). |
|
| **EntityName** | String | Human-readable name (for display without deserializing state). |
|
||||||
| **State** | nvarchar(max) | Entity state after the change, serialized as JSON. Null for deletes. |
|
| **State** | nvarchar(max) | Entity state after the change, serialized as JSON. Null for deletes. |
|
||||||
|
| **BundleImportId** | uniqueidentifier NULL | Correlation column threading every per-entity audit row written during a Transport bundle import back to the parent `BundleImported` event. Indexed via `IX_AuditLogEntries_BundleImportId`. Null for all rows not written as part of a bundle import. |
|
||||||
|
|
||||||
### State Serialization
|
### State Serialization
|
||||||
|
|
||||||
|
|||||||
@@ -151,3 +151,4 @@ The Deployment Manager sends the following commands to sites via the Communicati
|
|||||||
- **Template Engine**: Provides resolved and validated configurations.
|
- **Template Engine**: Provides resolved and validated configurations.
|
||||||
- **Site Runtime**: Receives and applies configurations and lifecycle commands.
|
- **Site Runtime**: Receives and applies configurations and lifecycle commands.
|
||||||
- **Health Monitoring**: Deployment failures contribute to site health status.
|
- **Health Monitoring**: Deployment failures contribute to site health status.
|
||||||
|
- **Transport (#24)**: Does not invoke Deployment Manager directly. Transport-driven template changes propagate to deployed instances through the existing revision-hash drift detection in `DeploymentService.CompareAsync`; the Deployments page surfaces affected instances as stale automatically.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ The table is type-agnostic so it can record any notification type the system sup
|
|||||||
| `LastError` | Detail of the most recent failure. |
|
| `LastError` | Detail of the most recent failure. |
|
||||||
| `ResolvedTargets` | Who the notification actually went to — snapshotted by central at delivery time, for audit. |
|
| `ResolvedTargets` | Who the notification actually went to — snapshotted by central at delivery time, for audit. |
|
||||||
| `SourceSiteId`, `SourceInstanceId`, `SourceScript` | Provenance. |
|
| `SourceSiteId`, `SourceInstanceId`, `SourceScript` | Provenance. |
|
||||||
|
| `SourceNode` | The cluster node on which the notification was enqueued — `node-a` / `node-b` for site-originated rows (qualified by `SourceSiteId`). Nullable. Carried verbatim from the site through the S&F handoff. |
|
||||||
| `SiteEnqueuedAt` | When the script called `Send()` (carried from the site). |
|
| `SiteEnqueuedAt` | When the script called `Send()` (carried from the site). |
|
||||||
| `CreatedAt` | When central ingested the row. |
|
| `CreatedAt` | When central ingested the row. |
|
||||||
| `LastAttemptAt`, `NextAttemptAt`, `DeliveredAt` | Delivery timestamps. |
|
| `LastAttemptAt`, `NextAttemptAt`, `DeliveredAt` | Delivery timestamps. |
|
||||||
|
|||||||
@@ -121,3 +121,4 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind
|
|||||||
- **Deployment Manager**: Deployment role enforcement with site scoping.
|
- **Deployment Manager**: Deployment role enforcement with site scoping.
|
||||||
- **All central components**: Role checks are a cross-cutting concern applied at the API layer.
|
- **All central components**: Role checks are a cross-cutting concern applied at the API layer.
|
||||||
- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. The CLI authenticates users via the same LDAP bind mechanism and passes the user's identity (username, roles, permitted sites) in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side.
|
- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. The CLI authenticates users via the same LDAP bind mechanism and passes the user's identity (username, roles, permitted sites) in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side.
|
||||||
|
- **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ScadaLink.Transport` service entrypoints.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Lives in the central MS SQL configuration database — a sibling of the
|
|||||||
|
|
||||||
- **TrackedOperationId** — GUID, primary key. Generated site-side at call time.
|
- **TrackedOperationId** — GUID, primary key. Generated site-side at call time.
|
||||||
- **SourceSite** — site that issued the call.
|
- **SourceSite** — site that issued the call.
|
||||||
|
- **SourceNode** — the cluster node on which the call was issued (`node-a` /
|
||||||
|
`node-b`, qualified by `SourceSite`). Nullable. Stamped site-side at submit
|
||||||
|
time and carried verbatim through the combined `CachedCallTelemetry` packet,
|
||||||
|
reconciliation pulls, and the central upsert.
|
||||||
- **Kind** — `TrackedOperationKind` enum: `ExternalCall` or `DatabaseWrite`.
|
- **Kind** — `TrackedOperationKind` enum: `ExternalCall` or `DatabaseWrite`.
|
||||||
- **TargetSummary** — external system + method name for an `ExternalCall`; for a
|
- **TargetSummary** — external system + method name for an `ExternalCall`; for a
|
||||||
`DatabaseWrite`, just the database connection name — intentionally not the SQL
|
`DatabaseWrite`, just the database connection name — intentionally not the SQL
|
||||||
|
|||||||
@@ -184,3 +184,4 @@ For shared scripts, pre-compilation validation is performed before deployment. S
|
|||||||
|
|
||||||
- **Deployment Manager**: Requests flattened configurations, diffs, and validation results from the Template Engine.
|
- **Deployment Manager**: Requests flattened configurations, diffs, and validation results from the Template Engine.
|
||||||
- **Central UI**: Provides the data model for template authoring, instance management, and on-demand validation.
|
- **Central UI**: Provides the data model for template authoring, instance management, and on-demand validation.
|
||||||
|
- **Transport (#24)**: Reads templates, attributes, alarms, scripts, and composition relationships for bundle export; writes the same via repositories during bundle import.
|
||||||
|
|||||||
383
docs/requirements/Component-Transport.md
Normal file
383
docs/requirements/Component-Transport.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Component: Transport
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The Transport component provides a file-based, encrypted, environment-agnostic way to promote configuration artifacts from one ScadaLink cluster to another through the Central UI. A user with the Design role on the source cluster exports a selected set of templates and supporting artifacts to a `.scadabundle` file. A user with the Admin role on the target cluster uploads the bundle, reviews a diff, resolves conflicts per artifact, and applies it. Import is config-only: it updates the central configuration database; affected instances surface as stale on the existing Deployments page and the user redeploys via the standard flow. Transport does not touch site nodes, does not move runtime state, and does not move site-scoped artifacts.
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
- New project: `src/ScadaLink.Transport/`
|
||||||
|
- New tests: `tests/ScadaLink.Transport.Tests/`, `tests/ScadaLink.Transport.IntegrationTests/`
|
||||||
|
- Central UI pages: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor`, `TransportImport.razor`
|
||||||
|
- EF migration in `src/ScadaLink.ConfigurationDatabase/Migrations/` (adds `BundleImportId` column to `AuditLogEntries`)
|
||||||
|
- This design doc: `docs/requirements/Component-Transport.md`
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Define and own the `.scadabundle` file format (ZIP container, `manifest.json`, `content.json` / `content.enc`).
|
||||||
|
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, API keys, API methods.
|
||||||
|
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`.
|
||||||
|
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
|
||||||
|
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||||
|
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||||
|
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, or a Blocker.
|
||||||
|
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running two-tier semantic validation before committing: a minimal name-resolution scan over the merged target (fails fast on unresolved SharedScript / ExternalSystem identifiers), then the full `SemanticValidator` from `ScadaLink.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
|
||||||
|
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, and `BundleImportCompositionUnresolved` audit events via `IAuditService`.
|
||||||
|
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
|
||||||
|
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
|
||||||
|
|
||||||
|
## Bundle Format
|
||||||
|
|
||||||
|
### File Layout
|
||||||
|
|
||||||
|
`.scadabundle` is a renamed `.zip`:
|
||||||
|
|
||||||
|
```
|
||||||
|
bundle.scadabundle
|
||||||
|
├── manifest.json # required, NOT encrypted
|
||||||
|
├── content.json # plaintext artifact data (when no passphrase)
|
||||||
|
├── content.enc # encrypted artifact data (when passphrase set)
|
||||||
|
└── scripts/ # optional: large script bodies as files
|
||||||
|
├── template-{id}-{name}.cs
|
||||||
|
└── shared-{id}-{name}.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one of `content.json` or `content.enc` is present.
|
||||||
|
|
||||||
|
### `manifest.json` (plaintext)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bundleFormatVersion": 1,
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"createdAtUtc": "2026-05-24T12:34:56Z",
|
||||||
|
"sourceEnvironment": "dev-cluster-a",
|
||||||
|
"exportedBy": "alice@corp.example",
|
||||||
|
"scadaLinkVersion": "1.4.2",
|
||||||
|
"contentHash": "sha256:...",
|
||||||
|
"encryption": {
|
||||||
|
"algorithm": "AES-256-GCM",
|
||||||
|
"kdf": "PBKDF2-SHA256",
|
||||||
|
"iterations": 600000,
|
||||||
|
"saltB64": "...",
|
||||||
|
"ivB64": "..."
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
|
||||||
|
"externalSystems": 2, "dbConnections": 1,
|
||||||
|
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5
|
||||||
|
},
|
||||||
|
"contents": [
|
||||||
|
{ "type": "Template", "name": "Pump", "version": 5,
|
||||||
|
"dependsOn": ["SharedScript:PumpUtils"] },
|
||||||
|
{ "type": "Template", "name": "Pump.WaterPump", "version": 3,
|
||||||
|
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is plaintext so the import wizard can preview bundle contents and source provenance before the user supplies a passphrase.
|
||||||
|
|
||||||
|
### `content.json` / `content.enc`
|
||||||
|
|
||||||
|
- One top-level array per entity type, POCO shapes serialized via `System.Text.Json`.
|
||||||
|
- Secret fields (API key hashes, SMTP password, external system credentials, DB connection passwords) live in a nested `secrets` block on each affected entity.
|
||||||
|
- The whole `content` blob is AES-256-GCM encrypted with a key derived via PBKDF2-SHA256 (600 000 iterations) from the user's passphrase, with per-bundle random salt and per-encryption random IV.
|
||||||
|
- Unencrypted bundles are allowed but the UI warns and audit-tags them `UnencryptedBundleExport`.
|
||||||
|
|
||||||
|
### Forward Compatibility
|
||||||
|
|
||||||
|
- Unknown top-level entity types in `contents[]` surface in the import preview as "skipped — unsupported in this version" rather than failing the whole import.
|
||||||
|
- `bundleFormatVersion` newer than what the importer supports produces a hard refusal at upload with a clear upgrade message.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
ScadaLink.Transport
|
||||||
|
├── IBundleExporter
|
||||||
|
│ ExportAsync(ExportSelection, Passphrase?, ct) → Stream
|
||||||
|
├── IBundleImporter
|
||||||
|
│ LoadAsync(stream, Passphrase?, ct) → BundleSession
|
||||||
|
│ PreviewAsync(sessionId, ct) → ImportPreview
|
||||||
|
│ ApplyAsync(sessionId, resolutions, ct) → ImportResult
|
||||||
|
├── DependencyResolver
|
||||||
|
├── BundleSerializer (manifest + content JSON; ZIP packer)
|
||||||
|
├── BundleSecretEncryptor (AES-256-GCM + PBKDF2)
|
||||||
|
├── BundleSessionStore (in-memory, TTL'd)
|
||||||
|
└── ManifestValidator (schema/version gating, hash check)
|
||||||
|
```
|
||||||
|
|
||||||
|
The component is central-only. It is registered in `ScadaLink.Host` for central roles only, never for site roles. All persistence flows through existing audited repository interfaces in `ScadaLink.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session.
|
||||||
|
|
||||||
|
## Export Flow
|
||||||
|
|
||||||
|
### UI — 4-Step Wizard (Design nav group)
|
||||||
|
|
||||||
|
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, API keys, API methods) are flat checkbox lists.
|
||||||
|
|
||||||
|
**Step 2 — Review dependencies.** The resolver expands the user's selection along these edges:
|
||||||
|
|
||||||
|
- `Template A` composes `Template B` → include `B`.
|
||||||
|
- `Template` references `SharedScript` (by name) → include the script.
|
||||||
|
- `Template` references `ExternalSystem` → include the definition and its methods.
|
||||||
|
- `ApiMethod` references `SharedScript` → include the script.
|
||||||
|
- `NotificationList` references `SmtpConfiguration` → include the SMTP config.
|
||||||
|
- Any folder containing a selected template is included so the structure is reproducible on import.
|
||||||
|
|
||||||
|
The user can toggle "include all dependencies" off (with a warning that the bundle may produce an invalid import).
|
||||||
|
|
||||||
|
**Step 3 — Encryption.** Passphrase and confirm. Strength meter. Explicit warning lists every secret field being encrypted. An "Export without encryption" path is logged with the `UnencryptedBundleExport` audit flag.
|
||||||
|
|
||||||
|
**Step 4 — Download.** Generated filename pattern: `scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle`. SHA-256 displayed for out-of-band verification.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
User (Design role) ─► Central UI Export wizard
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IBundleExporter
|
||||||
|
│
|
||||||
|
├─► DependencyResolver ─► repositories (read)
|
||||||
|
├─► EntitySerializer ─► content.json
|
||||||
|
├─► BundleSecretEncryptor ► content.enc (if passphrase)
|
||||||
|
├─► ManifestBuilder ─► manifest.json
|
||||||
|
▼
|
||||||
|
ZIP packer → temp file → browser download
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IAuditService.LogAsync(BundleExported …)
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit event: `BundleExported` — caller, artifact count, content hash, encrypted yes/no, bundle filename.
|
||||||
|
|
||||||
|
Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.ExportAsync`.
|
||||||
|
|
||||||
|
## Import Flow
|
||||||
|
|
||||||
|
### UI — 5-Step Wizard (Admin nav group)
|
||||||
|
|
||||||
|
**Step 1 — Upload.** Drag-and-drop or browse. On selection, the manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). The manifest hash is validated against the `content` blob.
|
||||||
|
|
||||||
|
**Step 2 — Passphrase** (skipped if the bundle is unencrypted). 3-wrong-attempt lockout invalidates the session.
|
||||||
|
|
||||||
|
**Step 3 — Diff & resolve conflicts.** For each artifact in the bundle, compare to existing by name:
|
||||||
|
|
||||||
|
- **Identical** (field-by-field) → marked, auto-skipped, cannot be selected.
|
||||||
|
- **Modified** → shows `+/-/~` line diff. User picks Skip / Overwrite / Rename.
|
||||||
|
- **New** → marked `+ Add`. User can opt to skip individually.
|
||||||
|
|
||||||
|
Bulk "Apply to all" at the top (Overwrite / Skip / Rename), overridable per row. Summary line at the bottom: "5 add · 2 overwrite · 1 skip · 0 rename".
|
||||||
|
|
||||||
|
Bundle references that cannot be satisfied in either the bundle or the target DB (e.g., a template references a shared script that is neither in the bundle nor pre-existing) appear as **blocker rows** — Apply is disabled until they are resolved (typically by skipping the dependent artifact or re-exporting with dependencies).
|
||||||
|
|
||||||
|
**Blocker-scan heuristic boundaries.** The scanner walks `TemplateScript.Code`, `TemplateAttribute.Value`, and `ApiMethod.Script` looking for top-level `Identifier(` or `Identifier.` tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by `.` (member access — `obj.Method()` does not flag `Method`); (b) does NOT scan `TemplateAttribute.DataSourceReference` (an OPC UA address path, never script source); and (c) filters out a small `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `ToString`, `Dispose`, `UtcNow`, …), ScadaLink runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.
|
||||||
|
|
||||||
|
**Step 4 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances. User types the source environment name to confirm (typo-resistant gate at the prod boundary).
|
||||||
|
|
||||||
|
**Step 5 — Result.** Counts, link to the `BundleImported` audit row, link to the Deployments page filtered to the newly stale instances.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```
|
||||||
|
User (Admin role) ─► uploads bundle
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IBundleImporter.LoadAsync
|
||||||
|
· verify SHA-256 (manifest vs content)
|
||||||
|
· check bundleFormatVersion supported
|
||||||
|
· decrypt content.enc with passphrase (if encrypted)
|
||||||
|
· deserialize entities
|
||||||
|
· open BundleSession (30-min TTL)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PreviewAsync → diff vs target DB → ImportPreview
|
||||||
|
│
|
||||||
|
▼ (user reviews + resolves conflicts)
|
||||||
|
│
|
||||||
|
ApplyAsync (single EF transaction)
|
||||||
|
· run two-tier semantic validation (minimal name scan + full SemanticValidator)
|
||||||
|
· apply resolutions (add / overwrite / skip / rename)
|
||||||
|
· upsert TemplateFolder hierarchy
|
||||||
|
· IAuditService.LogAsync(BundleImported …)
|
||||||
|
· commit
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ImportResult → UI step 5
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
"View on Deployments →" (existing page)
|
||||||
|
```
|
||||||
|
|
||||||
|
Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.
|
||||||
|
|
||||||
|
### Stale-Instance Signaling
|
||||||
|
|
||||||
|
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that `DeploymentService.CompareAsync` computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required. The import result enumerates affected instances (by computing the hash drift before committing) so Step 4 can display a preview count and Step 5 can link directly to the Deployments page.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Where | Failure | Surfaced as |
|
||||||
|
|---|---|---|
|
||||||
|
| Upload | Not a zip / missing `manifest.json` | Step 1 error: "Not a valid ScadaLink bundle" |
|
||||||
|
| Upload | `bundleFormatVersion` newer than supported | Step 1 error: "Bundle was created by ScadaLink v{x}; upgrade this cluster" |
|
||||||
|
| Upload | Content hash mismatch | Step 1 error: "Bundle integrity check failed — file may be corrupt" |
|
||||||
|
| Unlock | Wrong passphrase | Step 2 error; 3rd wrong attempt invalidates session, audit `BundleImportUnlockFailed` |
|
||||||
|
| Preview | Bundle references shared script not in bundle and not in target DB | Listed as a blocker row in Step 3; cannot Apply until resolved |
|
||||||
|
| Apply | Semantic validation fails (call target type mismatch, etc.) | Modal: "Validation failed — N errors", per-error list, no DB writes |
|
||||||
|
| Apply | DB transaction fails | Rollback; full transactional guarantee — nothing partial lands; `BundleImportFailed` audit row written outside the rolled-back transaction |
|
||||||
|
| Session | TTL expired | Step 3+ refresh prompts re-upload |
|
||||||
|
|
||||||
|
Imports are all-or-nothing per bundle. A bundle either applies fully or not at all.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **AES-256-GCM** for content encryption with **PBKDF2-SHA256 / 600 000 iterations** (OWASP 2023+ guidance), per-bundle random salt, random IV per encryption. The GCM auth tag is verified before decryption — a wrong passphrase fails cleanly.
|
||||||
|
- **Passphrase never persisted.** It lives only inside the export/import service call path and is discarded after use. No environment variable, no log line.
|
||||||
|
- **Failed-unlock rate limit:** per-session 3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute force against a stolen bundle. Each failed attempt produces a `BundleImportUnlockFailed` audit row.
|
||||||
|
- **Bundle size cap** on upload (default 100 MB, configurable) to bound memory.
|
||||||
|
- **In-transit:** existing HTTPS to the Central UI; no new channel.
|
||||||
|
- **Audit trail is the chain of custody.** Every export, every import (including aborted ones at validation), and every unlock failure is audit-logged with source env, content hash, encrypted yes/no, and artifact summary.
|
||||||
|
- **Defense in depth on authorization:** `RequireDesign` (export) and `RequireAdmin` (import) are enforced both on the Razor page and inside `ScadaLink.Transport` service entrypoints. The UI is not the only gate.
|
||||||
|
- **Bundles are not retained server-side** after download (export) or after `ApplyAsync` commits (import).
|
||||||
|
|
||||||
|
## Configuration Audit Trail
|
||||||
|
|
||||||
|
Import flows through the same audited repository methods the UI and CLI use, so every artifact mutated by `ApplyAsync` emits the existing per-entity `AuditLogEntry` row:
|
||||||
|
|
||||||
|
| Action during import | `AuditLogEntries` rows emitted |
|
||||||
|
|---|---|
|
||||||
|
| Template added | `TemplateCreated` + `TemplateAttributeCreated` (×N) + `TemplateScriptCreated` (×N) + … |
|
||||||
|
| Template overwritten | `TemplateUpdated` + per-field rows (`TemplateAttributeAdded`, `TemplateScriptUpdated`, …) |
|
||||||
|
| Template skipped | (no rows) |
|
||||||
|
| Template renamed-on-import | `TemplateCreated` with the new name (existing row untouched) |
|
||||||
|
| External system overwritten | `ExternalSystemDefinitionUpdated` + per-method rows |
|
||||||
|
| Notification list added | `NotificationListCreated` + per-recipient rows |
|
||||||
|
| API key added | `ApiKeyCreated` |
|
||||||
|
| Imported alarm references missing on-trigger script | `BundleImportAlarmScriptUnresolved` (warning; alarm FK left null) |
|
||||||
|
| Imported template's composition references missing target template | `BundleImportCompositionUnresolved` (warning; composition row not written) |
|
||||||
|
|
||||||
|
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing Configuration Audit Log Viewer gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.
|
||||||
|
|
||||||
|
**Schema change:** one EF migration adds:
|
||||||
|
|
||||||
|
- `BundleImportId uniqueidentifier NULL` on `AuditLogEntries`.
|
||||||
|
- Non-clustered index `IX_AuditLogEntries_BundleImportId`.
|
||||||
|
|
||||||
|
**Transactional guarantee:** the EF transaction wraps both the entity writes and the audit log writes (existing pattern). A rollback removes both. The `BundleImported` summary row is the final write inside the transaction, so partial audit trails are impossible. A failed import emits a single `BundleImportFailed` row outside the rolled-back transaction.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
Three commands surface the same Transport operations as the Central UI wizards, designed for test automation. The bundle bytes travel as base64 inside the existing `/management` JSON envelope — no new HTTP endpoints — and the per-request body cap is raised to 200 MB to cover the 100 MB raw-bundle ceiling once base64-inflated.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scadalink bundle export --output FILE --passphrase X [--all | --templates A,B ...] \
|
||||||
|
[--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
|
||||||
|
[--notification-lists ...] [--smtp-configs ...] [--api-keys ...] \
|
||||||
|
[--api-methods ...] [--include-dependencies] [--source-environment NAME]
|
||||||
|
|
||||||
|
scadalink bundle preview --input FILE --passphrase X
|
||||||
|
# prints PreviewBundleResult JSON: per-row items + add/modified/identical/blocker counts
|
||||||
|
|
||||||
|
scadalink bundle import --input FILE --passphrase X [--on-conflict skip|overwrite|rename]
|
||||||
|
# one-shot load + preview + apply with a single global policy for Modified rows
|
||||||
|
# Identical → Skip, New → Add, Blocker → abort
|
||||||
|
```
|
||||||
|
|
||||||
|
Selection uses entity **names** rather than IDs so scripts are portable across environments. The CLI per-command timeout is 5 minutes (vs the default 30 s for other commands) to comfortably cover large bundles. CLI commands route through `ManagementActor`'s `ExportBundleCommand` / `PreviewBundleCommand` / `ImportBundleCommand` handlers, which delegate to the same `IBundleExporter` / `IBundleImporter` services as the UI.
|
||||||
|
|
||||||
|
Exit codes follow the project convention: `0` = success, `1` = command failure (validation, blockers, wrong passphrase), `2` = authorization failure.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
| Operation | Required role | Enforced at |
|
||||||
|
|---|---|---|
|
||||||
|
| Open Export page / `bundle export` CLI | `RequireDesign` | Razor page authorize attribute + `ManagementActor.GetRequiredRole` |
|
||||||
|
| `IBundleExporter.ExportAsync` | `RequireDesign` | Service entrypoint |
|
||||||
|
| Open Import page / `bundle preview` + `bundle import` CLI | `RequireAdmin` | Razor page authorize attribute + `ManagementActor.GetRequiredRole` |
|
||||||
|
| `IBundleImporter.LoadAsync` / `PreviewAsync` / `ApplyAsync` | `RequireAdmin` | Service entrypoint |
|
||||||
|
| Configuration Audit Log "Bundle Import" filter | `RequireAdmin` or `Audit` | Existing audit page logic |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **`ScadaLink.Commons`** — Bundle manifest and content DTOs (`BundleManifest`, `ExportSelection`, `ImportPreview`, `ImportResolution`, `ImportResult`, `BundleSession`); transport interface definitions (`IBundleExporter`, `IBundleImporter`, `IBundleSessionStore`, `IAuditCorrelationContext`).
|
||||||
|
- **`ScadaLink.ConfigurationDatabase`** — All repository implementations and `IAuditService` for persistence and per-entity audit emission; `IAuditCorrelationContext` implementation (`AuditCorrelationContext`) registered as a scoped service; EF migration for `BundleImportId`.
|
||||||
|
- **`ScadaLink.TemplateEngine`** — Pre-deployment `SemanticValidator` invoked inside `ApplyAsync` before the transaction commits. The importer builds a single-template `FlattenedConfiguration` directly from each imported `TemplateDto` (no inheritance / composition resolution at design time — the deployment-time flatten revalidates against the full instance graph) and feeds it through the validator alongside a `ResolvedScript` catalog combining in-bundle + pre-existing target `SharedScript`s. Validator errors are aggregated per template and surfaced as a `SemanticValidationException` that rolls back the import transaction.
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
- **Central UI** — Hosts the Export Bundle (`/design/transport/export`) page under the Design nav group and the Import Bundle (`/design/transport/import`) page under the Admin nav group. The import result page links to the Deployments page and to the filtered Configuration Audit Log Viewer.
|
||||||
|
- **Management Service / CLI** — `ManagementActor` registers three Transport command handlers (`ExportBundleCommand`, `PreviewBundleCommand`, `ImportBundleCommand`) and the CLI ships `bundle export` / `bundle preview` / `bundle import` subcommands. Bundle bytes ride the existing `/management` JSON envelope as base64.
|
||||||
|
- **Deployment Manager** — Never directly invoked by Transport. Transport-driven template changes propagate to deployed instances through the existing revision-hash drift detection in `DeploymentService.CompareAsync`; the Deployments page surfaces affected instances as stale automatically.
|
||||||
|
- **Security & Auth** — Provides `RequireDesign` and `RequireAdmin` policies from `ScadaLink.Security`, enforced at both the page and service layers.
|
||||||
|
- **Audit Log (Configuration)** — Writes `BundleExported` / `BundleImported` / `BundleImportFailed` / `UnencryptedBundleExport` / `BundleImportUnlockFailed` rows via `IAuditService`, plus per-import name-resolution warnings `BundleImportAlarmScriptUnresolved` and `BundleImportCompositionUnresolved`; per-entity rows from audited repositories are correlated by `BundleImportId` via `IAuditCorrelationContext`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Bundle JSON Schema
|
||||||
|
|
||||||
|
The `manifest.json` file is always present in the ZIP root and is never encrypted.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bundleFormatVersion": 1,
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"createdAtUtc": "2026-05-24T12:34:56Z",
|
||||||
|
"sourceEnvironment": "dev-cluster-a",
|
||||||
|
"exportedBy": "alice@corp.example",
|
||||||
|
"scadaLinkVersion": "1.4.2",
|
||||||
|
"contentHash": "sha256:abc123...",
|
||||||
|
"encryption": {
|
||||||
|
"algorithm": "AES-256-GCM",
|
||||||
|
"kdf": "PBKDF2-SHA256",
|
||||||
|
"iterations": 600000,
|
||||||
|
"saltB64": "<base64-encoded 16-byte random salt>",
|
||||||
|
"ivB64": "<base64-encoded 12-byte random IV>"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"templates": 12,
|
||||||
|
"templateFolders": 3,
|
||||||
|
"sharedScripts": 4,
|
||||||
|
"externalSystems": 2,
|
||||||
|
"dbConnections": 1,
|
||||||
|
"notificationLists": 1,
|
||||||
|
"smtpConfigs": 0,
|
||||||
|
"apiKeys": 2,
|
||||||
|
"apiMethods": 5
|
||||||
|
},
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"type": "Template",
|
||||||
|
"name": "Pump",
|
||||||
|
"version": 5,
|
||||||
|
"dependsOn": ["SharedScript:PumpUtils"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Template",
|
||||||
|
"name": "Pump.WaterPump",
|
||||||
|
"version": 3,
|
||||||
|
"dependsOn": ["Template:Pump", "ExternalSystem:HistorianAPI"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field descriptions:**
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `bundleFormatVersion` | Integer. Importer hard-refuses any value higher than what its `TransportOptions.SchemaVersionMajor` supports. |
|
||||||
|
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. |
|
||||||
|
| `createdAtUtc` | ISO-8601 UTC timestamp of when the export was created. |
|
||||||
|
| `sourceEnvironment` | The `SourceEnvironment` name of the exporting cluster (from `TransportOptions`). Displayed in the import wizard and required to be retyped at the confirm step. |
|
||||||
|
| `exportedBy` | Authenticated username of the person who performed the export. |
|
||||||
|
| `scadaLinkVersion` | Application version of the exporting node. Used for diagnostic display only. |
|
||||||
|
| `contentHash` | `sha256:<hex>` — SHA-256 of the raw `content.json` or `content.enc` bytes (pre-encryption). Verified on upload before any decryption. |
|
||||||
|
| `encryption` | Present only when a passphrase was supplied. Contains the KDF parameters and the per-bundle random salt and IV needed to re-derive the key and decrypt. Omitted for plaintext bundles. |
|
||||||
|
| `encryption.algorithm` | Always `"AES-256-GCM"` in v1. |
|
||||||
|
| `encryption.kdf` | Always `"PBKDF2-SHA256"` in v1. |
|
||||||
|
| `encryption.iterations` | PBKDF2 iteration count. Defaults to 600 000 (configurable via `TransportOptions.Pbkdf2Iterations`). |
|
||||||
|
| `encryption.saltB64` | Base64-encoded 16-byte random salt generated at export time. |
|
||||||
|
| `encryption.ivB64` | Base64-encoded 12-byte (GCM standard) random IV generated at export time. |
|
||||||
|
| `summary` | Artifact count by type, for display in the import wizard's upload step without needing to decrypt content. |
|
||||||
|
| `contents` | Ordered list of all artifacts in the bundle. Order is topological (base templates before derived). Each entry carries the artifact's name, the schema `version` at export time, and its direct `dependsOn` edges for dependency display in the export wizard's Step 2. |
|
||||||
@@ -37,6 +37,16 @@ docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
|||||||
-i /docker-entrypoint-initdb.d/machinedata_seed.sql
|
-i /docker-entrypoint-initdb.d/machinedata_seed.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For the second environment (`docker-env2/`), also apply the env2 database setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||||
|
-i /docker-entrypoint-initdb.d/setup-env2.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `ScadaLinkConfig2` and `ScadaLinkMachineData2` databases on the same MSSQL instance. The script is also invoked automatically by `docker-env2/deploy.sh` via `docker-env2/init-db.sh`, so manual application here is only needed if you want the databases ready before first env2 deploy.
|
||||||
|
|
||||||
## Stopping & Teardown
|
## Stopping & Teardown
|
||||||
|
|
||||||
**Stop containers** (data persists in SQL volume):
|
**Stop containers** (data persists in SQL volume):
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ services:
|
|||||||
- scadalink-mssql-data:/var/opt/mssql
|
- scadalink-mssql-data:/var/opt/mssql
|
||||||
- ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro
|
- ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro
|
||||||
- ./mssql/machinedata_seed.sql:/docker-entrypoint-initdb.d/machinedata_seed.sql:ro
|
- ./mssql/machinedata_seed.sql:/docker-entrypoint-initdb.d/machinedata_seed.sql:ro
|
||||||
|
- ./mssql/setup-env2.sql:/docker-entrypoint-initdb.d/setup-env2.sql:ro
|
||||||
networks:
|
networks:
|
||||||
- scadalink-net
|
- scadalink-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
31
infra/mssql/setup-env2.sql
Normal file
31
infra/mssql/setup-env2.sql
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
-- ScadaLink env2 database setup
|
||||||
|
-- Creates env2 logical databases on an existing scadalink-mssql instance.
|
||||||
|
-- Idempotent: re-runs are no-ops. Assumes setup.sql has already run
|
||||||
|
-- (i.e. the scadalink_app login already exists).
|
||||||
|
|
||||||
|
-- Create env2 databases
|
||||||
|
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkConfig2')
|
||||||
|
CREATE DATABASE ScadaLinkConfig2;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkMachineData2')
|
||||||
|
CREATE DATABASE ScadaLinkMachineData2;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Grant db_owner on ScadaLinkConfig2
|
||||||
|
USE ScadaLinkConfig2;
|
||||||
|
GO
|
||||||
|
IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app')
|
||||||
|
CREATE USER scadalink_app FOR LOGIN scadalink_app;
|
||||||
|
GO
|
||||||
|
ALTER ROLE db_owner ADD MEMBER scadalink_app;
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- Grant db_owner on ScadaLinkMachineData2
|
||||||
|
USE ScadaLinkMachineData2;
|
||||||
|
GO
|
||||||
|
IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app')
|
||||||
|
CREATE USER scadalink_app FOR LOGIN scadalink_app;
|
||||||
|
GO
|
||||||
|
ALTER ROLE db_owner ADD MEMBER scadalink_app;
|
||||||
|
GO
|
||||||
@@ -43,6 +43,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
private readonly ILogger<CentralAuditWriter> _logger;
|
private readonly ILogger<CentralAuditWriter> _logger;
|
||||||
private readonly IAuditPayloadFilter? _filter;
|
private readonly IAuditPayloadFilter? _filter;
|
||||||
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
||||||
|
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bundle C (M5-T6) — the central direct-write path used by the
|
/// Bundle C (M5-T6) — the central direct-write path used by the
|
||||||
@@ -56,18 +57,27 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
/// throw bumps the central health surface's
|
/// throw bumps the central health surface's
|
||||||
/// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test
|
/// <c>CentralAuditWriteFailures</c> counter. Defaults to a NoOp so test
|
||||||
/// composition roots that don't wire the counter keep their current
|
/// composition roots that don't wire the counter keep their current
|
||||||
/// behaviour.
|
/// behaviour. SourceNode-stamping (Task 12) — adds the optional
|
||||||
|
/// <see cref="INodeIdentityProvider"/> so central-origin rows (Notification
|
||||||
|
/// Outbox dispatch, Inbound API) carry the writing central node's
|
||||||
|
/// identifier when the caller hasn't already supplied one. Optional /
|
||||||
|
/// defaulting-to-null so M4 test composition roots that don't pass a
|
||||||
|
/// provider keep working — the caller-wins discipline means an absent
|
||||||
|
/// provider simply leaves SourceNode at whatever the caller set (often
|
||||||
|
/// null, which is the legacy behaviour).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CentralAuditWriter(
|
public CentralAuditWriter(
|
||||||
IServiceProvider services,
|
IServiceProvider services,
|
||||||
ILogger<CentralAuditWriter> logger,
|
ILogger<CentralAuditWriter> logger,
|
||||||
IAuditPayloadFilter? filter = null,
|
IAuditPayloadFilter? filter = null,
|
||||||
ICentralAuditWriteFailureCounter? failureCounter = null)
|
ICentralAuditWriteFailureCounter? failureCounter = null,
|
||||||
|
INodeIdentityProvider? nodeIdentity = null)
|
||||||
{
|
{
|
||||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_filter = filter;
|
_filter = filter;
|
||||||
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
||||||
|
_nodeIdentity = nodeIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -93,6 +103,18 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
// M4 test composition roots (no filter passed) working unchanged.
|
// M4 test composition roots (no filter passed) working unchanged.
|
||||||
var filtered = _filter?.Apply(evt) ?? evt;
|
var filtered = _filter?.Apply(evt) ?? evt;
|
||||||
|
|
||||||
|
// SourceNode-stamping (Task 12): caller-provided value wins
|
||||||
|
// (supports any future direct-write callsite that already has its
|
||||||
|
// own node id); otherwise stamp from the local
|
||||||
|
// INodeIdentityProvider, when one is wired. Production DI on
|
||||||
|
// central nodes always supplies the provider; legacy test
|
||||||
|
// composition roots that don't pass it leave SourceNode at
|
||||||
|
// whatever the caller set (often null), preserving back-compat.
|
||||||
|
if (filtered.SourceNode is null && _nodeIdentity?.NodeName is { } nodeName)
|
||||||
|
{
|
||||||
|
filtered = filtered with { SourceNode = nodeName };
|
||||||
|
}
|
||||||
|
|
||||||
await using var scope = _services.CreateAsyncScope();
|
await using var scope = _services.CreateAsyncScope();
|
||||||
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
var stamped = filtered with { IngestedAtUtc = DateTime.UtcNow };
|
||||||
@@ -114,6 +136,13 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
|||||||
// misbehaving custom counter does, swallowing here keeps the
|
// misbehaving custom counter does, swallowing here keeps the
|
||||||
// best-effort contract intact.
|
// best-effort contract intact.
|
||||||
}
|
}
|
||||||
|
// Log the input event's identifying fields. These three (EventId,
|
||||||
|
// Kind, Status) are immutable across the filter+stamp chain — the
|
||||||
|
// `with` clones above touch only SourceNode and IngestedAtUtc — so
|
||||||
|
// referencing `evt` here is intentional and equivalent to the
|
||||||
|
// stamped record for diagnostics. If you add a field here that the
|
||||||
|
// stamp chain DOES mutate (e.g., SourceNode), reference the latest
|
||||||
|
// post-stamp record name instead, not `evt`.
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
ex,
|
ex,
|
||||||
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
"CentralAuditWriter failed for EventId {EventId} (Kind={Kind}, Status={Status})",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -146,7 +146,17 @@ public static class ServiceCollectionExtensions
|
|||||||
new CachedCallTelemetryForwarder(
|
new CachedCallTelemetryForwarder(
|
||||||
sp.GetRequiredService<IAuditWriter>(),
|
sp.GetRequiredService<IAuditWriter>(),
|
||||||
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
|
sp.GetService<ScadaLink.Commons.Interfaces.IOperationTrackingStore>(),
|
||||||
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>()));
|
sp.GetRequiredService<ILogger<CachedCallTelemetryForwarder>>(),
|
||||||
|
// SourceNode-stamping (Task 14): the local node identity is
|
||||||
|
// threaded through so RecordEnqueueAsync can stamp the
|
||||||
|
// tracking row's SourceNode column. GetService (not
|
||||||
|
// GetRequiredService) — test composition roots that build a
|
||||||
|
// stripped DI container may not register the provider, in
|
||||||
|
// which case the forwarder degrades to a null SourceNode
|
||||||
|
// rather than failing the DI resolution. Production hosts
|
||||||
|
// (site + central) always register it via
|
||||||
|
// SiteServiceRegistration.BindSharedOptions.
|
||||||
|
sp.GetService<INodeIdentityProvider>()));
|
||||||
|
|
||||||
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
|
// M3 Bundle F: bridge the store-and-forward retry-loop observer hook
|
||||||
// to the cached-call forwarder so per-attempt + terminal telemetry
|
// to the cached-call forwarder so per-attempt + terminal telemetry
|
||||||
@@ -154,7 +164,19 @@ public static class ServiceCollectionExtensions
|
|||||||
// as the script-thread CachedSubmit row. Registered as a singleton
|
// as the script-thread CachedSubmit row. Registered as a singleton
|
||||||
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
|
// and also bound to ICachedCallLifecycleObserver so AddStoreAndForward
|
||||||
// can resolve it through DI (Bundle F StoreAndForward wiring change).
|
// can resolve it through DI (Bundle F StoreAndForward wiring change).
|
||||||
services.AddSingleton<CachedCallLifecycleBridge>();
|
// SourceNode-stamping (Task 14): factory-resolved so the
|
||||||
|
// INodeIdentityProvider singleton can be threaded through — the
|
||||||
|
// bridge stamps SiteCallOperational.SourceNode from
|
||||||
|
// INodeIdentityProvider.NodeName on every cached-call lifecycle row.
|
||||||
|
// GetService (not GetRequiredService) — test composition roots that
|
||||||
|
// build a stripped DI container may not register the provider, in
|
||||||
|
// which case the bridge degrades to a null SourceNode rather than
|
||||||
|
// failing the DI resolution. Production hosts (site + central)
|
||||||
|
// always register it via SiteServiceRegistration.BindSharedOptions.
|
||||||
|
services.AddSingleton<CachedCallLifecycleBridge>(sp => new CachedCallLifecycleBridge(
|
||||||
|
sp.GetRequiredService<ICachedCallTelemetryForwarder>(),
|
||||||
|
sp.GetRequiredService<ILogger<CachedCallLifecycleBridge>>(),
|
||||||
|
sp.GetService<INodeIdentityProvider>()));
|
||||||
services.AddSingleton<ICachedCallLifecycleObserver>(
|
services.AddSingleton<ICachedCallLifecycleObserver>(
|
||||||
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
sp => sp.GetRequiredService<CachedCallLifecycleBridge>());
|
||||||
|
|
||||||
@@ -183,7 +205,14 @@ public static class ServiceCollectionExtensions
|
|||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
sp.GetRequiredService<ILogger<CentralAuditWriter>>(),
|
||||||
sp.GetRequiredService<IAuditPayloadFilter>(),
|
sp.GetRequiredService<IAuditPayloadFilter>(),
|
||||||
sp.GetRequiredService<ICentralAuditWriteFailureCounter>()));
|
sp.GetRequiredService<ICentralAuditWriteFailureCounter>(),
|
||||||
|
// SourceNode-stamping (Task 12): wire the local node identity so
|
||||||
|
// central-origin rows (Notification Outbox dispatch, Inbound API)
|
||||||
|
// carry the writing node's identifier when the caller hasn't
|
||||||
|
// already supplied one. GetRequiredService — the production
|
||||||
|
// composition root in SiteServiceRegistration registers the
|
||||||
|
// provider as a singleton on both site and central paths.
|
||||||
|
sp.GetRequiredService<INodeIdentityProvider>()));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
private readonly SqliteAuditWriterOptions _options;
|
private readonly SqliteAuditWriterOptions _options;
|
||||||
private readonly ILogger<SqliteAuditWriter> _logger;
|
private readonly ILogger<SqliteAuditWriter> _logger;
|
||||||
|
private readonly INodeIdentityProvider _nodeIdentity;
|
||||||
private readonly object _writeLock = new();
|
private readonly object _writeLock = new();
|
||||||
private readonly Channel<PendingAuditEvent> _writeQueue;
|
private readonly Channel<PendingAuditEvent> _writeQueue;
|
||||||
private readonly Task _writerLoop;
|
private readonly Task _writerLoop;
|
||||||
@@ -50,13 +51,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
public SqliteAuditWriter(
|
public SqliteAuditWriter(
|
||||||
IOptions<SqliteAuditWriterOptions> options,
|
IOptions<SqliteAuditWriterOptions> options,
|
||||||
ILogger<SqliteAuditWriter> logger,
|
ILogger<SqliteAuditWriter> logger,
|
||||||
|
INodeIdentityProvider nodeIdentity,
|
||||||
string? connectionStringOverride = null)
|
string? connectionStringOverride = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
ArgumentNullException.ThrowIfNull(nodeIdentity);
|
||||||
|
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_nodeIdentity = nodeIdentity;
|
||||||
|
|
||||||
var connectionString = connectionStringOverride
|
var connectionString = connectionStringOverride
|
||||||
?? $"Data Source={_options.DatabasePath};Cache=Shared";
|
?? $"Data Source={_options.DatabasePath};Cache=Shared";
|
||||||
@@ -100,6 +104,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
Kind TEXT NOT NULL,
|
Kind TEXT NOT NULL,
|
||||||
CorrelationId TEXT NULL,
|
CorrelationId TEXT NULL,
|
||||||
SourceSiteId TEXT NULL,
|
SourceSiteId TEXT NULL,
|
||||||
|
SourceNode TEXT NULL,
|
||||||
SourceInstanceId TEXT NULL,
|
SourceInstanceId TEXT NULL,
|
||||||
SourceScript TEXT NULL,
|
SourceScript TEXT NULL,
|
||||||
Actor TEXT NULL,
|
Actor TEXT NULL,
|
||||||
@@ -114,12 +119,71 @@ 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");
|
||||||
|
|
||||||
|
// SourceNode stamping: same idempotent upgrade path as ExecutionId /
|
||||||
|
// ParentExecutionId above. A deployment that already ran the
|
||||||
|
// ParentExecutionId branch has an auditlog.db with the 22-column
|
||||||
|
// schema and no SourceNode 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 SourceNode = null.
|
||||||
|
AddColumnIfMissing("SourceNode", "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>
|
||||||
@@ -219,14 +283,16 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
INSERT INTO AuditLog (
|
INSERT INTO AuditLog (
|
||||||
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceNode, 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, $SourceNode, $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
|
||||||
);
|
);
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -236,6 +302,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
|
var pKind = cmd.Parameters.Add("$Kind", SqliteType.Text);
|
||||||
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
var pCorrelationId = cmd.Parameters.Add("$CorrelationId", SqliteType.Text);
|
||||||
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
|
var pSourceSiteId = cmd.Parameters.Add("$SourceSiteId", SqliteType.Text);
|
||||||
|
var pSourceNode = cmd.Parameters.Add("$SourceNode", SqliteType.Text);
|
||||||
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
|
var pSourceInstanceId = cmd.Parameters.Add("$SourceInstanceId", SqliteType.Text);
|
||||||
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
|
var pSourceScript = cmd.Parameters.Add("$SourceScript", SqliteType.Text);
|
||||||
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
|
var pActor = cmd.Parameters.Add("$Actor", SqliteType.Text);
|
||||||
@@ -250,6 +317,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)
|
||||||
{
|
{
|
||||||
@@ -260,6 +329,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
pKind.Value = e.Kind.ToString();
|
pKind.Value = e.Kind.ToString();
|
||||||
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
|
pCorrelationId.Value = (object?)e.CorrelationId?.ToString() ?? DBNull.Value;
|
||||||
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
|
pSourceSiteId.Value = (object?)e.SourceSiteId ?? DBNull.Value;
|
||||||
|
// SourceNode-stamping: caller-provided value wins (preserves
|
||||||
|
// rows reconciled in from other nodes via the same writer);
|
||||||
|
// otherwise stamp from the local INodeIdentityProvider. The
|
||||||
|
// event record itself is NOT mutated — stamping is at write
|
||||||
|
// time only. If the provider also returns null (unconfigured
|
||||||
|
// node), the row's SourceNode stays NULL — operators see
|
||||||
|
// "needs config" via the schema, not a magic fallback string.
|
||||||
|
var sourceNode = e.SourceNode ?? _nodeIdentity.NodeName;
|
||||||
|
pSourceNode.Value = (object?)sourceNode ?? DBNull.Value;
|
||||||
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
|
pSourceInstanceId.Value = (object?)e.SourceInstanceId ?? DBNull.Value;
|
||||||
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
|
pSourceScript.Value = (object?)e.SourceScript ?? DBNull.Value;
|
||||||
pActor.Value = (object?)e.Actor ?? DBNull.Value;
|
pActor.Value = (object?)e.Actor ?? DBNull.Value;
|
||||||
@@ -274,6 +352,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
|
||||||
{
|
{
|
||||||
@@ -329,9 +409,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceNode, 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
|
||||||
@@ -377,9 +458,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceNode, 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
|
||||||
@@ -463,9 +545,10 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
using var cmd = _connection.CreateCommand();
|
using var cmd = _connection.CreateCommand();
|
||||||
cmd.CommandText = """
|
cmd.CommandText = """
|
||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceNode, 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
|
||||||
@@ -628,20 +711,23 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
|
Kind = Enum.Parse<AuditKind>(reader.GetString(3)),
|
||||||
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
CorrelationId = reader.IsDBNull(4) ? null : Guid.Parse(reader.GetString(4)),
|
||||||
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
SourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
SourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6),
|
SourceNode = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
SourceScript = reader.IsDBNull(7) ? null : reader.GetString(7),
|
SourceInstanceId = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
Actor = reader.IsDBNull(8) ? null : reader.GetString(8),
|
SourceScript = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
Target = reader.IsDBNull(9) ? null : reader.GetString(9),
|
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
Status = Enum.Parse<AuditStatus>(reader.GetString(10)),
|
Target = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||||
HttpStatus = reader.IsDBNull(11) ? null : reader.GetInt32(11),
|
Status = Enum.Parse<AuditStatus>(reader.GetString(11)),
|
||||||
DurationMs = reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
HttpStatus = reader.IsDBNull(12) ? null : reader.GetInt32(12),
|
||||||
ErrorMessage = reader.IsDBNull(13) ? null : reader.GetString(13),
|
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
|
||||||
ErrorDetail = reader.IsDBNull(14) ? null : reader.GetString(14),
|
ErrorMessage = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||||
RequestSummary = reader.IsDBNull(15) ? null : reader.GetString(15),
|
ErrorDetail = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||||
ResponseSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
|
RequestSummary = reader.IsDBNull(16) ? null : reader.GetString(16),
|
||||||
PayloadTruncated = reader.GetInt32(17) != 0,
|
ResponseSummary = reader.IsDBNull(17) ? null : reader.GetString(17),
|
||||||
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
PayloadTruncated = reader.GetInt32(18) != 0,
|
||||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
Extra = reader.IsDBNull(19) ? null : reader.GetString(19),
|
||||||
|
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
|
||||||
|
ExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||||
|
ParentExecutionId = reader.IsDBNull(22) ? null : Guid.Parse(reader.GetString(22)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|||||||
private readonly ICachedCallTelemetryForwarder _forwarder;
|
private readonly ICachedCallTelemetryForwarder _forwarder;
|
||||||
private readonly ILogger<CachedCallLifecycleBridge> _logger;
|
private readonly ILogger<CachedCallLifecycleBridge> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SourceNode-stamping (Task 14): the local node identity provider used to
|
||||||
|
/// stamp <c>SiteCallOperational.SourceNode</c> on every cached-call
|
||||||
|
/// lifecycle row this bridge emits. Optional — when null (legacy hosts /
|
||||||
|
/// tests that don't register the provider) SourceNode stays null and
|
||||||
|
/// central persists the <c>SiteCalls</c> row with SourceNode NULL.
|
||||||
|
/// </summary>
|
||||||
|
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||||
|
|
||||||
public CachedCallLifecycleBridge(
|
public CachedCallLifecycleBridge(
|
||||||
ICachedCallTelemetryForwarder forwarder,
|
ICachedCallTelemetryForwarder forwarder,
|
||||||
ILogger<CachedCallLifecycleBridge> logger)
|
ILogger<CachedCallLifecycleBridge> logger,
|
||||||
|
INodeIdentityProvider? nodeIdentity = null)
|
||||||
{
|
{
|
||||||
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
|
_forwarder = forwarder ?? throw new ArgumentNullException(nameof(forwarder));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_nodeIdentity = nodeIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -114,7 +125,7 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|||||||
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
await _forwarder.ForwardAsync(packet, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CachedCallTelemetry BuildPacket(
|
private CachedCallTelemetry BuildPacket(
|
||||||
CachedCallAttemptContext context,
|
CachedCallAttemptContext context,
|
||||||
AuditKind kind,
|
AuditKind kind,
|
||||||
AuditStatus status,
|
AuditStatus status,
|
||||||
@@ -133,9 +144,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,
|
||||||
@@ -148,6 +173,11 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
|
|||||||
Channel: context.Channel,
|
Channel: context.Channel,
|
||||||
Target: context.Target,
|
Target: context.Target,
|
||||||
SourceSite: context.SourceSite,
|
SourceSite: context.SourceSite,
|
||||||
|
// SourceNode-stamping (Task 14): the local cluster node name
|
||||||
|
// (node-a/node-b on a site). Stamped from the injected
|
||||||
|
// INodeIdentityProvider; null when no provider was wired so
|
||||||
|
// central persists SiteCalls.SourceNode as NULL.
|
||||||
|
SourceNode: _nodeIdentity?.NodeName,
|
||||||
Status: operationalStatus,
|
Status: operationalStatus,
|
||||||
RetryCount: context.RetryCount,
|
RetryCount: context.RetryCount,
|
||||||
LastError: lastError,
|
LastError: lastError,
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
private readonly IOperationTrackingStore? _trackingStore;
|
private readonly IOperationTrackingStore? _trackingStore;
|
||||||
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
|
private readonly ILogger<CachedCallTelemetryForwarder> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SourceNode-stamping (Task 14): local node identity provider used to
|
||||||
|
/// stamp the tracking-store row's <c>SourceNode</c> column on
|
||||||
|
/// <c>RecordEnqueueAsync</c>. Optional — when null (legacy / test hosts)
|
||||||
|
/// the column stays NULL on the tracking row.
|
||||||
|
/// </summary>
|
||||||
|
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
|
/// Construct the forwarder. <paramref name="trackingStore"/> is optional —
|
||||||
/// when null only the audit half of the packet is emitted, which matches
|
/// when null only the audit half of the packet is emitted, which matches
|
||||||
@@ -65,11 +73,13 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
public CachedCallTelemetryForwarder(
|
public CachedCallTelemetryForwarder(
|
||||||
IAuditWriter auditWriter,
|
IAuditWriter auditWriter,
|
||||||
IOperationTrackingStore? trackingStore,
|
IOperationTrackingStore? trackingStore,
|
||||||
ILogger<CachedCallTelemetryForwarder> logger)
|
ILogger<CachedCallTelemetryForwarder> logger,
|
||||||
|
INodeIdentityProvider? nodeIdentity = null)
|
||||||
{
|
{
|
||||||
_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));
|
||||||
_trackingStore = trackingStore;
|
_trackingStore = trackingStore;
|
||||||
|
_nodeIdentity = nodeIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -128,12 +138,17 @@ public sealed class CachedCallTelemetryForwarder : ICachedCallTelemetryForwarder
|
|||||||
// Enqueue — insert-if-not-exists with the operational
|
// Enqueue — insert-if-not-exists with the operational
|
||||||
// channel as the kind discriminator. RetryCount is fixed
|
// channel as the kind discriminator. RetryCount is fixed
|
||||||
// at 0 by the tracking store's INSERT contract.
|
// at 0 by the tracking store's INSERT contract.
|
||||||
|
// SourceNode-stamping (Task 14): stamp the local node
|
||||||
|
// name (node-a/node-b) from the injected
|
||||||
|
// INodeIdentityProvider; null when no provider was wired
|
||||||
|
// so the tracking row's SourceNode column stays NULL.
|
||||||
await _trackingStore.RecordEnqueueAsync(
|
await _trackingStore.RecordEnqueueAsync(
|
||||||
telemetry.Operational.TrackedOperationId,
|
telemetry.Operational.TrackedOperationId,
|
||||||
telemetry.Operational.Channel,
|
telemetry.Operational.Channel,
|
||||||
telemetry.Operational.Target,
|
telemetry.Operational.Target,
|
||||||
telemetry.Audit.SourceInstanceId,
|
telemetry.Audit.SourceInstanceId,
|
||||||
telemetry.Audit.SourceScript,
|
telemetry.Audit.SourceScript,
|
||||||
|
sourceNode: _nodeIdentity?.NodeName,
|
||||||
ct).ConfigureAwait(false);
|
ct).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
301
src/ScadaLink.CLI/Commands/BundleCommands.cs
Normal file
301
src/ScadaLink.CLI/Commands/BundleCommands.cs
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
using System.CommandLine;
|
||||||
|
using System.CommandLine.Parsing;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
|
namespace ScadaLink.CLI.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transport (#24) bundle export / preview / import. The bundle bytes travel
|
||||||
|
/// through the management endpoint as base64 inside the standard JSON envelope
|
||||||
|
/// so no transport plumbing diverges from the other commands; the CLI handles
|
||||||
|
/// file I/O at the edges.
|
||||||
|
/// </summary>
|
||||||
|
public static class BundleCommands
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan BundleCommandTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public static Command Build(
|
||||||
|
Option<string> urlOption, Option<string> formatOption,
|
||||||
|
Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var command = new Command("bundle")
|
||||||
|
{
|
||||||
|
Description = "Export, preview, and import Transport bundles",
|
||||||
|
};
|
||||||
|
command.Add(BuildExport(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildPreview(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildImport(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// bundle export
|
||||||
|
// ====================================================================
|
||||||
|
private static Command BuildExport(
|
||||||
|
Option<string> urlOption, Option<string> formatOption,
|
||||||
|
Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var outputOption = new Option<string>("--output")
|
||||||
|
{
|
||||||
|
Description = "Output file path (.scadabundle)",
|
||||||
|
Required = true,
|
||||||
|
};
|
||||||
|
var passphraseOption = new Option<string?>("--passphrase")
|
||||||
|
{
|
||||||
|
Description = "Encryption passphrase. Omit to produce an unencrypted bundle.",
|
||||||
|
};
|
||||||
|
var allOption = new Option<bool>("--all")
|
||||||
|
{
|
||||||
|
Description = "Export every entity of every supported type (ignores per-type name flags).",
|
||||||
|
};
|
||||||
|
var templatesOption = NameListOption("--templates", "Comma-separated template names");
|
||||||
|
var sharedScriptsOption = NameListOption("--shared-scripts", "Comma-separated shared-script names");
|
||||||
|
var externalSystemsOption = NameListOption("--external-systems", "Comma-separated external-system names");
|
||||||
|
var dbConnectionsOption = NameListOption("--db-connections", "Comma-separated database-connection names");
|
||||||
|
var notificationListsOption = NameListOption("--notification-lists", "Comma-separated notification-list names");
|
||||||
|
var smtpConfigsOption = NameListOption("--smtp-configs", "Comma-separated SMTP host names");
|
||||||
|
var apiKeysOption = NameListOption("--api-keys", "Comma-separated API-key names");
|
||||||
|
var apiMethodsOption = NameListOption("--api-methods", "Comma-separated API-method names");
|
||||||
|
var includeDepsOption = new Option<bool>("--include-dependencies")
|
||||||
|
{
|
||||||
|
Description = "Pull transitive dependencies (referenced shared scripts, parents, composed members) into the bundle.",
|
||||||
|
};
|
||||||
|
var sourceEnvOption = new Option<string?>("--source-environment")
|
||||||
|
{
|
||||||
|
Description = "SourceEnvironment value stamped into the bundle manifest. Defaults to 'cli'.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = new Command("export")
|
||||||
|
{
|
||||||
|
Description = "Export a bundle to a file",
|
||||||
|
};
|
||||||
|
cmd.Add(outputOption);
|
||||||
|
cmd.Add(passphraseOption);
|
||||||
|
cmd.Add(allOption);
|
||||||
|
cmd.Add(templatesOption);
|
||||||
|
cmd.Add(sharedScriptsOption);
|
||||||
|
cmd.Add(externalSystemsOption);
|
||||||
|
cmd.Add(dbConnectionsOption);
|
||||||
|
cmd.Add(notificationListsOption);
|
||||||
|
cmd.Add(smtpConfigsOption);
|
||||||
|
cmd.Add(apiKeysOption);
|
||||||
|
cmd.Add(apiMethodsOption);
|
||||||
|
cmd.Add(includeDepsOption);
|
||||||
|
cmd.Add(sourceEnvOption);
|
||||||
|
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var output = result.GetValue(outputOption)!;
|
||||||
|
var passphrase = result.GetValue(passphraseOption);
|
||||||
|
var all = result.GetValue(allOption);
|
||||||
|
var includeDeps = result.GetValue(includeDepsOption);
|
||||||
|
var sourceEnv = result.GetValue(sourceEnvOption) ?? "cli";
|
||||||
|
|
||||||
|
var payload = new ExportBundleCommand(
|
||||||
|
All: all,
|
||||||
|
TemplateNames: result.GetValue(templatesOption),
|
||||||
|
SharedScriptNames: result.GetValue(sharedScriptsOption),
|
||||||
|
ExternalSystemNames: result.GetValue(externalSystemsOption),
|
||||||
|
DatabaseConnectionNames: result.GetValue(dbConnectionsOption),
|
||||||
|
NotificationListNames: result.GetValue(notificationListsOption),
|
||||||
|
SmtpConfigurationNames: result.GetValue(smtpConfigsOption),
|
||||||
|
ApiKeyNames: result.GetValue(apiKeysOption),
|
||||||
|
ApiMethodNames: result.GetValue(apiMethodsOption),
|
||||||
|
IncludeDependencies: includeDeps,
|
||||||
|
Passphrase: passphrase,
|
||||||
|
SourceEnvironment: sourceEnv);
|
||||||
|
|
||||||
|
return await RunBundleCommandAsync(
|
||||||
|
result, urlOption, usernameOption, passwordOption,
|
||||||
|
payload, jsonOk =>
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(jsonOk);
|
||||||
|
var base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
|
||||||
|
var byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
|
||||||
|
var bytes = Convert.FromBase64String(base64);
|
||||||
|
File.WriteAllBytes(output, bytes);
|
||||||
|
Console.WriteLine($"Wrote {bytes.Length:N0} bytes to {output} (server reported {byteCount:N0}).");
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// bundle preview
|
||||||
|
// ====================================================================
|
||||||
|
private static Command BuildPreview(
|
||||||
|
Option<string> urlOption, Option<string> formatOption,
|
||||||
|
Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var inputOption = new Option<string>("--input")
|
||||||
|
{
|
||||||
|
Description = "Bundle file path (.scadabundle)",
|
||||||
|
Required = true,
|
||||||
|
};
|
||||||
|
var passphraseOption = new Option<string?>("--passphrase")
|
||||||
|
{
|
||||||
|
Description = "Passphrase for encrypted bundles.",
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = new Command("preview")
|
||||||
|
{
|
||||||
|
Description = "Load a bundle and print the diff preview without applying",
|
||||||
|
};
|
||||||
|
cmd.Add(inputOption);
|
||||||
|
cmd.Add(passphraseOption);
|
||||||
|
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var input = result.GetValue(inputOption)!;
|
||||||
|
if (!File.Exists(input))
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
var bytes = await File.ReadAllBytesAsync(input);
|
||||||
|
var payload = new PreviewBundleCommand(
|
||||||
|
Base64Bundle: Convert.ToBase64String(bytes),
|
||||||
|
Passphrase: result.GetValue(passphraseOption));
|
||||||
|
|
||||||
|
return await RunBundleCommandAsync(
|
||||||
|
result, urlOption, usernameOption, passwordOption,
|
||||||
|
payload, jsonOk =>
|
||||||
|
{
|
||||||
|
Console.WriteLine(jsonOk);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// bundle import
|
||||||
|
// ====================================================================
|
||||||
|
private static Command BuildImport(
|
||||||
|
Option<string> urlOption, Option<string> formatOption,
|
||||||
|
Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
{
|
||||||
|
var inputOption = new Option<string>("--input")
|
||||||
|
{
|
||||||
|
Description = "Bundle file path (.scadabundle)",
|
||||||
|
Required = true,
|
||||||
|
};
|
||||||
|
var passphraseOption = new Option<string?>("--passphrase")
|
||||||
|
{
|
||||||
|
Description = "Passphrase for encrypted bundles.",
|
||||||
|
};
|
||||||
|
var onConflictOption = new Option<string>("--on-conflict")
|
||||||
|
{
|
||||||
|
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
|
||||||
|
DefaultValueFactory = _ => "overwrite",
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = new Command("import")
|
||||||
|
{
|
||||||
|
Description = "Load + apply a bundle with a single global conflict policy",
|
||||||
|
};
|
||||||
|
cmd.Add(inputOption);
|
||||||
|
cmd.Add(passphraseOption);
|
||||||
|
cmd.Add(onConflictOption);
|
||||||
|
|
||||||
|
cmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var input = result.GetValue(inputOption)!;
|
||||||
|
if (!File.Exists(input))
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
var bytes = await File.ReadAllBytesAsync(input);
|
||||||
|
var payload = new ImportBundleCommand(
|
||||||
|
Base64Bundle: Convert.ToBase64String(bytes),
|
||||||
|
Passphrase: result.GetValue(passphraseOption),
|
||||||
|
DefaultConflictPolicy: result.GetValue(onConflictOption)!);
|
||||||
|
|
||||||
|
return await RunBundleCommandAsync(
|
||||||
|
result, urlOption, usernameOption, passwordOption,
|
||||||
|
payload, jsonOk =>
|
||||||
|
{
|
||||||
|
Console.WriteLine(jsonOk);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// Shared HTTP plumbing
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same shape as <see cref="CommandHelpers.ExecuteCommandAsync"/> but with
|
||||||
|
/// a longer per-command timeout (bundles are big) and a caller-supplied
|
||||||
|
/// success handler so export can capture the base64 payload into a file
|
||||||
|
/// rather than print the whole envelope to stdout.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<int> RunBundleCommandAsync(
|
||||||
|
ParseResult result,
|
||||||
|
Option<string> urlOption,
|
||||||
|
Option<string> usernameOption,
|
||||||
|
Option<string> passwordOption,
|
||||||
|
object payload,
|
||||||
|
Func<string, int> onSuccess)
|
||||||
|
{
|
||||||
|
var config = CliConfig.Load();
|
||||||
|
var url = result.GetValue(urlOption);
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) url = config.ManagementUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError(
|
||||||
|
"No management URL specified. Use --url or set 'managementUrl' in ~/.scadalink/config.json.",
|
||||||
|
"NO_URL");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!CommandHelpers.IsValidManagementUrl(url))
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError(
|
||||||
|
$"Invalid management URL '{url}'.", "INVALID_URL");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username);
|
||||||
|
var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password);
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError(
|
||||||
|
"Credentials required. Use --username/--password or SCADALINK_USERNAME/SCADALINK_PASSWORD.",
|
||||||
|
"NO_CREDENTIALS");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandName = ManagementCommandRegistry.GetCommandName(payload.GetType());
|
||||||
|
using var client = new ManagementHttpClient(url, username, password);
|
||||||
|
var response = await client.SendCommandAsync(commandName, payload, BundleCommandTimeout);
|
||||||
|
|
||||||
|
if (response.JsonData is not null)
|
||||||
|
{
|
||||||
|
return onSuccess(response.JsonData);
|
||||||
|
}
|
||||||
|
OutputFormatter.WriteError(response.Error ?? "Unknown error", response.ErrorCode ?? "ERROR");
|
||||||
|
if (response.StatusCode == 403) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Option<IReadOnlyList<string>?> NameListOption(string name, string description)
|
||||||
|
{
|
||||||
|
var opt = new Option<IReadOnlyList<string>?>(name)
|
||||||
|
{
|
||||||
|
Description = description,
|
||||||
|
CustomParser = arg =>
|
||||||
|
{
|
||||||
|
var token = arg.Tokens.Count == 0 ? null : arg.Tokens[0].Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) return null;
|
||||||
|
return token
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToArray();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, pas
|
|||||||
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(DbConnectionCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
rootCommand.Add(ApiMethodCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
rootCommand.Add(BundleCommands.Build(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
rootCommand.SetAction(_ =>
|
rootCommand.SetAction(_ =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
168
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
168
src/ScadaLink.CentralUI/Components/Audit/AuditEventDetail.razor
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
@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">SourceNode</dt>
|
||||||
|
<dd class="col-8" data-test="field-SourceNode">@(Event.SourceNode ?? "—")</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);
|
||||||
|
}
|
||||||
@@ -1,83 +1,80 @@
|
|||||||
|
@using ScadaLink.CentralUI.Services
|
||||||
@using ScadaLink.Commons.Entities.Sites
|
@using ScadaLink.Commons.Entities.Sites
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.Commons.Types.Audit
|
@using ScadaLink.Commons.Types.Audit
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
|
@inject IAuditLogQueryService AuditLogQueryService
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
@* Node multi-select. Options are the distinct SourceNode values
|
||||||
|
observed in the AuditLog table; the service-side lookup is cached
|
||||||
|
for 60s so a render of this bar costs at most one DB hit per
|
||||||
|
minute per circuit. *@
|
||||||
|
<div class="col-auto" data-test="filter-node">
|
||||||
|
<label class="form-label small mb-1">Node</label>
|
||||||
|
<div>
|
||||||
|
<MultiSelectDropdown TValue="string"
|
||||||
|
Items="_sourceNodes"
|
||||||
|
Selected="_model.SourceNodes"
|
||||||
|
EmptyText="No nodes available"
|
||||||
|
DataTest="filter-node-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 +134,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,41 @@ 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
|
/// Distinct <c>SourceNode</c> identifiers in display order; populated once
|
||||||
|
/// when the filter bar initialises from the cached
|
||||||
|
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetDistinctSourceNodesAsync"/>
|
||||||
|
/// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
|
||||||
|
/// "No nodes available", mirroring the site loader.
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<string> _sourceNodes = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 +73,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 +83,66 @@ 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();
|
||||||
|
|
||||||
|
// Populate the Node dropdown alongside the Site dropdown. The service
|
||||||
|
// caches the distinct-nodes lookup for 60s so this never costs more
|
||||||
|
// than one DB hit per minute per circuit; on failure the dropdown
|
||||||
|
// degrades to "No nodes available" like the site loader.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nodes = await AuditLogQueryService.GetDistinctSourceNodesAsync();
|
||||||
|
_sourceNodes = nodes.ToArray();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_sourceNodes = Array.Empty<string>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -112,6 +151,7 @@ public partial class AuditFilterBar
|
|||||||
_model.Kinds.Clear();
|
_model.Kinds.Clear();
|
||||||
_model.Statuses.Clear();
|
_model.Statuses.Clear();
|
||||||
_model.SiteIdentifiers.Clear();
|
_model.SiteIdentifiers.Clear();
|
||||||
|
_model.SourceNodes.Clear();
|
||||||
_model.TimeRange = AuditTimeRangePreset.LastHour;
|
_model.TimeRange = AuditTimeRangePreset.LastHour;
|
||||||
_model.CustomFromUtc = null;
|
_model.CustomFromUtc = null;
|
||||||
_model.CustomToUtc = null;
|
_model.CustomToUtc = null;
|
||||||
@@ -119,6 +159,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 +171,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",
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ public sealed class AuditQueryModel
|
|||||||
public HashSet<AuditStatus> Statuses { get; } = new();
|
public HashSet<AuditStatus> Statuses { get; } = new();
|
||||||
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selected source-node identifiers (e.g. <c>"central-a"</c>,
|
||||||
|
/// <c>"site-plant-a-node-a"</c>). Mirrors <see cref="SiteIdentifiers"/> —
|
||||||
|
/// chip multi-select state, empty = "do not constrain", mapped through to
|
||||||
|
/// <see cref="AuditLogQueryFilter.SourceNodes"/> by <see cref="ToFilter"/>.
|
||||||
|
/// </summary>
|
||||||
|
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
|
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
|
||||||
public DateTime? CustomFromUtc { get; set; }
|
public DateTime? CustomFromUtc { get; set; }
|
||||||
public DateTime? CustomToUtc { get; set; }
|
public DateTime? CustomToUtc { get; set; }
|
||||||
@@ -47,6 +55,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 +139,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,8 +158,11 @@ 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,
|
||||||
|
SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
|
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -96,6 +105,9 @@
|
|||||||
case "Site":
|
case "Site":
|
||||||
<span class="small">@(row.SourceSiteId ?? "—")</span>
|
<span class="small">@(row.SourceSiteId ?? "—")</span>
|
||||||
break;
|
break;
|
||||||
|
case "Node":
|
||||||
|
<span class="small">@(row.SourceNode ?? "—")</span>
|
||||||
|
break;
|
||||||
case "Channel":
|
case "Channel":
|
||||||
<span class="small">@row.Channel</span>
|
<span class="small">@row.Channel</span>
|
||||||
break;
|
break;
|
||||||
@@ -111,6 +123,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.
|
||||||
///
|
///
|
||||||
@@ -116,11 +118,14 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
("OccurredAtUtc", "OccurredAtUtc"),
|
("OccurredAtUtc", "OccurredAtUtc"),
|
||||||
("Site", "Site"),
|
("Site", "Site"),
|
||||||
|
("Node", "Node"),
|
||||||
("Channel", "Channel"),
|
("Channel", "Channel"),
|
||||||
("Kind", "Kind"),
|
("Kind", "Kind"),
|
||||||
("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,160 @@
|
|||||||
@* 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>
|
||||||
|
@* Import Bundle requires Admin only — Design role is not sufficient.
|
||||||
|
Export Bundle lives in the Design section (RequireDesign). *@
|
||||||
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</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>
|
||||||
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</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 +183,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)));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
|
||||||
@inject ICentralUiRepository CentralUiRepository
|
@inject ICentralUiRepository CentralUiRepository
|
||||||
|
@inject NavigationManager Nav
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
@@ -12,6 +13,23 @@
|
|||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
|
|
||||||
|
@* Bundle Import filter chip (T24). Set via ?bundleImportId={guid} query
|
||||||
|
string so drill-ins from the Import wizard / other pages can scope this
|
||||||
|
page to a single import run. Cleared via the × button, which navigates
|
||||||
|
back to the page without the query param so the user sees all rows. *@
|
||||||
|
@if (BundleImportId is Guid bundleId)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="badge bg-primary p-2">
|
||||||
|
Filtered by Bundle Import: <code class="text-light">@bundleId.ToString()[..8]</code>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-close btn-close-white btn-sm ms-2"
|
||||||
|
aria-label="Clear Bundle Import filter"
|
||||||
|
@onclick="ClearBundleImportFilter"></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row mb-3 g-2 align-items-end">
|
<div class="row mb-3 g-2 align-items-end">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small" for="audit-filter-user">User</label>
|
<label class="form-label small" for="audit-filter-user">User</label>
|
||||||
@@ -190,6 +208,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// T24 (Transport). When non-null, scopes the page to a single bundle
|
||||||
|
/// import run. Set via the <c>?bundleImportId=</c> query string from
|
||||||
|
/// drill-ins (Import wizard summary, future BundleImported row links).
|
||||||
|
/// </summary>
|
||||||
|
[SupplyParameterFromQuery, Parameter] public Guid? BundleImportId { get; set; }
|
||||||
|
|
||||||
private string? _filterUser;
|
private string? _filterUser;
|
||||||
private string? _filterEntityType;
|
private string? _filterEntityType;
|
||||||
private string? _filterAction;
|
private string? _filterAction;
|
||||||
@@ -216,6 +241,10 @@
|
|||||||
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
|
private int TotalPages => _pageSize > 0 ? Math.Max(1, (_totalCount + _pageSize - 1) / _pageSize) : 1;
|
||||||
private bool HasMore => _page * _pageSize < _totalCount;
|
private bool HasMore => _page * _pageSize < _totalCount;
|
||||||
|
|
||||||
|
// Tracks the BundleImportId we last fetched against so a re-render with the
|
||||||
|
// same query param doesn't re-run the query on every parameter set.
|
||||||
|
private Guid? _lastFetchedBundleImportId;
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
@@ -233,6 +262,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
// T24: when the BundleImportId query param is set (or cleared), refetch
|
||||||
|
// automatically so the user lands on a pre-filtered page from a drill-in
|
||||||
|
// link without having to click Search.
|
||||||
|
if (BundleImportId != _lastFetchedBundleImportId)
|
||||||
|
{
|
||||||
|
_lastFetchedBundleImportId = BundleImportId;
|
||||||
|
_page = 1;
|
||||||
|
await FetchPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearBundleImportFilter()
|
||||||
|
{
|
||||||
|
// Strip the query param by navigating to the bare page route. The
|
||||||
|
// resulting OnParametersSetAsync run will refetch with BundleImportId
|
||||||
|
// back to null.
|
||||||
|
Nav.NavigateTo("/audit/configuration");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task Search()
|
private async Task Search()
|
||||||
{
|
{
|
||||||
_page = 1;
|
_page = 1;
|
||||||
@@ -265,6 +315,7 @@
|
|||||||
action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(),
|
action: string.IsNullOrWhiteSpace(_filterAction) ? null : _filterAction.Trim(),
|
||||||
from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes),
|
from: BrowserTime.LocalInputToUtc(_filterFrom, _browserUtcOffsetMinutes),
|
||||||
to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes),
|
to: BrowserTime.LocalInputToUtc(_filterTo, _browserUtcOffsetMinutes),
|
||||||
|
bundleImportId: BundleImportId,
|
||||||
page: _page,
|
page: _page,
|
||||||
pageSize: _pageSize);
|
pageSize: _pageSize);
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@using ScadaLink.Security
|
@using ScadaLink.Security
|
||||||
@using ScadaLink.Commons.Entities.Templates
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
|
@using ScadaLink.CentralUI.Components.Shared
|
||||||
@using ScadaLink.TemplateEngine
|
@using ScadaLink.TemplateEngine
|
||||||
@using ScadaLink.TemplateEngine.Services
|
@using ScadaLink.TemplateEngine.Services
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
@@ -79,11 +80,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
<div style="max-height: calc(100vh - 160px); overflow-y: auto; padding: 4px;">
|
||||||
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
|
<TemplateFolderTree @ref="_tree"
|
||||||
ChildrenSelector="n => n.Children"
|
Folders="_folders"
|
||||||
HasChildrenSelector="n => n.Children.Count > 0"
|
Templates="_templates"
|
||||||
KeySelector="n => (object)n.Key"
|
SelectionMode="TreeViewSelectionMode.Single"
|
||||||
StorageKey="templates-tree">
|
ExtraTemplateChildren="BuildCompositionLeavesFor"
|
||||||
|
StorageKey="templates-tree">
|
||||||
<NodeContent Context="node">
|
<NodeContent Context="node">
|
||||||
@RenderNodeLabel(node)
|
@RenderNodeLabel(node)
|
||||||
</NodeContent>
|
</NodeContent>
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
|
||||||
</EmptyContent>
|
</EmptyContent>
|
||||||
</TreeView>
|
</TemplateFolderTree>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +126,11 @@
|
|||||||
{
|
{
|
||||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||||
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
_folders = (await TemplateEngineRepository.GetAllFoldersAsync()).ToList();
|
||||||
BuildTemplateTree();
|
_templatesById = _templates.ToDictionary(t => t.Id);
|
||||||
|
_compositionsById = _templates
|
||||||
|
.SelectMany(t => t.Compositions)
|
||||||
|
.GroupBy(c => c.Id)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -133,134 +139,53 @@
|
|||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum TmplNodeKind { Folder, Template, Composition }
|
// ID lookups so RenderNodeLabel / RenderNodeContextMenu can resolve the
|
||||||
|
// entity behind a TemplateTreeNode (whose payload is just Id+Kind+Name).
|
||||||
|
private Dictionary<int, Template> _templatesById = new();
|
||||||
|
private Dictionary<int, TemplateComposition> _compositionsById = new();
|
||||||
|
|
||||||
private record TmplNode(
|
// Composition-leaf builder for TemplateFolderTree's ExtraTemplateChildren
|
||||||
string Key,
|
// hook: walks each template's compositions recursively so cascaded slots
|
||||||
TmplNodeKind Kind,
|
// appear as nested children. The Transport Export wizard intentionally
|
||||||
int EntityId,
|
// does NOT supply this hook — compositions aren't independently exportable.
|
||||||
string Label,
|
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
|
||||||
int? ParentFolderId,
|
|
||||||
int? OwnerTemplateId,
|
|
||||||
Template? Template,
|
|
||||||
TemplateComposition? Composition,
|
|
||||||
List<TmplNode> Children);
|
|
||||||
|
|
||||||
private List<TmplNode> _treeRoots = new();
|
|
||||||
|
|
||||||
private void BuildTemplateTree()
|
|
||||||
{
|
{
|
||||||
// 1. Folder nodes keyed by id
|
var result = new List<TemplateTreeNode>();
|
||||||
var folderNodes = _folders.ToDictionary(
|
|
||||||
f => f.Id,
|
|
||||||
f => new TmplNode(
|
|
||||||
Key: $"f:{f.Id}",
|
|
||||||
Kind: TmplNodeKind.Folder,
|
|
||||||
EntityId: f.Id,
|
|
||||||
Label: f.Name,
|
|
||||||
ParentFolderId: f.ParentFolderId,
|
|
||||||
OwnerTemplateId: null,
|
|
||||||
Template: null,
|
|
||||||
Composition: null,
|
|
||||||
Children: new List<TmplNode>()));
|
|
||||||
|
|
||||||
// 2. Attach folder nodes by ParentFolderId
|
|
||||||
var roots = new List<TmplNode>();
|
|
||||||
foreach (var f in _folders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var node = folderNodes[f.Id];
|
|
||||||
if (f.ParentFolderId is int pid && folderNodes.TryGetValue(pid, out var parent))
|
|
||||||
parent.Children.Add(node);
|
|
||||||
else
|
|
||||||
roots.Add(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Template nodes with composition leaves. Derived templates are
|
|
||||||
// slot-owned and reached via their parent's composition leaf — never
|
|
||||||
// shown as standalone tree nodes. Composition leaves recurse so a
|
|
||||||
// composite slot (e.g. Pump composed with TempSensor) reveals its own
|
|
||||||
// child slots when expanded.
|
|
||||||
var templatesById = _templates.ToDictionary(t => t.Id);
|
|
||||||
foreach (var t in _templates.Where(t => !t.IsDerived).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var compChildren = BuildCompositionLeaves(t, templatesById);
|
|
||||||
|
|
||||||
var tNode = new TmplNode(
|
|
||||||
Key: $"t:{t.Id}",
|
|
||||||
Kind: TmplNodeKind.Template,
|
|
||||||
EntityId: t.Id,
|
|
||||||
Label: t.Name,
|
|
||||||
ParentFolderId: t.FolderId,
|
|
||||||
OwnerTemplateId: null,
|
|
||||||
Template: t,
|
|
||||||
Composition: null,
|
|
||||||
Children: compChildren);
|
|
||||||
|
|
||||||
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
|
|
||||||
parentFolder.Children.Add(tNode);
|
|
||||||
else
|
|
||||||
roots.Add(tNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Sort each level: folders before templates, alphabetical
|
|
||||||
SortChildren(roots);
|
|
||||||
foreach (var node in folderNodes.Values)
|
|
||||||
SortChildren(node.Children);
|
|
||||||
|
|
||||||
_treeRoots = roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursive: each composition leaf's children are the composed-template's
|
|
||||||
// own composition leaves. Cascaded derived templates carry their slot
|
|
||||||
// compositions, so walking ComposedTemplateId surfaces the full nested
|
|
||||||
// structure.
|
|
||||||
private static List<TmplNode> BuildCompositionLeaves(Template owner, IReadOnlyDictionary<int, Template> templatesById)
|
|
||||||
{
|
|
||||||
var result = new List<TmplNode>();
|
|
||||||
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var nestedChildren = templatesById.TryGetValue(c.ComposedTemplateId, out var composed)
|
var node = new TemplateTreeNode
|
||||||
? BuildCompositionLeaves(composed, templatesById)
|
{
|
||||||
: new List<TmplNode>();
|
Kind = TemplateTreeNodeKind.Composition,
|
||||||
|
Id = c.Id,
|
||||||
|
Name = c.InstanceName,
|
||||||
|
};
|
||||||
|
|
||||||
result.Add(new TmplNode(
|
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
|
||||||
Key: $"c:{c.Id}",
|
{
|
||||||
Kind: TmplNodeKind.Composition,
|
foreach (var nested in BuildCompositionLeavesFor(composed))
|
||||||
EntityId: c.Id,
|
{
|
||||||
Label: c.InstanceName,
|
node.Children.Add(nested);
|
||||||
ParentFolderId: null,
|
}
|
||||||
OwnerTemplateId: owner.Id,
|
}
|
||||||
Template: null,
|
|
||||||
Composition: c,
|
result.Add(node);
|
||||||
Children: nestedChildren));
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SortChildren(List<TmplNode> children)
|
private TemplateFolderTree _tree = default!;
|
||||||
{
|
|
||||||
children.Sort((a, b) =>
|
|
||||||
{
|
|
||||||
var kindOrder = (int)a.Kind - (int)b.Kind;
|
|
||||||
if (kindOrder != 0) return kindOrder;
|
|
||||||
return string.Compare(a.Label, b.Label, StringComparison.OrdinalIgnoreCase);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private TreeView<TmplNode> _tree = default!;
|
|
||||||
|
|
||||||
private void OpenTemplate(int templateId) =>
|
private void OpenTemplate(int templateId) =>
|
||||||
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
NavigationManager.NavigateTo($"/design/templates/{templateId}");
|
||||||
|
|
||||||
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeLabel(TemplateTreeNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
case TmplNodeKind.Folder:
|
case TemplateTreeNodeKind.Folder:
|
||||||
var folderOpen = _tree?.IsExpanded(node.Key) ?? false;
|
<span class="tv-glyph"><i class="bi bi-folder"></i></span>
|
||||||
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
|
|
||||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
title="@node.Label">@node.Label</span>
|
title="@node.Name">@node.Name</span>
|
||||||
@if (node.Children.Count > 0)
|
@if (node.Children.Count > 0)
|
||||||
{
|
{
|
||||||
<span class="tv-meta">
|
<span class="tv-meta">
|
||||||
@@ -269,47 +194,58 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TemplateTreeNodeKind.Template:
|
||||||
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
||||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
title="@node.Label"
|
title="@node.Name"
|
||||||
@ondblclick="() => OpenTemplate(node.EntityId)">@node.Label</span>
|
@ondblclick="() => OpenTemplate(node.Id)">@node.Name</span>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TemplateTreeNodeKind.Composition:
|
||||||
|
var composedId = _compositionsById.TryGetValue(node.Id, out var comp) ? comp.ComposedTemplateId : 0;
|
||||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||||
<span class="tv-label" title="@node.Label"
|
<span class="tv-label" title="@node.Name"
|
||||||
@ondblclick="() => OpenTemplate(node.Composition!.ComposedTemplateId)">@node.Label</span>
|
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder =>
|
private RenderFragment RenderNodeContextMenu(TemplateTreeNode node) => __builder =>
|
||||||
{
|
{
|
||||||
switch (node.Kind)
|
switch (node.Kind)
|
||||||
{
|
{
|
||||||
case TmplNodeKind.Folder:
|
case TemplateTreeNodeKind.Folder:
|
||||||
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.EntityId)">New Folder</button>
|
<button class="dropdown-item" @onclick="() => OpenNewFolderDialog(node.Id)">New Folder</button>
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.EntityId}")'>New Template</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?folderId={node.Id}")'>New Template</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.EntityId, node.Label)">Rename</button>
|
<button class="dropdown-item" @onclick="() => OpenRenameFolderDialog(node.Id, node.Name)">Rename</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
<button class="dropdown-item" @onclick="() => OpenMoveFolderDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.EntityId, node.Label)">Delete</button>
|
<button class="dropdown-item text-danger" @onclick="() => DeleteFolder(node.Id, node.Name)">Delete</button>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Template:
|
case TemplateTreeNodeKind.Template:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.EntityId}")'>New Inheriting Template</button>
|
var tmpl = _templatesById.TryGetValue(node.Id, out var t) ? t : null;
|
||||||
<button class="dropdown-item" @onclick="() => OpenComposeDialog(node.Template!)">Compose into…</button>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/create?parentId={node.Id}")'>New Inheriting Template</button>
|
||||||
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.EntityId, node.Label)">Move to Folder…</button>
|
@if (tmpl != null)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenComposeDialog(tmpl)">Compose into…</button>
|
||||||
|
}
|
||||||
|
<button class="dropdown-item" @onclick="() => OpenMoveTemplateDialog(node.Id, node.Name)">Move to Folder…</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template!)">Delete</button>
|
@if (tmpl != null)
|
||||||
|
{
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(tmpl)">Delete</button>
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TmplNodeKind.Composition:
|
case TemplateTreeNodeKind.Composition:
|
||||||
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{node.Composition!.ComposedTemplateId}")'>Open composed template</button>
|
if (_compositionsById.TryGetValue(node.Id, out var ctx))
|
||||||
<button class="dropdown-item" @onclick="() => RenameComposition(node.Composition!)">Rename…</button>
|
{
|
||||||
<div class="dropdown-divider"></div>
|
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
|
||||||
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(node.Composition!)">Delete</button>
|
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
@page "/design/transport/export"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
|
@using ScadaLink.Commons.Entities.Scripts
|
||||||
|
@using ScadaLink.Commons.Entities.ExternalSystems
|
||||||
|
@using ScadaLink.Commons.Entities.Notifications
|
||||||
|
@using ScadaLink.Commons.Entities.InboundApi
|
||||||
|
@using ScadaLink.CentralUI.Components.Shared
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||||
|
|
||||||
|
@*
|
||||||
|
TransportExport wizard (Component #24, Task T21).
|
||||||
|
|
||||||
|
A 4-step linear wizard:
|
||||||
|
Step 1 — Select : templates (tree, checkbox-mode) + flat artifact lists.
|
||||||
|
Step 2 — Review : resolved closure split into seed vs auto-included.
|
||||||
|
Step 3 — Encrypt : passphrase + confirm, or explicit unencrypted opt-out.
|
||||||
|
Step 4 — Download : streams the bundle bytes to the browser via JS interop.
|
||||||
|
|
||||||
|
The page is Design-role gated; deeper interactions (audit row, secrets
|
||||||
|
warning) come from BundleExporter itself.
|
||||||
|
*@
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
}
|
||||||
|
else if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<h4 class="mb-3">Export Bundle</h4>
|
||||||
|
|
||||||
|
@* Step indicator — Bootstrap progress with discrete numbered pills. *@
|
||||||
|
<nav aria-label="Export wizard steps" class="mb-4">
|
||||||
|
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
||||||
|
<li class="@StepClass(ExportWizardStep.Select)">
|
||||||
|
<span class="badge rounded-pill me-1">1</span> Select
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ExportWizardStep.Review)">
|
||||||
|
<span class="badge rounded-pill me-1">2</span> Review
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ExportWizardStep.Encrypt)">
|
||||||
|
<span class="badge rounded-pill me-1">3</span> Encrypt
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ExportWizardStep.Download)">
|
||||||
|
<span class="badge rounded-pill me-1">4</span> Download
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@switch (_step)
|
||||||
|
{
|
||||||
|
case ExportWizardStep.Select:
|
||||||
|
@RenderStepSelect();
|
||||||
|
break;
|
||||||
|
case ExportWizardStep.Review:
|
||||||
|
@RenderStepReview();
|
||||||
|
break;
|
||||||
|
case ExportWizardStep.Encrypt:
|
||||||
|
@RenderStepEncrypt();
|
||||||
|
break;
|
||||||
|
case ExportWizardStep.Download:
|
||||||
|
@RenderStepDownload();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string StepClass(ExportWizardStep s) =>
|
||||||
|
s == _step ? "fw-semibold text-primary"
|
||||||
|
: (int)s < (int)_step ? "text-success"
|
||||||
|
: "text-muted";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 1 — Select
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepSelect() => __builder =>
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="export-filter" class="form-label">Search</label>
|
||||||
|
<input id="export-filter" type="search" class="form-control"
|
||||||
|
placeholder="Filter all artifacts…"
|
||||||
|
@bind="_filter" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-templates">
|
||||||
|
<legend class="h6">Templates</legend>
|
||||||
|
@if (_templates.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted small fst-italic">No templates.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="max-height: 320px; overflow-y: auto; padding: 4px; border: 1px solid var(--bs-border-color); border-radius: 4px;">
|
||||||
|
<TemplateFolderTree Folders="_folders"
|
||||||
|
Templates="_templates"
|
||||||
|
SelectionMode="TreeViewSelectionMode.Checkbox"
|
||||||
|
SelectedKeys="_selectedTemplateKeys"
|
||||||
|
SelectedKeysChanged="OnTemplateSelectionChanged"
|
||||||
|
Filter="@_filter" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-shared-scripts">
|
||||||
|
<legend class="h6">Shared Scripts</legend>
|
||||||
|
@RenderCheckboxList(_sharedScripts, s => s.Id, s => s.Name, _selectedSharedScripts)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-external-systems">
|
||||||
|
<legend class="h6">External Systems</legend>
|
||||||
|
@RenderCheckboxList(_externalSystems, e => e.Id, e => e.Name, _selectedExternalSystems)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-db-connections">
|
||||||
|
<legend class="h6">Database Connections</legend>
|
||||||
|
@RenderCheckboxList(_dbConnections, d => d.Id, d => d.Name, _selectedDbConnections)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-notification-lists">
|
||||||
|
<legend class="h6">Notification Lists</legend>
|
||||||
|
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
|
||||||
|
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
|
||||||
|
Selecting a notification list does <strong>not</strong> automatically include its
|
||||||
|
SMTP configuration. SMTP configurations are environment-specific and must be
|
||||||
|
selected separately if you want them in the bundle.
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-smtp-configs">
|
||||||
|
<legend class="h6">SMTP Configurations</legend>
|
||||||
|
@RenderCheckboxList(_smtpConfigs, s => s.Id, s => s.Host, _selectedSmtpConfigs)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-api-keys">
|
||||||
|
<legend class="h6">API Keys</legend>
|
||||||
|
@RenderCheckboxList(_apiKeys, k => k.Id, k => k.Name, _selectedApiKeys)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||||
|
<legend class="h6">API Methods</legend>
|
||||||
|
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
disabled="@(!HasAnySelection || _resolving)"
|
||||||
|
@onclick="GoToReviewAsync">
|
||||||
|
@(_resolving ? "Resolving…" : "Next")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private void OnTemplateSelectionChanged(HashSet<object> keys)
|
||||||
|
{
|
||||||
|
// TemplateFolderTree hands back a fresh HashSet each time; mirror it
|
||||||
|
// into our owned set so subsequent renders see the same instance the
|
||||||
|
// tree is binding against.
|
||||||
|
_selectedTemplateKeys.Clear();
|
||||||
|
foreach (var k in keys)
|
||||||
|
{
|
||||||
|
_selectedTemplateKeys.Add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RenderFragment RenderCheckboxList<T>(
|
||||||
|
IReadOnlyList<T> items,
|
||||||
|
Func<T, int> idOf,
|
||||||
|
Func<T, string> nameOf,
|
||||||
|
HashSet<int> selected) => __builder =>
|
||||||
|
{
|
||||||
|
var visible = items.Where(x => MatchesFilter(nameOf(x))).ToList();
|
||||||
|
if (visible.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted small fst-italic">No matches.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-1">
|
||||||
|
@foreach (var item in visible)
|
||||||
|
{
|
||||||
|
var id = idOf(item);
|
||||||
|
var inputId = $"chk-{typeof(T).Name}-{id}";
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox"
|
||||||
|
id="@inputId"
|
||||||
|
checked="@selected.Contains(id)"
|
||||||
|
@onchange="e => Toggle(selected, id, ((bool?)e.Value) == true)" />
|
||||||
|
<label class="form-check-label" for="@inputId">@nameOf(item)</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 2 — Review
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepReview() => __builder =>
|
||||||
|
{
|
||||||
|
if (_resolved is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">Nothing resolved yet — please go back to step 1.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seedTemplateIds = new HashSet<int>(SelectedTemplateIds());
|
||||||
|
var seedSharedScripts = new HashSet<int>(_selectedSharedScripts);
|
||||||
|
var seedExternalSystems = new HashSet<int>(_selectedExternalSystems);
|
||||||
|
|
||||||
|
var autoTemplates = AutoIncluded(_resolved.Templates, seedTemplateIds, t => t.Id);
|
||||||
|
var autoShared = AutoIncluded(_resolved.SharedScripts, seedSharedScripts, s => s.Id);
|
||||||
|
var autoExternals = AutoIncluded(_resolved.ExternalSystems, seedExternalSystems, e => e.Id);
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
The resolver walked your selection's dependency graph and produced the closure
|
||||||
|
below. Items under <em>Auto-included</em> were pulled in because the items you
|
||||||
|
ticked reference them; unticking
|
||||||
|
<em>Include all dependencies</em> exports the seed alone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="include-deps"
|
||||||
|
checked="@_includeDependencies"
|
||||||
|
@onchange="async e => { _includeDependencies = ((bool?)e.Value) == true; await ReresolveAsync(); }" />
|
||||||
|
<label class="form-check-label" for="include-deps">Include all dependencies</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6" data-testid="seed-group">
|
||||||
|
<h6>Selected by you</h6>
|
||||||
|
<ul class="small list-unstyled mb-0">
|
||||||
|
@foreach (var t in _resolved.Templates.Where(t => seedTemplateIds.Contains(t.Id)).OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>Template: @t.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var s in _resolved.SharedScripts.Where(s => seedSharedScripts.Contains(s.Id)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>SharedScript: @s.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var e in _resolved.ExternalSystems.Where(e => seedExternalSystems.Contains(e.Id)).OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>ExternalSystem: @e.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var d in _resolved.DatabaseConnections.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>DatabaseConnection: @d.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var n in _resolved.NotificationLists.OrderBy(n => n.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>NotificationList: @n.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var s in _resolved.SmtpConfigs.OrderBy(s => s.Host, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>SmtpConfig: @s.Host</li>
|
||||||
|
}
|
||||||
|
@foreach (var k in _resolved.ApiKeys.OrderBy(k => k.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>ApiKey: @k.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var m in _resolved.ApiMethods.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>ApiMethod: @m.Name</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6" data-testid="auto-group">
|
||||||
|
<h6>Auto-included (dependencies)</h6>
|
||||||
|
@if (autoTemplates.Count + autoShared.Count + autoExternals.Count + _resolved.TemplateFolders.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="small text-muted fst-italic">No additional dependencies.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="small list-unstyled mb-0">
|
||||||
|
@foreach (var f in _resolved.TemplateFolders.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>TemplateFolder: @f.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var t in autoTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>Template: @t.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var s in autoShared.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>SharedScript: @s.Name</li>
|
||||||
|
}
|
||||||
|
@foreach (var e in autoExternals.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<li>ExternalSystem: @e.Name</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToSelect">Back</button>
|
||||||
|
<button class="btn btn-primary" @onclick="GoToEncrypt">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 3 — Encrypt
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepEncrypt() => __builder =>
|
||||||
|
{
|
||||||
|
var strength = PassphraseStrength(_passphrase);
|
||||||
|
var strengthLabel = strength switch
|
||||||
|
{
|
||||||
|
0 => "Too short",
|
||||||
|
1 => "Weak",
|
||||||
|
2 => "Fair",
|
||||||
|
3 => "Good",
|
||||||
|
_ => "Strong",
|
||||||
|
};
|
||||||
|
var strengthColor = strength switch
|
||||||
|
{
|
||||||
|
<= 1 => "bg-danger",
|
||||||
|
2 => "bg-warning",
|
||||||
|
3 => "bg-info",
|
||||||
|
_ => "bg-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
<div>
|
||||||
|
@if (_secretCount > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning" role="alert" data-testid="secrets-warning">
|
||||||
|
<strong>@_secretCount</strong> secret @(_secretCount == 1 ? "field" : "fields")
|
||||||
|
will be encrypted (external-system credentials, SMTP credentials, and database
|
||||||
|
connection strings).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info small" role="alert" data-testid="secrets-warning">
|
||||||
|
The selected closure contains no secret fields, but the bundle's content
|
||||||
|
payload will still be encrypted in full when a passphrase is supplied.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="passphrase" class="form-label">Passphrase</label>
|
||||||
|
<input id="passphrase" type="password" class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@bind="_passphrase" @bind:event="oninput" />
|
||||||
|
<div class="progress mt-1" style="height: 4px;">
|
||||||
|
<div class="progress-bar @strengthColor"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: @(strength * 25)%;"
|
||||||
|
aria-valuenow="@(strength * 25)" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Strength: @strengthLabel · minimum 8 characters.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="passphrase-confirm" class="form-label">Confirm passphrase</label>
|
||||||
|
<input id="passphrase-confirm" type="password" class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@bind="_passphraseConfirm" @bind:event="oninput" />
|
||||||
|
@if (!string.IsNullOrEmpty(_passphraseConfirm) && _passphrase != _passphraseConfirm)
|
||||||
|
{
|
||||||
|
<div class="form-text text-danger">Passphrases do not match.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="small">
|
||||||
|
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
|
||||||
|
Export without encryption…
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (_showUnencryptedConfirm)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" data-testid="unencrypted-confirm">
|
||||||
|
<strong>Unencrypted export</strong> — the bundle will contain all secret fields
|
||||||
|
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
||||||
|
passwords, and database connection strings. The audit log will record this as
|
||||||
|
<code>UnencryptedBundleExport</code>.
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||||
|
Yes, export without encryption
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
disabled="@(!PassphraseValid)"
|
||||||
|
@onclick="StartEncryptedExportAsync">
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 4 — Download
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepDownload() => __builder =>
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
@if (_downloadInProgress)
|
||||||
|
{
|
||||||
|
<LoadingSpinner IsLoading="true" />
|
||||||
|
<p class="text-body-secondary">Building bundle…</p>
|
||||||
|
}
|
||||||
|
else if (_downloadError != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Export failed:</strong> @_downloadError
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToReview">Back</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-success" data-testid="download-summary">
|
||||||
|
<strong>Bundle ready.</strong> Your browser is downloading the file.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="row small">
|
||||||
|
<dt class="col-sm-3">Filename</dt>
|
||||||
|
<dd class="col-sm-9"><code>@_downloadFilename</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Size</dt>
|
||||||
|
<dd class="col-sm-9">@FormatBytes(_downloadSize)</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">SHA-256</dt>
|
||||||
|
<dd class="col-sm-9"><code>@_downloadSha256</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Encryption</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@if (_exportUnencrypted)
|
||||||
|
{
|
||||||
|
<span class="text-danger">Unencrypted (audited as <code>UnencryptedBundleExport</code>)</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-success">AES-256-GCM with PBKDF2-SHA256</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" @onclick="Done">Done</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatBytes(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes < 1024) return $"{bytes} B";
|
||||||
|
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:0.0} KB";
|
||||||
|
return $"{bytes / (1024.0 * 1024.0):0.00} MB";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using ScadaLink.CentralUI.Auth;
|
||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Entities.InboundApi;
|
||||||
|
using ScadaLink.Commons.Entities.Notifications;
|
||||||
|
using ScadaLink.Commons.Entities.Scripts;
|
||||||
|
using ScadaLink.Commons.Entities.Templates;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
using ScadaLink.Transport;
|
||||||
|
using ScadaLink.Transport.Export;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Pages.Design;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Code-behind for the TransportExport wizard (Transport feature, Task T21).
|
||||||
|
///
|
||||||
|
/// Four-step state machine:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="ExportWizardStep.Select"/> — pick templates + flat artifact lists.</item>
|
||||||
|
/// <item><see cref="ExportWizardStep.Review"/> — show resolved closure (seed + auto-included).</item>
|
||||||
|
/// <item><see cref="ExportWizardStep.Encrypt"/> — passphrase + secret-count warning, or explicit unencrypted opt-out.</item>
|
||||||
|
/// <item><see cref="ExportWizardStep.Download"/> — call <see cref="IBundleExporter"/>, render the file via JS interop.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// The wizard transitions are linear; "Back" returns to the previous step
|
||||||
|
/// without clearing selection state. "Done" on Step 4 resets to Step 1 fresh.
|
||||||
|
///
|
||||||
|
/// <c>SourceEnvironment</c> is sourced from <see cref="TransportOptions.SourceEnvironment"/>
|
||||||
|
/// (bound from <c>ScadaLink:Transport:SourceEnvironment</c>) so a multi-cluster
|
||||||
|
/// deployment can label its bundles distinctly. Defaults to <c>"scadalink"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TransportExport : ComponentBase
|
||||||
|
{
|
||||||
|
public enum ExportWizardStep
|
||||||
|
{
|
||||||
|
Select = 1,
|
||||||
|
Review = 2,
|
||||||
|
Encrypt = 3,
|
||||||
|
Download = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Injected services ----
|
||||||
|
[Inject] private IBundleExporter BundleExporter { get; set; } = default!;
|
||||||
|
[Inject] private ITemplateEngineRepository TemplateRepo { get; set; } = default!;
|
||||||
|
[Inject] private IExternalSystemRepository ExternalRepo { get; set; } = default!;
|
||||||
|
[Inject] private INotificationRepository NotificationRepo { get; set; } = default!;
|
||||||
|
[Inject] private IInboundApiRepository InboundApiRepo { get; set; } = default!;
|
||||||
|
[Inject] private DependencyResolver DepResolver { get; set; } = default!;
|
||||||
|
[Inject] private IJSRuntime JS { get; set; } = default!;
|
||||||
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||||
|
[Inject] private IOptions<TransportOptions> TransportOptions { get; set; } = default!;
|
||||||
|
|
||||||
|
// ---- Wizard state ----
|
||||||
|
private ExportWizardStep _step = ExportWizardStep.Select;
|
||||||
|
private bool _loading = true;
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
// ---- Step 1: source data ----
|
||||||
|
private List<Template> _templates = new();
|
||||||
|
private List<TemplateFolder> _folders = new();
|
||||||
|
private List<SharedScript> _sharedScripts = new();
|
||||||
|
private List<ExternalSystemDefinition> _externalSystems = new();
|
||||||
|
private List<DatabaseConnectionDefinition> _dbConnections = new();
|
||||||
|
private List<NotificationList> _notificationLists = new();
|
||||||
|
private List<SmtpConfiguration> _smtpConfigs = new();
|
||||||
|
private List<ApiKey> _apiKeys = new();
|
||||||
|
private List<ApiMethod> _apiMethods = new();
|
||||||
|
|
||||||
|
// ---- Step 1: selection state ----
|
||||||
|
// TemplateFolderTree uses string keys ("t:{id}", "f:{id}") via TemplateTreeNode.Key.
|
||||||
|
// Templates are selected via the tree; the other artifacts use flat checkbox lists
|
||||||
|
// backed by integer-id HashSets so wiring is uniform across categories.
|
||||||
|
private readonly HashSet<object> _selectedTemplateKeys = new();
|
||||||
|
private readonly HashSet<int> _selectedSharedScripts = new();
|
||||||
|
private readonly HashSet<int> _selectedExternalSystems = new();
|
||||||
|
private readonly HashSet<int> _selectedDbConnections = new();
|
||||||
|
private readonly HashSet<int> _selectedNotificationLists = new();
|
||||||
|
private readonly HashSet<int> _selectedSmtpConfigs = new();
|
||||||
|
private readonly HashSet<int> _selectedApiKeys = new();
|
||||||
|
private readonly HashSet<int> _selectedApiMethods = new();
|
||||||
|
private string _filter = string.Empty;
|
||||||
|
private bool _includeDependencies = true;
|
||||||
|
|
||||||
|
// ---- Step 2: dependency-resolved closure ----
|
||||||
|
private ResolvedExport? _resolved;
|
||||||
|
private bool _resolving;
|
||||||
|
|
||||||
|
// ---- Step 3: encryption ----
|
||||||
|
private string _passphrase = string.Empty;
|
||||||
|
private string _passphraseConfirm = string.Empty;
|
||||||
|
private bool _showUnencryptedConfirm;
|
||||||
|
private bool _exportUnencrypted;
|
||||||
|
private int _secretCount;
|
||||||
|
|
||||||
|
// ---- Step 4: download result ----
|
||||||
|
private string? _downloadFilename;
|
||||||
|
private long _downloadSize;
|
||||||
|
private string? _downloadSha256;
|
||||||
|
private bool _downloadInProgress;
|
||||||
|
private string? _downloadError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAllAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAllAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_templates = (await TemplateRepo.GetAllTemplatesAsync()).ToList();
|
||||||
|
_folders = (await TemplateRepo.GetAllFoldersAsync()).ToList();
|
||||||
|
_sharedScripts = (await TemplateRepo.GetAllSharedScriptsAsync()).ToList();
|
||||||
|
_externalSystems = (await ExternalRepo.GetAllExternalSystemsAsync()).ToList();
|
||||||
|
_dbConnections = (await ExternalRepo.GetAllDatabaseConnectionsAsync()).ToList();
|
||||||
|
_notificationLists = (await NotificationRepo.GetAllNotificationListsAsync()).ToList();
|
||||||
|
_smtpConfigs = (await NotificationRepo.GetAllSmtpConfigurationsAsync()).ToList();
|
||||||
|
_apiKeys = (await InboundApiRepo.GetAllApiKeysAsync()).ToList();
|
||||||
|
_apiMethods = (await InboundApiRepo.GetAllApiMethodsAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to load export source data: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Selection helpers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project the tree's checkbox-keys back to template ids. Template keys are
|
||||||
|
/// the strings produced by <c>TemplateTreeNode.Key</c> — folder ids are
|
||||||
|
/// excluded (folders aren't directly exportable; their templates are).
|
||||||
|
/// </summary>
|
||||||
|
private IReadOnlyList<int> SelectedTemplateIds()
|
||||||
|
{
|
||||||
|
var ids = new List<int>();
|
||||||
|
foreach (var key in _selectedTemplateKeys)
|
||||||
|
{
|
||||||
|
if (key is string s && s.StartsWith("t:", StringComparison.Ordinal)
|
||||||
|
&& int.TryParse(s.AsSpan(2), out var id))
|
||||||
|
{
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the user has ticked at least one item in any category. Drives
|
||||||
|
/// the "Next" button on Step 1.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HasAnySelection =>
|
||||||
|
SelectedTemplateIds().Count > 0
|
||||||
|
|| _selectedSharedScripts.Count > 0
|
||||||
|
|| _selectedExternalSystems.Count > 0
|
||||||
|
|| _selectedDbConnections.Count > 0
|
||||||
|
|| _selectedNotificationLists.Count > 0
|
||||||
|
|| _selectedSmtpConfigs.Count > 0
|
||||||
|
|| _selectedApiKeys.Count > 0
|
||||||
|
|| _selectedApiMethods.Count > 0;
|
||||||
|
|
||||||
|
private bool PassphraseValid =>
|
||||||
|
!string.IsNullOrEmpty(_passphrase)
|
||||||
|
&& _passphrase.Length >= 8
|
||||||
|
&& _passphrase == _passphraseConfirm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Coarse strength score 0-4 based on length + character-class diversity. Used
|
||||||
|
/// to colour an inline strength meter; never used to gate the export — the
|
||||||
|
/// importer enforces its own strength + lockout policies.
|
||||||
|
/// </summary>
|
||||||
|
internal static int PassphraseStrength(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return 0;
|
||||||
|
var score = 0;
|
||||||
|
if (s.Length >= 8) score++;
|
||||||
|
if (s.Length >= 16) score++;
|
||||||
|
if (s.Any(char.IsUpper) && s.Any(char.IsLower)) score++;
|
||||||
|
if (s.Any(char.IsDigit) && s.Any(ch => !char.IsLetterOrDigit(ch))) score++;
|
||||||
|
return Math.Min(4, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Wizard transitions ----
|
||||||
|
|
||||||
|
private ExportSelection BuildSelection()
|
||||||
|
{
|
||||||
|
return new ExportSelection(
|
||||||
|
TemplateIds: SelectedTemplateIds(),
|
||||||
|
SharedScriptIds: _selectedSharedScripts.ToList(),
|
||||||
|
ExternalSystemIds: _selectedExternalSystems.ToList(),
|
||||||
|
DatabaseConnectionIds: _selectedDbConnections.ToList(),
|
||||||
|
NotificationListIds: _selectedNotificationLists.ToList(),
|
||||||
|
SmtpConfigurationIds: _selectedSmtpConfigs.ToList(),
|
||||||
|
ApiKeyIds: _selectedApiKeys.ToList(),
|
||||||
|
ApiMethodIds: _selectedApiMethods.ToList(),
|
||||||
|
IncludeDependencies: _includeDependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GoToReviewAsync()
|
||||||
|
{
|
||||||
|
if (!HasAnySelection) return;
|
||||||
|
_resolving = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var selection = BuildSelection();
|
||||||
|
_resolved = await DepResolver.ResolveAsync(selection, CancellationToken.None);
|
||||||
|
_step = ExportWizardStep.Review;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to resolve dependencies: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_resolving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReresolveAsync()
|
||||||
|
{
|
||||||
|
// Re-run resolution from Step 2 when the user toggles IncludeDependencies.
|
||||||
|
_resolving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_resolved = await DepResolver.ResolveAsync(BuildSelection(), CancellationToken.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_resolving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoToEncrypt()
|
||||||
|
{
|
||||||
|
// Recompute the secret-field count from the resolved closure so the
|
||||||
|
// warning banner stays honest if the user backed up and re-resolved.
|
||||||
|
if (_resolved is not null)
|
||||||
|
{
|
||||||
|
_secretCount = CountSecrets(_resolved);
|
||||||
|
}
|
||||||
|
_step = ExportWizardStep.Encrypt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Count the secret fields that <see cref="BundleSecretEncryptor"/> will
|
||||||
|
/// envelope-encrypt. Surfaces in the Step 3 warning banner so the user
|
||||||
|
/// knows exactly what an unencrypted export would leak.
|
||||||
|
/// </summary>
|
||||||
|
internal static int CountSecrets(ResolvedExport resolved)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var es in resolved.ExternalSystems)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(es.AuthConfiguration)) count++;
|
||||||
|
}
|
||||||
|
foreach (var smtp in resolved.SmtpConfigs)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(smtp.Credentials)) count++;
|
||||||
|
}
|
||||||
|
foreach (var db in resolved.DatabaseConnections)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(db.ConnectionString)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToSelect() => _step = ExportWizardStep.Select;
|
||||||
|
private void BackToReview() => _step = ExportWizardStep.Review;
|
||||||
|
|
||||||
|
private void OpenUnencryptedConfirm()
|
||||||
|
{
|
||||||
|
_showUnencryptedConfirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmUnencryptedExport()
|
||||||
|
{
|
||||||
|
_showUnencryptedConfirm = false;
|
||||||
|
_exportUnencrypted = true;
|
||||||
|
await StartExportAsync(passphrase: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelUnencryptedConfirm()
|
||||||
|
{
|
||||||
|
_showUnencryptedConfirm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartEncryptedExportAsync()
|
||||||
|
{
|
||||||
|
if (!PassphraseValid) return;
|
||||||
|
_exportUnencrypted = false;
|
||||||
|
await StartExportAsync(_passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Final export step: invokes <see cref="IBundleExporter.ExportAsync"/>,
|
||||||
|
/// captures the bundle bytes, computes a display-side SHA-256 (matching
|
||||||
|
/// the manifest's content hash naming), and pushes the file to the browser
|
||||||
|
/// via JS interop. Errors surface inline; the page never throws to the user.
|
||||||
|
/// </summary>
|
||||||
|
private async Task StartExportAsync(string? passphrase)
|
||||||
|
{
|
||||||
|
_step = ExportWizardStep.Download;
|
||||||
|
_downloadInProgress = true;
|
||||||
|
_downloadError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await Auth.GetCurrentUsernameAsync();
|
||||||
|
var sourceEnv = TransportOptions.Value.SourceEnvironment;
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceEnv))
|
||||||
|
{
|
||||||
|
sourceEnv = "scadalink";
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection = BuildSelection();
|
||||||
|
await using var stream = await BundleExporter.ExportAsync(
|
||||||
|
selection, user, sourceEnv, passphrase, CancellationToken.None);
|
||||||
|
|
||||||
|
byte[] bytes;
|
||||||
|
if (stream is MemoryStream ms)
|
||||||
|
{
|
||||||
|
bytes = ms.ToArray();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using var copy = new MemoryStream();
|
||||||
|
await stream.CopyToAsync(copy);
|
||||||
|
bytes = copy.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
_downloadSize = bytes.LongLength;
|
||||||
|
_downloadSha256 = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||||
|
_downloadFilename = BuildFilename(sourceEnv);
|
||||||
|
|
||||||
|
var base64 = Convert.ToBase64String(bytes);
|
||||||
|
await JS.InvokeVoidAsync("scadalinkTransport.downloadBundle", _downloadFilename, base64);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_downloadError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_downloadInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filename pattern <c>scadabundle-{sourceEnv}-{yyyy-MM-dd-HHmmss}.scadabundle</c>.
|
||||||
|
/// Source environment characters are sanitised to a filename-safe alphabet so
|
||||||
|
/// odd chars in <c>TransportOptions.SourceEnvironment</c> don't produce
|
||||||
|
/// browser-rejected filenames.
|
||||||
|
/// </summary>
|
||||||
|
internal static string BuildFilename(string sourceEnvironment, DateTimeOffset? nowUtc = null)
|
||||||
|
{
|
||||||
|
var safe = SanitizeForFilename(sourceEnvironment);
|
||||||
|
var ts = (nowUtc ?? DateTimeOffset.UtcNow).ToString("yyyy-MM-dd-HHmmss");
|
||||||
|
return $"scadabundle-{safe}-{ts}.scadabundle";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeForFilename(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) return "scadalink";
|
||||||
|
var chars = input.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' ? c : '-').ToArray();
|
||||||
|
return new string(chars);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Done()
|
||||||
|
{
|
||||||
|
// Reset every wizard piece so the operator can immediately start a fresh
|
||||||
|
// export without page-refresh-induced data reload.
|
||||||
|
_step = ExportWizardStep.Select;
|
||||||
|
_selectedTemplateKeys.Clear();
|
||||||
|
_selectedSharedScripts.Clear();
|
||||||
|
_selectedExternalSystems.Clear();
|
||||||
|
_selectedDbConnections.Clear();
|
||||||
|
_selectedNotificationLists.Clear();
|
||||||
|
_selectedSmtpConfigs.Clear();
|
||||||
|
_selectedApiKeys.Clear();
|
||||||
|
_selectedApiMethods.Clear();
|
||||||
|
_filter = string.Empty;
|
||||||
|
_includeDependencies = true;
|
||||||
|
_resolved = null;
|
||||||
|
_passphrase = string.Empty;
|
||||||
|
_passphraseConfirm = string.Empty;
|
||||||
|
_exportUnencrypted = false;
|
||||||
|
_showUnencryptedConfirm = false;
|
||||||
|
_downloadFilename = null;
|
||||||
|
_downloadSize = 0;
|
||||||
|
_downloadSha256 = null;
|
||||||
|
_downloadError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Flat-list filter helpers (search box reuses TemplateFolderTree.Filter for the tree) ----
|
||||||
|
private bool MatchesFilter(string name) =>
|
||||||
|
string.IsNullOrWhiteSpace(_filter)
|
||||||
|
|| name.Contains(_filter, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static void Toggle(HashSet<int> set, int id, bool value)
|
||||||
|
{
|
||||||
|
if (value) set.Add(id);
|
||||||
|
else set.Remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Step 2 grouping helpers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Items that are in <paramref name="all"/> but NOT in <paramref name="seed"/> —
|
||||||
|
/// the auto-included dependencies the resolver pulled in for the user.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<T> AutoIncluded<T>(IReadOnlyList<T> all, IReadOnlyCollection<int> seed, Func<T, int> idOf)
|
||||||
|
{
|
||||||
|
return all.Where(x => !seed.Contains(idOf(x))).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user