Compare commits
90 Commits
e3345a0fc1
...
f6cd097c62
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -140,6 +147,7 @@ This project contains design documentation for a distributed SCADA system built
|
|||||||
- 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+34
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+15
@@ -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."
|
||||||
Executable
+60
@@ -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."
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+19
@@ -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;\""
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -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) |
|
||||||
@@ -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).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ row per lifecycle event across all channels.
|
|||||||
| `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. |
|
| `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). |
|
| `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. |
|
||||||
@@ -104,6 +105,7 @@ 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_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_CorrelationId (CorrelationId)` — drilldown from a single operation.
|
||||||
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
|
- `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_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.
|
||||||
@@ -172,7 +174,9 @@ generalises to it with no schema change once that spawn point is threaded.
|
|||||||
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):**
|
||||||
|
|
||||||
@@ -233,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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# 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 (Design 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).
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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`) and Import Bundle (`/design/transport/import`) wizard pages under the Design nav group. The result page links to the Deployments page and to the filtered Configuration Audit Log Viewer.
|
||||||
|
- **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
|
||||||
|
|||||||
@@ -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})",
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -144,6 +149,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
// so it is ALTER-ed in here. Nullable with no default — rows written
|
// so it is ALTER-ed in here. Nullable with no default — rows written
|
||||||
// before this migration read back ParentExecutionId = null.
|
// before this migration read back ParentExecutionId = null.
|
||||||
AddColumnIfMissing("ParentExecutionId", "TEXT 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>
|
/// <summary>
|
||||||
@@ -270,13 +283,13 @@ 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
|
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
|
$ExecutionId, $ParentExecutionId
|
||||||
@@ -289,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);
|
||||||
@@ -315,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;
|
||||||
@@ -386,7 +409,7 @@ 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
|
ExecutionId, ParentExecutionId
|
||||||
@@ -435,7 +458,7 @@ 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
|
ExecutionId, ParentExecutionId
|
||||||
@@ -522,7 +545,7 @@ 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
|
ExecutionId, ParentExecutionId
|
||||||
@@ -688,22 +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),
|
||||||
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
|
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(20)),
|
||||||
ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
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,
|
||||||
@@ -162,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(_ =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
|
<dt class="col-4 text-muted fw-normal">SourceSiteId</dt>
|
||||||
<dd class="col-8" data-test="field-SourceSiteId">@(Event.SourceSiteId ?? "—")</dd>
|
<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>
|
<dt class="col-4 text-muted fw-normal">SourceInstanceId</dt>
|
||||||
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
|
<dd class="col-8" data-test="field-SourceInstanceId">@(Event.SourceInstanceId ?? "—")</dd>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
@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">
|
||||||
@@ -58,6 +60,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ public partial class AuditFilterBar
|
|||||||
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
|
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
|
||||||
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
|
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
/// <summary>
|
||||||
/// Raised when the user clicks Apply. Carries the
|
/// Raised when the user clicks Apply. Carries the
|
||||||
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
|
||||||
@@ -80,6 +89,20 @@ public partial class AuditFilterBar
|
|||||||
}
|
}
|
||||||
|
|
||||||
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
|
_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>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -128,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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -153,7 +161,8 @@ public sealed class AuditQueryModel
|
|||||||
ExecutionId: executionId,
|
ExecutionId: executionId,
|
||||||
ParentExecutionId: parentExecutionId,
|
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>
|
||||||
|
|||||||
@@ -105,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;
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ 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"),
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
</li>
|
</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>
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -54,6 +59,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
|
||||||
|
</li>
|
||||||
</NavSection>
|
</NavSection>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
@page "/design/transport/import"
|
||||||
|
@using ScadaLink.Security
|
||||||
|
@using ScadaLink.Commons.Types.Transport
|
||||||
|
@using ScadaLink.Commons.Interfaces.Transport
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using ScadaLink.Transport
|
||||||
|
@using ScadaLink.Transport.Import
|
||||||
|
@using System.Security.Cryptography
|
||||||
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||||
|
|
||||||
|
@*
|
||||||
|
TransportImport wizard (Component #24, Task T22).
|
||||||
|
|
||||||
|
A 5-step linear wizard:
|
||||||
|
Step 1 — Upload : InputFile + manifest summary; LoadAsync without passphrase first.
|
||||||
|
Step 2 — Passphrase : only shown for encrypted bundles; 3-strike lockout.
|
||||||
|
Step 3 — Diff : conflict resolution (Add/Overwrite/Skip/Rename) per ImportPreviewItem.
|
||||||
|
Step 4 — Confirm : type-the-environment-name guard.
|
||||||
|
Step 5 — Result : ApplyAsync result + audit drilldown link.
|
||||||
|
|
||||||
|
The page is Admin-only — Import touches central configuration globally.
|
||||||
|
*@
|
||||||
|
|
||||||
|
<div class="container-fluid mt-3">
|
||||||
|
<h4 class="mb-3">Import Bundle</h4>
|
||||||
|
|
||||||
|
@* Step indicator — five numbered pills, mirrors TransportExport. *@
|
||||||
|
<nav aria-label="Import wizard steps" class="mb-4">
|
||||||
|
<ol class="list-unstyled d-flex flex-wrap gap-3 mb-0 small">
|
||||||
|
<li class="@StepClass(ImportWizardStep.Upload)">
|
||||||
|
<span class="badge rounded-pill me-1">1</span> Upload
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ImportWizardStep.Passphrase)">
|
||||||
|
<span class="badge rounded-pill me-1">2</span> Passphrase
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ImportWizardStep.Diff)">
|
||||||
|
<span class="badge rounded-pill me-1">3</span> Diff
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ImportWizardStep.Confirm)">
|
||||||
|
<span class="badge rounded-pill me-1">4</span> Confirm
|
||||||
|
</li>
|
||||||
|
<li class="@StepClass(ImportWizardStep.Result)">
|
||||||
|
<span class="badge rounded-pill me-1">5</span> Result
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (_errorMessage != null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" data-testid="error-message">@_errorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@switch (_step)
|
||||||
|
{
|
||||||
|
case ImportWizardStep.Upload:
|
||||||
|
@RenderStepUpload();
|
||||||
|
break;
|
||||||
|
case ImportWizardStep.Passphrase:
|
||||||
|
@RenderStepPassphrase();
|
||||||
|
break;
|
||||||
|
case ImportWizardStep.Diff:
|
||||||
|
@RenderStepDiff();
|
||||||
|
break;
|
||||||
|
case ImportWizardStep.Confirm:
|
||||||
|
@RenderStepConfirm();
|
||||||
|
break;
|
||||||
|
case ImportWizardStep.Result:
|
||||||
|
@RenderStepResult();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private string StepClass(ImportWizardStep s) =>
|
||||||
|
s == _step ? "fw-semibold text-primary"
|
||||||
|
: (int)s < (int)_step ? "text-success"
|
||||||
|
: "text-muted";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 1 — Upload
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepUpload() => __builder =>
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
Select a <code>.scadabundle</code> file produced by an exporter on this
|
||||||
|
or another cluster. The bundle's manifest will be validated immediately;
|
||||||
|
encrypted bundles will prompt for a passphrase on the next step.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="bundle-input" class="form-label">Bundle file</label>
|
||||||
|
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
|
||||||
|
class="form-control" accept=".scadabundle,application/zip" />
|
||||||
|
<div class="form-text">
|
||||||
|
Maximum bundle size: @Options.Value.MaxBundleSizeMb MB.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_uploadInProgress)
|
||||||
|
{
|
||||||
|
<div class="text-muted small fst-italic">Reading bundle…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_bundleBytes is not null && _errorMessage is null)
|
||||||
|
{
|
||||||
|
@if (_session is not null)
|
||||||
|
{
|
||||||
|
<dl class="row small mt-3" data-testid="manifest-summary">
|
||||||
|
<dt class="col-sm-3">Source environment</dt>
|
||||||
|
<dd class="col-sm-9"><code>@_session.Manifest.SourceEnvironment</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Exported by</dt>
|
||||||
|
<dd class="col-sm-9">@_session.Manifest.ExportedBy</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Created</dt>
|
||||||
|
<dd class="col-sm-9">@_session.Manifest.CreatedAtUtc.ToString("u")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Content count</dt>
|
||||||
|
<dd class="col-sm-9">@_session.Manifest.Contents.Count items</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">SHA-256</dt>
|
||||||
|
<dd class="col-sm-9"><code class="small">@_session.Manifest.ContentHash</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Encryption</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@if (_session.Manifest.Encryption is null)
|
||||||
|
{
|
||||||
|
<span class="text-warning">Unencrypted</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-success">@_session.Manifest.Encryption.Algorithm</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info mt-3" data-testid="encrypted-bundle-notice">
|
||||||
|
<strong>Encrypted bundle uploaded.</strong>
|
||||||
|
Click <strong>Next</strong> to enter the passphrase.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button class="btn btn-primary" @onclick="GoFromUploadAsync">Next</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 2 — Passphrase
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepPassphrase() => __builder =>
|
||||||
|
{
|
||||||
|
var maxAttempts = Options.Value.MaxUnlockAttemptsPerSession;
|
||||||
|
var attemptsLeft = Math.Max(0, maxAttempts - _failedUnlockAttempts);
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
This bundle is encrypted. Enter the passphrase that was used to
|
||||||
|
produce it. You have @attemptsLeft of @maxAttempts attempts before
|
||||||
|
the upload must be restarted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="import-passphrase" class="form-label">Passphrase</label>
|
||||||
|
<input id="import-passphrase" type="password" class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
@bind="_passphrase" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_failedUnlockAttempts > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small" data-testid="unlock-attempts">
|
||||||
|
Failed unlock attempts: @_failedUnlockAttempts of @maxAttempts.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
disabled="@(string.IsNullOrEmpty(_passphrase) || _uploadInProgress)"
|
||||||
|
@onclick="SubmitPassphraseAsync">
|
||||||
|
@(_uploadInProgress ? "Unlocking…" : "Unlock")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 3 — Diff & resolve conflicts
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepDiff() => __builder =>
|
||||||
|
{
|
||||||
|
if (_preview is null || _resolutions is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">No preview available — please go back and re-upload.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (adds, overs, skips, renames, blockers) = CountResolutions();
|
||||||
|
var hasBlockers = _preview.Items.Any(i => i.Kind == ConflictKind.Blocker);
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
Review each artifact in the bundle and choose how it should be applied
|
||||||
|
to this environment. Identical items are skipped automatically; new
|
||||||
|
items default to Add; modified items require an explicit choice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
|
||||||
|
<span class="small text-body-secondary">Apply to all modified:</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
|
||||||
|
<table class="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Existing</th>
|
||||||
|
<th>Incoming</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var item in _preview.Items)
|
||||||
|
{
|
||||||
|
var key = (item.EntityType, item.Name);
|
||||||
|
var current = _resolutions[key];
|
||||||
|
<tr data-testid="diff-row">
|
||||||
|
<td><span class="badge bg-secondary">@item.EntityType</span></td>
|
||||||
|
<td>@item.Name</td>
|
||||||
|
<td>@RenderKindBadge(item)</td>
|
||||||
|
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
|
||||||
|
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
|
||||||
|
<td>@RenderResolutionControls(item, current)</td>
|
||||||
|
</tr>
|
||||||
|
@if (item.Kind == ConflictKind.Modified && !string.IsNullOrEmpty(item.FieldDiffJson))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<details>
|
||||||
|
<summary class="small">Field diff</summary>
|
||||||
|
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@if (item.Kind == ConflictKind.Blocker && !string.IsNullOrEmpty(item.BlockerReason))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="alert alert-danger small mb-0">@item.BlockerReason</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||||
|
<div>
|
||||||
|
<span class="me-3 small text-body-secondary" data-testid="diff-summary">
|
||||||
|
@adds add · @overs overwrite · @skips skip · @renames rename · @blockers blocker
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
disabled="@hasBlockers"
|
||||||
|
@onclick="GoToConfirm">
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderKindBadge(ImportPreviewItem item) => __builder =>
|
||||||
|
{
|
||||||
|
var (cls, label) = item.Kind switch
|
||||||
|
{
|
||||||
|
ConflictKind.Identical => ("bg-secondary", "Identical"),
|
||||||
|
ConflictKind.Modified => ("bg-warning text-dark", "Modified"),
|
||||||
|
ConflictKind.New => ("bg-success", "New"),
|
||||||
|
ConflictKind.Blocker => ("bg-danger", "Blocker"),
|
||||||
|
_ => ("bg-light text-dark", item.Kind.ToString()),
|
||||||
|
};
|
||||||
|
<span class="badge @cls">@label</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment RenderResolutionControls(ImportPreviewItem item, ImportResolution current) => __builder =>
|
||||||
|
{
|
||||||
|
// Identical → forced Skip; New → forced Add; Blocker → no actions.
|
||||||
|
if (item.Kind == ConflictKind.Identical)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Skip</span>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Kind == ConflictKind.New)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Add</span>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Kind == ConflictKind.Blocker)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = (item.EntityType, item.Name);
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
@foreach (var action in new[] { ResolutionAction.Overwrite, ResolutionAction.Skip, ResolutionAction.Rename })
|
||||||
|
{
|
||||||
|
var inputId = $"res-{item.EntityType}-{item.Name}-{action}";
|
||||||
|
<div class="form-check form-check-inline mb-0">
|
||||||
|
<input class="form-check-input" type="radio"
|
||||||
|
id="@inputId"
|
||||||
|
name="@($"res-{item.EntityType}-{item.Name}")"
|
||||||
|
checked="@(current.Action == action)"
|
||||||
|
@onchange="() => SetResolution(key, action)" />
|
||||||
|
<label class="form-check-label small" for="@inputId">@action</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (current.Action == ResolutionAction.Rename)
|
||||||
|
{
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
style="max-width: 14rem;"
|
||||||
|
placeholder="New name"
|
||||||
|
value="@(current.RenameTo ?? string.Empty)"
|
||||||
|
@onchange="e => SetRenameTo(key, e.Value?.ToString())" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 4 — Confirm
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepConfirm() => __builder =>
|
||||||
|
{
|
||||||
|
if (_session is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning">No bundle session — please re-upload.</div>
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (adds, overs, skips, renames, _) = CountResolutions();
|
||||||
|
var changeCount = adds + overs + renames;
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-body-secondary">
|
||||||
|
You are about to apply <strong>@changeCount</strong> change(s)
|
||||||
|
to this environment (@adds add · @overs overwrite · @skips skip · @renames rename).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="alert alert-info small">
|
||||||
|
Affected instances will become stale and require redeployment via the
|
||||||
|
<a href="/deployment/deployments">Deployments</a> page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm-env" class="form-label">
|
||||||
|
Type the source environment name <code>@_session.Manifest.SourceEnvironment</code> to confirm:
|
||||||
|
</label>
|
||||||
|
<input id="confirm-env" type="text" class="form-control"
|
||||||
|
@bind="_confirmEnvironmentText" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
|
||||||
|
<button class="btn btn-danger"
|
||||||
|
disabled="@(_confirmEnvironmentText != _session.Manifest.SourceEnvironment || _applyInProgress)"
|
||||||
|
@onclick="ApplyAsync">
|
||||||
|
@(_applyInProgress ? "Applying…" : "Apply Import")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 5 — Result
|
||||||
|
// ============================================================
|
||||||
|
private RenderFragment RenderStepResult() => __builder =>
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
@if (_validationErrors is not null && _validationErrors.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger" data-testid="validation-errors">
|
||||||
|
<strong>Bundle semantic validation failed.</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var err in _validationErrors)
|
||||||
|
{
|
||||||
|
<li>@err</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToDiff">Back</button>
|
||||||
|
}
|
||||||
|
else if (_result is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success" data-testid="result-summary">
|
||||||
|
<strong>Import complete.</strong>
|
||||||
|
@_result.Added added · @_result.Overwritten overwritten ·
|
||||||
|
@_result.Skipped skipped · @_result.Renamed renamed.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="row small">
|
||||||
|
<dt class="col-sm-3">Bundle Import Id</dt>
|
||||||
|
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<a class="btn btn-outline-primary" href="/deployment/deployments">
|
||||||
|
View on Deployments →
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-outline-secondary"
|
||||||
|
href="@($"/audit/configuration?bundleImportId={_result.BundleImportId}")">
|
||||||
|
Audit trail →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
Import failed. Please re-upload the bundle.
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToUpload">Back</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.CentralUI.Auth;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.Transport;
|
||||||
|
using ScadaLink.Transport.Import;
|
||||||
|
|
||||||
|
namespace ScadaLink.CentralUI.Components.Pages.Design;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
|
||||||
|
///
|
||||||
|
/// Five-step state machine:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
|
||||||
|
/// a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
|
||||||
|
/// bundle is encrypted, advance to Step 2 without yet opening a session.</item>
|
||||||
|
/// <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
|
||||||
|
/// and retry LoadAsync; 3-strike lockout per the configured
|
||||||
|
/// <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
|
||||||
|
/// <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
|
||||||
|
/// items, collect <see cref="ImportResolution"/> per Modified item; Apply
|
||||||
|
/// is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
|
||||||
|
/// <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
|
||||||
|
/// guard prevents accidental cross-cluster overwrites.</item>
|
||||||
|
/// <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
|
||||||
|
/// drill-in link; on <see cref="SemanticValidationException"/>, surface
|
||||||
|
/// the error list and allow returning to Step 3.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
|
||||||
|
/// globally and must not be available to Design-only or Deployment-only users.
|
||||||
|
///
|
||||||
|
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
|
||||||
|
/// peeks the manifest by attempting decryption, encrypted bundles require two
|
||||||
|
/// LoadAsync invocations. We cache the raw bytes in <c>_bundleBytes</c> after the
|
||||||
|
/// first read so the user does not need to re-select the file before entering the
|
||||||
|
/// passphrase. The bytes are cleared on Done / Back-to-Upload.
|
||||||
|
/// </summary>
|
||||||
|
public partial class TransportImport : ComponentBase
|
||||||
|
{
|
||||||
|
public enum ImportWizardStep
|
||||||
|
{
|
||||||
|
Upload = 1,
|
||||||
|
Passphrase = 2,
|
||||||
|
Diff = 3,
|
||||||
|
Confirm = 4,
|
||||||
|
Result = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Injected services ----
|
||||||
|
[Inject] private IBundleImporter BundleImporter { get; set; } = default!;
|
||||||
|
[Inject] private NavigationManager Nav { get; set; } = default!;
|
||||||
|
[Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
|
||||||
|
[Inject] private IOptions<TransportOptions> Options { get; set; } = default!;
|
||||||
|
[Inject] private IAuditService AuditService { get; set; } = default!;
|
||||||
|
[Inject] private ScadaLinkDbContext DbContext { get; set; } = default!;
|
||||||
|
|
||||||
|
// ---- Wizard state ----
|
||||||
|
private ImportWizardStep _step = ImportWizardStep.Upload;
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
// ---- Session + cached bytes ----
|
||||||
|
// Bundle bytes are cached so the same file can be re-attempted with a
|
||||||
|
// passphrase without forcing the user to re-pick it. Cleared in ResetAll.
|
||||||
|
private byte[]? _bundleBytes;
|
||||||
|
private BundleSession? _session;
|
||||||
|
private bool _uploadInProgress;
|
||||||
|
|
||||||
|
// ---- Step 2: passphrase ----
|
||||||
|
private string _passphrase = string.Empty;
|
||||||
|
private int _failedUnlockAttempts;
|
||||||
|
|
||||||
|
// ---- Step 3: preview + resolutions ----
|
||||||
|
private ImportPreview? _preview;
|
||||||
|
// Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
|
||||||
|
private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;
|
||||||
|
|
||||||
|
// ---- Step 4: confirm ----
|
||||||
|
private string _confirmEnvironmentText = string.Empty;
|
||||||
|
|
||||||
|
// ---- Step 5: apply result ----
|
||||||
|
private bool _applyInProgress;
|
||||||
|
private ImportResult? _result;
|
||||||
|
private IReadOnlyList<string>? _validationErrors;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 1 — Upload
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffers the selected file, enforces the configured size cap, then calls
|
||||||
|
/// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
|
||||||
|
/// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
|
||||||
|
/// which we catch and use to advance to Step 2 — the session is opened on
|
||||||
|
/// the second LoadAsync call once the passphrase is provided.
|
||||||
|
/// </summary>
|
||||||
|
private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
_uploadInProgress = true;
|
||||||
|
_session = null;
|
||||||
|
_bundleBytes = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
|
||||||
|
if (e.File.Size > maxBytes)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
|
||||||
|
// it to the configured cap so the read doesn't throw before we get
|
||||||
|
// to the importer's own length check.
|
||||||
|
using var fileStream = e.File.OpenReadStream(maxBytes);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await fileStream.CopyToAsync(ms);
|
||||||
|
_bundleBytes = ms.ToArray();
|
||||||
|
|
||||||
|
await TryLoadAsync(passphrase: null);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Encrypted bundle, no passphrase yet — expected. The wizard
|
||||||
|
// advances to the passphrase step when the user clicks Next.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to read bundle: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_uploadInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
|
||||||
|
/// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
|
||||||
|
/// with no passphrase) leaves the wizard's step caller to advance to the
|
||||||
|
/// passphrase step. Wrong-passphrase failures surface as
|
||||||
|
/// <see cref="CryptographicException"/> and are counted by the caller.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TryLoadAsync(string? passphrase)
|
||||||
|
{
|
||||||
|
if (_bundleBytes is null)
|
||||||
|
{
|
||||||
|
_errorMessage = "No bundle bytes cached — please re-select the file.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(_bundleBytes);
|
||||||
|
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
|
||||||
|
_errorMessage = null;
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Encrypted bundle, no passphrase supplied — caller advances to Step 2.
|
||||||
|
// We deliberately do NOT set _errorMessage here; the page surfaces
|
||||||
|
// an empty Step-2 prompt instead.
|
||||||
|
_session = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
// Wrong passphrase — bubble so the caller can increment the counter.
|
||||||
|
_session = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (InvalidDataException ex)
|
||||||
|
{
|
||||||
|
_session = null;
|
||||||
|
_errorMessage = $"Bundle is invalid: {ex.Message}";
|
||||||
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_session = null;
|
||||||
|
_errorMessage = $"Bundle format unsupported: {ex.Message}";
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
_session = null;
|
||||||
|
_errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances from Step 1 to either the passphrase step (encrypted bundle) or
|
||||||
|
/// straight to the diff step (unencrypted bundle). For encrypted bundles
|
||||||
|
/// LoadAsync was already attempted with <c>null</c> and threw
|
||||||
|
/// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
|
||||||
|
/// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
|
||||||
|
/// jump directly to Step 3.
|
||||||
|
/// </summary>
|
||||||
|
private async Task GoFromUploadAsync()
|
||||||
|
{
|
||||||
|
if (_session is null)
|
||||||
|
{
|
||||||
|
// Peek the manifest to find out if it's encrypted. We re-call LoadAsync
|
||||||
|
// with null passphrase; for encrypted bundles this throws
|
||||||
|
// ArgumentException → advance to Step 2.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TryLoadAsync(passphrase: null);
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
_step = ImportWizardStep.Passphrase;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (CryptographicException)
|
||||||
|
{
|
||||||
|
_errorMessage = "Bundle could not be decrypted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_session is null)
|
||||||
|
{
|
||||||
|
// Some other error already surfaced via _errorMessage.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_session.Manifest.Encryption is not null)
|
||||||
|
{
|
||||||
|
_step = ImportWizardStep.Passphrase;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LoadPreviewAndAdvanceAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 2 — Passphrase
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Submits the entered passphrase. On <see cref="CryptographicException"/>
|
||||||
|
/// increments the per-session counter; once the configured threshold is
|
||||||
|
/// reached the wizard resets to Step 1 with an explanatory error.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SubmitPassphraseAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_passphrase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_uploadInProgress = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TryLoadAsync(_passphrase);
|
||||||
|
if (_session is not null)
|
||||||
|
{
|
||||||
|
_failedUnlockAttempts = 0;
|
||||||
|
_passphrase = string.Empty;
|
||||||
|
await LoadPreviewAndAdvanceAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (CryptographicException ex)
|
||||||
|
{
|
||||||
|
_failedUnlockAttempts++;
|
||||||
|
_passphrase = string.Empty;
|
||||||
|
|
||||||
|
// Emit audit row for every wrong-passphrase attempt (BundleImportUnlockFailed).
|
||||||
|
// Best-effort — audit failure must never abort the user-facing action.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await Auth.GetCurrentUsernameAsync();
|
||||||
|
var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
|
||||||
|
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
|
||||||
|
await AuditService.LogAsync(
|
||||||
|
user: user,
|
||||||
|
action: "BundleImportUnlockFailed",
|
||||||
|
entityType: "Bundle",
|
||||||
|
entityId: entityId,
|
||||||
|
entityName: entityName,
|
||||||
|
afterState: new
|
||||||
|
{
|
||||||
|
AttemptNumber = _failedUnlockAttempts,
|
||||||
|
Reason = ex.Message,
|
||||||
|
},
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
await DbContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Audit failure is non-fatal — swallow and continue.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
|
||||||
|
{
|
||||||
|
_errorMessage =
|
||||||
|
$"Too many failed unlock attempts ({_failedUnlockAttempts}). "
|
||||||
|
+ "Please re-upload the bundle.";
|
||||||
|
ResetSessionState();
|
||||||
|
_step = ImportWizardStep.Upload;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_errorMessage = "Wrong passphrase. Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
_errorMessage = "Passphrase required.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_uploadInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToUpload()
|
||||||
|
{
|
||||||
|
_step = ImportWizardStep.Upload;
|
||||||
|
_errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 3 — Diff & resolve conflicts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private async Task LoadPreviewAndAdvanceAsync()
|
||||||
|
{
|
||||||
|
if (_session is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
|
||||||
|
_resolutions = BuildDefaultResolutions(_preview);
|
||||||
|
_step = ImportWizardStep.Diff;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Failed to build import preview: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the default resolution per preview item:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
|
||||||
|
/// <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
|
||||||
|
/// <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
|
||||||
|
/// <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
|
||||||
|
/// </list>
|
||||||
|
/// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
|
||||||
|
/// </summary>
|
||||||
|
internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
|
||||||
|
ImportPreview preview)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<(string, string), ImportResolution>();
|
||||||
|
foreach (var item in preview.Items)
|
||||||
|
{
|
||||||
|
var action = item.Kind switch
|
||||||
|
{
|
||||||
|
ConflictKind.Identical => ResolutionAction.Skip,
|
||||||
|
ConflictKind.New => ResolutionAction.Add,
|
||||||
|
ConflictKind.Modified => ResolutionAction.Overwrite,
|
||||||
|
ConflictKind.Blocker => ResolutionAction.Skip,
|
||||||
|
_ => ResolutionAction.Skip,
|
||||||
|
};
|
||||||
|
map[(item.EntityType, item.Name)] = new ImportResolution(
|
||||||
|
item.EntityType, item.Name, action, RenameTo: null);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
|
||||||
|
{
|
||||||
|
if (_resolutions is null) return;
|
||||||
|
var existing = _resolutions[key];
|
||||||
|
_resolutions[key] = existing with { Action = action };
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
|
||||||
|
{
|
||||||
|
if (_resolutions is null) return;
|
||||||
|
var existing = _resolutions[key];
|
||||||
|
_resolutions[key] = existing with { RenameTo = renameTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BulkSet(ResolutionAction action)
|
||||||
|
{
|
||||||
|
if (_resolutions is null || _preview is null) return;
|
||||||
|
foreach (var item in _preview.Items)
|
||||||
|
{
|
||||||
|
if (item.Kind != ConflictKind.Modified) continue;
|
||||||
|
var key = (item.EntityType, item.Name);
|
||||||
|
_resolutions[key] = _resolutions[key] with { Action = action };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
|
||||||
|
{
|
||||||
|
if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
|
||||||
|
var adds = 0;
|
||||||
|
var overs = 0;
|
||||||
|
var skips = 0;
|
||||||
|
var renames = 0;
|
||||||
|
var blockers = 0;
|
||||||
|
foreach (var item in _preview.Items)
|
||||||
|
{
|
||||||
|
if (item.Kind == ConflictKind.Blocker)
|
||||||
|
{
|
||||||
|
blockers++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var action = _resolutions[(item.EntityType, item.Name)].Action;
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ResolutionAction.Add: adds++; break;
|
||||||
|
case ResolutionAction.Overwrite: overs++; break;
|
||||||
|
case ResolutionAction.Skip: skips++; break;
|
||||||
|
case ResolutionAction.Rename: renames++; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (adds, overs, skips, renames, blockers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoToConfirm()
|
||||||
|
{
|
||||||
|
if (_preview is null) return;
|
||||||
|
if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
|
||||||
|
{
|
||||||
|
_errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_confirmEnvironmentText = string.Empty;
|
||||||
|
_step = ImportWizardStep.Confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToDiff()
|
||||||
|
{
|
||||||
|
_step = ImportWizardStep.Diff;
|
||||||
|
_errorMessage = null;
|
||||||
|
_validationErrors = null;
|
||||||
|
_result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Step 4 + 5 — Confirm & Apply
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
|
||||||
|
/// resolutions and the authenticated user identity. Distinguishes
|
||||||
|
/// <see cref="SemanticValidationException"/> (recoverable — surface the
|
||||||
|
/// error list and let the operator return to Step 3) from generic
|
||||||
|
/// exceptions (display generic error + force re-upload).
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyAsync()
|
||||||
|
{
|
||||||
|
if (_session is null || _resolutions is null) return;
|
||||||
|
if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;
|
||||||
|
|
||||||
|
_applyInProgress = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_validationErrors = null;
|
||||||
|
_result = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = await Auth.GetCurrentUsernameAsync();
|
||||||
|
_result = await BundleImporter.ApplyAsync(
|
||||||
|
_session.SessionId,
|
||||||
|
_resolutions.Values.ToList(),
|
||||||
|
user,
|
||||||
|
CancellationToken.None);
|
||||||
|
_step = ImportWizardStep.Result;
|
||||||
|
}
|
||||||
|
catch (SemanticValidationException ex)
|
||||||
|
{
|
||||||
|
_validationErrors = ex.Errors;
|
||||||
|
_step = ImportWizardStep.Result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
|
||||||
|
_step = ImportWizardStep.Result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_applyInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Reset helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void ResetSessionState()
|
||||||
|
{
|
||||||
|
_session = null;
|
||||||
|
_bundleBytes = null;
|
||||||
|
_preview = null;
|
||||||
|
_resolutions = null;
|
||||||
|
_passphrase = string.Empty;
|
||||||
|
_confirmEnvironmentText = string.Empty;
|
||||||
|
_result = null;
|
||||||
|
_validationErrors = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-center text-muted mt-3 small mb-0">Authenticate with your organization's LDAP credentials.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,16 @@
|
|||||||
<input id="no-list" type="text" class="form-control form-control-sm"
|
<input id="no-list" type="text" class="form-control form-control-sm"
|
||||||
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
|
style="min-width: 140px;" placeholder="Any" @bind="_listFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
@* Task 16: free-text Node filter — exact match against the
|
||||||
|
notification's SourceNode column. Sites + central nodes
|
||||||
|
both flow through this single input. *@
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="no-node">Node</label>
|
||||||
|
<input id="no-node" type="text" class="form-control form-control-sm"
|
||||||
|
style="min-width: 140px;" placeholder="Any"
|
||||||
|
data-test="notif-filter-node"
|
||||||
|
@bind="_nodeFilter" />
|
||||||
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label small mb-1" for="no-from">From</label>
|
<label class="form-label small mb-1" for="no-from">From</label>
|
||||||
<input id="no-from" type="datetime-local" class="form-control form-control-sm"
|
<input id="no-from" type="datetime-local" class="form-control form-control-sm"
|
||||||
@@ -131,6 +141,7 @@
|
|||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th class="text-end">Retries</th>
|
<th class="text-end">Retries</th>
|
||||||
<th>Source site</th>
|
<th>Source site</th>
|
||||||
|
<th>Node</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Delivered</th>
|
<th>Delivered</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
@@ -162,6 +173,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end font-monospace">@n.RetryCount</td>
|
<td class="text-end font-monospace">@n.RetryCount</td>
|
||||||
<td><span class="small">@SiteName(n.SourceSiteId)</span></td>
|
<td><span class="small">@SiteName(n.SourceSiteId)</span></td>
|
||||||
|
<td><span class="small">@(n.SourceNode ?? "—")</span></td>
|
||||||
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
|
<td><TimestampDisplay Value="@n.CreatedAt" Format="yyyy-MM-dd HH:mm" /></td>
|
||||||
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
|
<td><TimestampDisplay Value="@n.DeliveredAt" Format="yyyy-MM-dd HH:mm" NullText="—" /></td>
|
||||||
<td class="text-end" @ondblclick:stopPropagation="true">
|
<td class="text-end" @ondblclick:stopPropagation="true">
|
||||||
@@ -253,6 +265,9 @@
|
|||||||
<dt class="col-sm-3">Source site</dt>
|
<dt class="col-sm-3">Source site</dt>
|
||||||
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
|
<dd class="col-sm-9">@SiteName(d.SourceSiteId)</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Source node</dt>
|
||||||
|
<dd class="col-sm-9">@(string.IsNullOrEmpty(_detail?.SourceNode) ? "—" : _detail.SourceNode)</dd>
|
||||||
|
|
||||||
<dt class="col-sm-3">Source instance</dt>
|
<dt class="col-sm-3">Source instance</dt>
|
||||||
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
|
<dd class="col-sm-9">@(string.IsNullOrEmpty(d.SourceInstanceId) ? "—" : d.SourceInstanceId)</dd>
|
||||||
|
|
||||||
@@ -372,6 +387,7 @@
|
|||||||
private string _siteFilter = string.Empty;
|
private string _siteFilter = string.Empty;
|
||||||
private string _listFilter = string.Empty;
|
private string _listFilter = string.Empty;
|
||||||
private string _subjectFilter = string.Empty;
|
private string _subjectFilter = string.Empty;
|
||||||
|
private string _nodeFilter = string.Empty;
|
||||||
private bool _stuckOnly;
|
private bool _stuckOnly;
|
||||||
private DateTime? _fromFilter;
|
private DateTime? _fromFilter;
|
||||||
private DateTime? _toFilter;
|
private DateTime? _toFilter;
|
||||||
@@ -422,7 +438,8 @@
|
|||||||
From: ToUtc(_fromFilter),
|
From: ToUtc(_fromFilter),
|
||||||
To: ToUtc(_toFilter),
|
To: ToUtc(_toFilter),
|
||||||
PageNumber: _pageNumber,
|
PageNumber: _pageNumber,
|
||||||
PageSize: _pageSize);
|
PageSize: _pageSize,
|
||||||
|
SourceNodeFilter: NullIfEmpty(_nodeFilter));
|
||||||
|
|
||||||
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
|
var response = await CommunicationService.QueryNotificationOutboxAsync(request);
|
||||||
if (response.Success)
|
if (response.Success)
|
||||||
@@ -597,6 +614,7 @@
|
|||||||
_siteFilter = string.Empty;
|
_siteFilter = string.Empty;
|
||||||
_listFilter = string.Empty;
|
_listFilter = string.Empty;
|
||||||
_subjectFilter = string.Empty;
|
_subjectFilter = string.Empty;
|
||||||
|
_nodeFilter = string.Empty;
|
||||||
_stuckOnly = false;
|
_stuckOnly = false;
|
||||||
_fromFilter = null;
|
_fromFilter = null;
|
||||||
_toFilter = null;
|
_toFilter = null;
|
||||||
@@ -608,6 +626,7 @@
|
|||||||
!string.IsNullOrEmpty(_siteFilter) ||
|
!string.IsNullOrEmpty(_siteFilter) ||
|
||||||
!string.IsNullOrEmpty(_listFilter) ||
|
!string.IsNullOrEmpty(_listFilter) ||
|
||||||
!string.IsNullOrEmpty(_subjectFilter) ||
|
!string.IsNullOrEmpty(_subjectFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_nodeFilter) ||
|
||||||
_stuckOnly ||
|
_stuckOnly ||
|
||||||
_fromFilter != null ||
|
_fromFilter != null ||
|
||||||
_toFilter != null;
|
_toFilter != null;
|
||||||
|
|||||||
@@ -58,6 +58,17 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@* Task 17: free-text Node filter — exact match against the
|
||||||
|
SiteCall.SourceNode column. The Source site dropdown narrows
|
||||||
|
to a site; Node narrows further within that site (or across
|
||||||
|
sites if Source site is "Any"). *@
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label small mb-1" for="sc-node">Node</label>
|
||||||
|
<input id="sc-node" type="text" class="form-control form-control-sm"
|
||||||
|
style="min-width: 150px;" placeholder="Any"
|
||||||
|
data-test="site-calls-filter-node"
|
||||||
|
@bind="_nodeFilter" />
|
||||||
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label class="form-label small mb-1" for="sc-from">From</label>
|
<label class="form-label small mb-1" for="sc-from">From</label>
|
||||||
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
|
<input id="sc-from" type="datetime-local" class="form-control form-control-sm"
|
||||||
@@ -125,6 +136,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Tracked operation</th>
|
<th>Tracked operation</th>
|
||||||
<th>Source site</th>
|
<th>Source site</th>
|
||||||
|
<th>Node</th>
|
||||||
<th>Channel</th>
|
<th>Channel</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -143,6 +155,7 @@
|
|||||||
title="Double-click for full detail">
|
title="Double-click for full detail">
|
||||||
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
|
<td><code class="small" title="@c.TrackedOperationId">@ShortId(c.TrackedOperationId)</code></td>
|
||||||
<td><span class="small">@SiteName(c.SourceSite)</span></td>
|
<td><span class="small">@SiteName(c.SourceSite)</span></td>
|
||||||
|
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
|
||||||
<td>@c.Channel</td>
|
<td>@c.Channel</td>
|
||||||
<td>@c.Target</td>
|
<td>@c.Target</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -253,6 +266,9 @@
|
|||||||
<dt class="col-sm-3">Source site</dt>
|
<dt class="col-sm-3">Source site</dt>
|
||||||
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
|
<dd class="col-sm-9">@SiteName(det.SourceSite)</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Source node</dt>
|
||||||
|
<dd class="col-sm-9">@(string.IsNullOrEmpty(det.SourceNode) ? "—" : det.SourceNode)</dd>
|
||||||
|
|
||||||
<dt class="col-sm-3">Channel</dt>
|
<dt class="col-sm-3">Channel</dt>
|
||||||
<dd class="col-sm-9">@det.Channel</dd>
|
<dd class="col-sm-9">@det.Channel</dd>
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ public partial class SiteCallsReport
|
|||||||
private string _channelFilter = string.Empty;
|
private string _channelFilter = string.Empty;
|
||||||
private string _siteFilter = string.Empty;
|
private string _siteFilter = string.Empty;
|
||||||
private string _targetFilter = string.Empty;
|
private string _targetFilter = string.Empty;
|
||||||
|
private string _nodeFilter = string.Empty;
|
||||||
private bool _stuckOnly;
|
private bool _stuckOnly;
|
||||||
private DateTime? _fromFilter;
|
private DateTime? _fromFilter;
|
||||||
private DateTime? _toFilter;
|
private DateTime? _toFilter;
|
||||||
@@ -204,7 +205,8 @@ public partial class SiteCallsReport
|
|||||||
ToUtc: ToUtc(_toFilter),
|
ToUtc: ToUtc(_toFilter),
|
||||||
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
|
||||||
AfterId: cursor.AfterId,
|
AfterId: cursor.AfterId,
|
||||||
PageSize: PageSize);
|
PageSize: PageSize,
|
||||||
|
SourceNodeFilter: NullIfEmpty(_nodeFilter));
|
||||||
|
|
||||||
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
var response = await CommunicationService.QuerySiteCallsAsync(request);
|
||||||
if (response.Success)
|
if (response.Success)
|
||||||
@@ -393,6 +395,7 @@ public partial class SiteCallsReport
|
|||||||
_channelFilter = string.Empty;
|
_channelFilter = string.Empty;
|
||||||
_siteFilter = string.Empty;
|
_siteFilter = string.Empty;
|
||||||
_targetFilter = string.Empty;
|
_targetFilter = string.Empty;
|
||||||
|
_nodeFilter = string.Empty;
|
||||||
_stuckOnly = false;
|
_stuckOnly = false;
|
||||||
_fromFilter = null;
|
_fromFilter = null;
|
||||||
_toFilter = null;
|
_toFilter = null;
|
||||||
@@ -403,6 +406,7 @@ public partial class SiteCallsReport
|
|||||||
!string.IsNullOrEmpty(_channelFilter) ||
|
!string.IsNullOrEmpty(_channelFilter) ||
|
||||||
!string.IsNullOrEmpty(_siteFilter) ||
|
!string.IsNullOrEmpty(_siteFilter) ||
|
||||||
!string.IsNullOrEmpty(_targetFilter) ||
|
!string.IsNullOrEmpty(_targetFilter) ||
|
||||||
|
!string.IsNullOrEmpty(_nodeFilter) ||
|
||||||
_stuckOnly ||
|
_stuckOnly ||
|
||||||
_fromFilter != null ||
|
_fromFilter != null ||
|
||||||
_toFilter != null;
|
_toFilter != null;
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
@* Shared template folder/template tree.
|
||||||
|
|
||||||
|
Wraps TreeView<TemplateTreeNode> with template-folder-specific layout: folder
|
||||||
|
nodes carry their child folders + templates, leaves are templates. Used by:
|
||||||
|
- Templates page (Single mode, navigation on click)
|
||||||
|
- Transport Export wizard (Checkbox mode, bulk selection)
|
||||||
|
|
||||||
|
Optional ExtraTemplateChildren lets callers nest extra leaves (e.g. composition
|
||||||
|
slots) under a template without forking the component. *@
|
||||||
|
|
||||||
|
@using ScadaLink.Commons.Entities.Templates
|
||||||
|
@using ScadaLink.CentralUI.Components.Shared
|
||||||
|
|
||||||
|
<TreeView @ref="_tree" TItem="TemplateTreeNode"
|
||||||
|
Items="_visibleRoots"
|
||||||
|
ChildrenSelector="n => n.Children"
|
||||||
|
HasChildrenSelector="n => n.Children.Count > 0"
|
||||||
|
KeySelector="n => (object)n.Key"
|
||||||
|
Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
|
||||||
|
SelectionMode="SelectionMode"
|
||||||
|
SelectedKeys="SelectedKeys"
|
||||||
|
SelectedKeysChanged="SelectedKeysChanged"
|
||||||
|
InitiallyExpanded="@(_initiallyExpanded)"
|
||||||
|
StorageKey="@StorageKey">
|
||||||
|
<NodeContent Context="node">
|
||||||
|
@if (NodeContent != null)
|
||||||
|
{
|
||||||
|
@NodeContent(node)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="tv-glyph">
|
||||||
|
<i class="bi @(NodeGlyph(node))"></i>
|
||||||
|
</span>
|
||||||
|
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||||
|
title="@node.Name"
|
||||||
|
@onclick="() => InvokeNodeClick(node)">@node.Name</span>
|
||||||
|
@if (NodeExtras != null)
|
||||||
|
{
|
||||||
|
@NodeExtras(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</NodeContent>
|
||||||
|
<ContextMenu Context="node">
|
||||||
|
@if (ContextMenu != null)
|
||||||
|
{
|
||||||
|
@ContextMenu(node)
|
||||||
|
}
|
||||||
|
</ContextMenu>
|
||||||
|
<EmptyContent>
|
||||||
|
@if (EmptyContent != null)
|
||||||
|
{
|
||||||
|
@EmptyContent
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted fst-italic">No templates.</span>
|
||||||
|
}
|
||||||
|
</EmptyContent>
|
||||||
|
</TreeView>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public IReadOnlyList<TemplateFolder> Folders { get; set; } = Array.Empty<TemplateFolder>();
|
||||||
|
[Parameter] public IReadOnlyList<Template> Templates { get; set; } = Array.Empty<Template>();
|
||||||
|
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
||||||
|
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
|
||||||
|
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
|
||||||
|
[Parameter] public string Filter { get; set; } = "";
|
||||||
|
[Parameter] public RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }
|
||||||
|
[Parameter] public RenderFragment<TemplateTreeNode>? NodeContent { get; set; }
|
||||||
|
[Parameter] public RenderFragment<TemplateTreeNode>? ContextMenu { get; set; }
|
||||||
|
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||||
|
[Parameter] public EventCallback<TemplateTreeNode> OnNodeClick { get; set; }
|
||||||
|
[Parameter] public string? StorageKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional: caller-supplied extra leaves to nest under a template. Used by
|
||||||
|
/// the Templates page to surface composition slots; left null by the
|
||||||
|
/// Transport Export wizard (compositions aren't exportable as standalone
|
||||||
|
/// items, so the wizard's checkbox tree intentionally hides them).
|
||||||
|
/// </summary>
|
||||||
|
[Parameter] public Func<Template, IReadOnlyList<TemplateTreeNode>>? ExtraTemplateChildren { get; set; }
|
||||||
|
|
||||||
|
private TreeView<TemplateTreeNode>? _tree;
|
||||||
|
private List<TemplateTreeNode> _allRoots = new();
|
||||||
|
private List<TemplateTreeNode> _visibleRoots = new();
|
||||||
|
private HashSet<string>? _filterRevealed;
|
||||||
|
private Func<TemplateTreeNode, bool>? _initiallyExpanded;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
BuildTree();
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildTree()
|
||||||
|
{
|
||||||
|
var folderNodes = Folders.ToDictionary(
|
||||||
|
f => f.Id,
|
||||||
|
f => new TemplateTreeNode
|
||||||
|
{
|
||||||
|
Kind = TemplateTreeNodeKind.Folder,
|
||||||
|
Id = f.Id,
|
||||||
|
Name = f.Name,
|
||||||
|
});
|
||||||
|
|
||||||
|
var roots = new List<TemplateTreeNode>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var t in Templates
|
||||||
|
.Where(t => !t.IsDerived)
|
||||||
|
.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var tNode = new TemplateTreeNode
|
||||||
|
{
|
||||||
|
Kind = TemplateTreeNodeKind.Template,
|
||||||
|
Id = t.Id,
|
||||||
|
Name = t.Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ExtraTemplateChildren != null)
|
||||||
|
{
|
||||||
|
foreach (var extra in ExtraTemplateChildren(t))
|
||||||
|
{
|
||||||
|
tNode.Children.Add(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.FolderId is int fid && folderNodes.TryGetValue(fid, out var parentFolder))
|
||||||
|
parentFolder.Children.Add(tNode);
|
||||||
|
else
|
||||||
|
roots.Add(tNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
SortChildren(roots);
|
||||||
|
foreach (var node in folderNodes.Values)
|
||||||
|
SortChildren(node.Children);
|
||||||
|
|
||||||
|
_allRoots = roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SortChildren(List<TemplateTreeNode> children)
|
||||||
|
{
|
||||||
|
children.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var kindOrder = (int)a.Kind - (int)b.Kind;
|
||||||
|
if (kindOrder != 0) return kindOrder;
|
||||||
|
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Filter))
|
||||||
|
{
|
||||||
|
_visibleRoots = _allRoots;
|
||||||
|
_filterRevealed = null;
|
||||||
|
_initiallyExpanded = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var needle = Filter.Trim();
|
||||||
|
var revealed = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var filtered = new List<TemplateTreeNode>();
|
||||||
|
foreach (var root in _allRoots)
|
||||||
|
{
|
||||||
|
if (CopyMatching(root, needle, revealed) is { } copy)
|
||||||
|
filtered.Add(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
_visibleRoots = filtered;
|
||||||
|
_filterRevealed = revealed;
|
||||||
|
// Force every ancestor of a match to be expanded so the matched leaf is
|
||||||
|
// visible without the user clicking through.
|
||||||
|
_initiallyExpanded = n => revealed.Contains(n.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateTreeNode? CopyMatching(TemplateTreeNode node, string needle, HashSet<string> revealed)
|
||||||
|
{
|
||||||
|
var selfMatch = node.Name.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var keptChildren = new List<TemplateTreeNode>();
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
var copy = CopyMatching(child, needle, revealed);
|
||||||
|
if (copy != null) keptChildren.Add(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selfMatch && keptChildren.Count == 0) return null;
|
||||||
|
|
||||||
|
var clone = new TemplateTreeNode
|
||||||
|
{
|
||||||
|
Kind = node.Kind,
|
||||||
|
Id = node.Id,
|
||||||
|
Name = node.Name,
|
||||||
|
};
|
||||||
|
foreach (var k in keptChildren) clone.Children.Add(k);
|
||||||
|
if (keptChildren.Count > 0)
|
||||||
|
{
|
||||||
|
// Mark this node as an ancestor on the match path so it auto-expands.
|
||||||
|
revealed.Add(clone.Key);
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NodeGlyph(TemplateTreeNode node) =>
|
||||||
|
node.Kind == TemplateTreeNodeKind.Folder ? "bi-folder" : "bi-file-earmark-text";
|
||||||
|
|
||||||
|
private async Task InvokeNodeClick(TemplateTreeNode node)
|
||||||
|
{
|
||||||
|
if (OnNodeClick.HasDelegate)
|
||||||
|
{
|
||||||
|
await OnNodeClick.InvokeAsync(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
||||||
|
public void ExpandAll() => _tree?.ExpandAll();
|
||||||
|
|
||||||
|
/// <summary>Forwarded to the inner TreeView so callers can drive expand/collapse.</summary>
|
||||||
|
public void CollapseAll() => _tree?.CollapseAll();
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
public enum TemplateTreeNodeKind
|
||||||
|
{
|
||||||
|
Folder,
|
||||||
|
Template,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composition slot under a parent Template — produced only by callers that
|
||||||
|
/// supply <c>TemplateFolderTree.ExtraTemplateChildren</c>. The Transport
|
||||||
|
/// Export wizard intentionally never emits this kind (compositions aren't
|
||||||
|
/// independently exportable); the Templates page uses it to surface slots.
|
||||||
|
/// </summary>
|
||||||
|
Composition,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter node used by <c>TemplateFolderTree</c> to model the template-folder
|
||||||
|
/// hierarchy in a TreeView. Folder nodes carry sub-folders + their templates as
|
||||||
|
/// children; template nodes are leaves unless the caller injects extras via
|
||||||
|
/// <c>TemplateFolderTree.ExtraTemplateChildren</c> (e.g. composition slots).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TemplateTreeNode
|
||||||
|
{
|
||||||
|
public required TemplateTreeNodeKind Kind { get; init; }
|
||||||
|
public required int Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public List<TemplateTreeNode> Children { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
|
||||||
|
public string Key => Kind switch
|
||||||
|
{
|
||||||
|
TemplateTreeNodeKind.Folder => $"f:{Id}",
|
||||||
|
TemplateTreeNodeKind.Template => $"t:{Id}",
|
||||||
|
TemplateTreeNodeKind.Composition => $"c:{Id}",
|
||||||
|
_ => $"x:{Id}",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,12 @@ else
|
|||||||
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
||||||
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
||||||
|
|
||||||
|
// Checkbox-mode tri-state computed for this node (folder = aggregate of
|
||||||
|
// descendant leaves; leaf = present-in-SelectedKeys).
|
||||||
|
var checkState = SelectionMode == TreeViewSelectionMode.Checkbox
|
||||||
|
? ComputeCheckState(item)
|
||||||
|
: CheckState.Unchecked;
|
||||||
|
|
||||||
<li role="treeitem" @key="key"
|
<li role="treeitem" @key="key"
|
||||||
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
||||||
aria-selected="@(isSelected ? "true" : null)">
|
aria-selected="@(isSelected ? "true" : null)">
|
||||||
@@ -51,6 +57,15 @@ else
|
|||||||
{
|
{
|
||||||
<span class="tv-spacer"></span>
|
<span class="tv-spacer"></span>
|
||||||
}
|
}
|
||||||
|
@if (SelectionMode == TreeViewSelectionMode.Checkbox)
|
||||||
|
{
|
||||||
|
<input type="checkbox"
|
||||||
|
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
|
||||||
|
@ref="_checkboxRefs[KeyStr(key)]"
|
||||||
|
checked="@(checkState == CheckState.Checked)"
|
||||||
|
@onchange="() => OnCheckboxToggle(item)"
|
||||||
|
@onclick:stopPropagation />
|
||||||
|
}
|
||||||
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
|
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
|
||||||
@NodeContent(item)
|
@NodeContent(item)
|
||||||
</span>
|
</span>
|
||||||
@@ -99,6 +114,15 @@ else
|
|||||||
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
||||||
[Parameter] public string? StorageKey { get; set; }
|
[Parameter] public string? StorageKey { get; set; }
|
||||||
|
|
||||||
|
// ── Checkbox-selection mode (additive; SelectionMode=Single keeps prior behaviour) ──
|
||||||
|
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
||||||
|
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
|
||||||
|
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ElementReference> _checkboxRefs = new();
|
||||||
|
|
||||||
|
private enum CheckState { Unchecked, Checked, Indeterminate }
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
_items = Items;
|
_items = Items;
|
||||||
@@ -182,6 +206,32 @@ else
|
|||||||
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply checkbox tri-state (`indeterminate`) after every render in
|
||||||
|
// Checkbox mode. Blazor doesn't bind input.indeterminate natively.
|
||||||
|
if (SelectionMode == TreeViewSelectionMode.Checkbox && _items is { Count: > 0 })
|
||||||
|
{
|
||||||
|
await ApplyIndeterminateStateAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyIndeterminateStateAsync()
|
||||||
|
{
|
||||||
|
foreach (var (keyStr, elemRef) in _checkboxRefs)
|
||||||
|
{
|
||||||
|
var item = FindItemByKey(_items!, keyStr);
|
||||||
|
if (item is null) continue;
|
||||||
|
var state = ComputeCheckState(item);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"treeviewStorage.setIndeterminate",
|
||||||
|
elemRef,
|
||||||
|
state == CheckState.Indeterminate);
|
||||||
|
}
|
||||||
|
catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
|
||||||
|
catch (Microsoft.JSInterop.JSException) { /* element gone */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
|
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
|
||||||
@@ -362,4 +412,110 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Checkbox-selection helpers ──────────────────────────────────────────
|
||||||
|
// Folder = aggregate of its descendant LEAVES (we don't track folder keys
|
||||||
|
// in SelectedKeys — only leaf keys are persisted). A folder is Checked when
|
||||||
|
// every descendant leaf is in SelectedKeys, Unchecked when none are, and
|
||||||
|
// Indeterminate otherwise.
|
||||||
|
private CheckState ComputeCheckState(TItem item)
|
||||||
|
{
|
||||||
|
var children = ChildrenSelector(item);
|
||||||
|
var isBranch = HasChildrenSelector(item);
|
||||||
|
if (!isBranch || children is null || children.Count == 0)
|
||||||
|
{
|
||||||
|
var leafKey = KeySelector(item);
|
||||||
|
return SelectedKeys != null && SelectedKeys.Contains(leafKey)
|
||||||
|
? CheckState.Checked
|
||||||
|
: CheckState.Unchecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = 0;
|
||||||
|
var selected = 0;
|
||||||
|
CountDescendantLeaves(item, ref total, ref selected);
|
||||||
|
if (total == 0) return CheckState.Unchecked;
|
||||||
|
if (selected == 0) return CheckState.Unchecked;
|
||||||
|
if (selected == total) return CheckState.Checked;
|
||||||
|
return CheckState.Indeterminate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CountDescendantLeaves(TItem item, ref int total, ref int selected)
|
||||||
|
{
|
||||||
|
var children = ChildrenSelector(item);
|
||||||
|
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
|
||||||
|
if (!hasChildren)
|
||||||
|
{
|
||||||
|
total++;
|
||||||
|
if (SelectedKeys != null && SelectedKeys.Contains(KeySelector(item)))
|
||||||
|
{
|
||||||
|
selected++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in children!)
|
||||||
|
{
|
||||||
|
CountDescendantLeaves(child, ref total, ref selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollectDescendantLeafKeys(TItem item, List<object> sink)
|
||||||
|
{
|
||||||
|
var children = ChildrenSelector(item);
|
||||||
|
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
|
||||||
|
if (!hasChildren)
|
||||||
|
{
|
||||||
|
sink.Add(KeySelector(item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in children!)
|
||||||
|
{
|
||||||
|
CollectDescendantLeafKeys(child, sink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnCheckboxToggle(TItem item)
|
||||||
|
{
|
||||||
|
// SelectedKeys is the source of truth — copy-on-write so consumers
|
||||||
|
// observe a fresh reference and Blazor reliably re-renders.
|
||||||
|
var current = SelectedKeys is null
|
||||||
|
? new HashSet<object>()
|
||||||
|
: new HashSet<object>(SelectedKeys);
|
||||||
|
|
||||||
|
var leaves = new List<object>();
|
||||||
|
CollectDescendantLeafKeys(item, leaves);
|
||||||
|
if (leaves.Count == 0) return;
|
||||||
|
|
||||||
|
// Folder-toggle semantics: if every descendant leaf is currently selected,
|
||||||
|
// uncheck them all; otherwise select all. Leaf nodes have leaves = { self }
|
||||||
|
// so this simplifies to a plain toggle.
|
||||||
|
var allSelected = leaves.All(current.Contains);
|
||||||
|
if (allSelected)
|
||||||
|
{
|
||||||
|
foreach (var k in leaves) current.Remove(k);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var k in leaves) current.Add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedKeys = current;
|
||||||
|
await SelectedKeysChanged.InvokeAsync(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TItem? FindItemByKey(IReadOnlyList<TItem> items, string keyStr)
|
||||||
|
{
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (KeyStr(KeySelector(item)) == keyStr) return item;
|
||||||
|
var children = ChildrenSelector(item);
|
||||||
|
if (children is { Count: > 0 })
|
||||||
|
{
|
||||||
|
var found = FindItemByKey(children, keyStr);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ScadaLink.CentralUI.Components.Shared;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selection mode for <see cref="TreeView{TItem}"/>. <see cref="Single"/> is the
|
||||||
|
/// default click-to-select behaviour (preserves legacy callers). <see cref="Checkbox"/>
|
||||||
|
/// renders an input checkbox per node with tri-state propagation on folders.
|
||||||
|
/// </summary>
|
||||||
|
public enum TreeViewSelectionMode
|
||||||
|
{
|
||||||
|
Single,
|
||||||
|
Checkbox,
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<ProjectReference Include="../ScadaLink.DeploymentManager/ScadaLink.DeploymentManager.csproj" />
|
<ProjectReference Include="../ScadaLink.DeploymentManager/ScadaLink.DeploymentManager.csproj" />
|
||||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||||
|
<ProjectReference Include="../ScadaLink.Transport/ScadaLink.Transport.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
// and "% errors over the last hour" as the KPI definition.
|
// and "% errors over the last hour" as the KPI definition.
|
||||||
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
|
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
// Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers
|
||||||
|
// the filter dropdown population. A 60s cache keeps rendering the filter
|
||||||
|
// bar cheap — node membership changes (failover, scaling) surface within
|
||||||
|
// a minute, which is acceptable for a filter affordance.
|
||||||
|
private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
|
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
|
||||||
private readonly IServiceScopeFactory? _scopeFactory;
|
private readonly IServiceScopeFactory? _scopeFactory;
|
||||||
|
|
||||||
@@ -47,6 +53,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
|
|
||||||
private readonly ICentralHealthAggregator _healthAggregator;
|
private readonly ICentralHealthAggregator _healthAggregator;
|
||||||
|
|
||||||
|
// Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
|
||||||
|
// a stampede of concurrent filter-bar renders does not turn into N DB hits.
|
||||||
|
private readonly object _sourceNodesLock = new();
|
||||||
|
private IReadOnlyList<string>? _cachedSourceNodes;
|
||||||
|
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
|
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
|
||||||
/// fresh DI scope on every call so each query gets its own
|
/// fresh DI scope on every call so each query gets its own
|
||||||
@@ -151,4 +163,42 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
|||||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
return await repository.GetExecutionTreeAsync(executionId, ct);
|
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Fast path: a fresh cache entry served entirely from memory.
|
||||||
|
lock (_sourceNodesLock)
|
||||||
|
{
|
||||||
|
if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
|
||||||
|
{
|
||||||
|
return _cachedSourceNodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<string> snapshot;
|
||||||
|
if (_injectedRepository is not null)
|
||||||
|
{
|
||||||
|
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||||
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||||
|
snapshot = await repository.GetDistinctSourceNodesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: replace the cache entry. Concurrent slow paths can race
|
||||||
|
// here — every winner stores a valid snapshot, so the last write wins
|
||||||
|
// and no caller sees stale data. The double-check inside the lock is
|
||||||
|
// deliberately omitted: redundant DB hits during a stampede are rare
|
||||||
|
// (60s TTL) and cheaper than holding the lock across the await.
|
||||||
|
lock (_sourceNodesLock)
|
||||||
|
{
|
||||||
|
_cachedSourceNodes = snapshot;
|
||||||
|
_cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,4 +69,15 @@ public interface IAuditLogQueryService
|
|||||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId,
|
Guid executionId,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
|
||||||
|
/// central <c>AuditLog</c> table, backing the Audit Log page's Node
|
||||||
|
/// multi-select filter. The implementation caches the result for ~60s so
|
||||||
|
/// rendering the filter bar never produces more than one DB hit per minute
|
||||||
|
/// per circuit. The cache is process-wide — node membership changes
|
||||||
|
/// (failover, scaling) surface within a minute, which is acceptable for a
|
||||||
|
/// filter affordance.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Transport bundle export — browser-side download trigger.
|
||||||
|
//
|
||||||
|
// The TransportExport wizard (T21) computes a bundle byte[] server-side, encodes
|
||||||
|
// it to base64, and hands it to this helper to push to the user's browser as a
|
||||||
|
// file download. The base64 detour avoids binary marshalling headaches over the
|
||||||
|
// SignalR JS-interop boundary — Blazor Server cannot stream a byte[] back to
|
||||||
|
// the browser directly without a [JSInvokable] dance.
|
||||||
|
//
|
||||||
|
// The link is built fresh and removed each call so back-to-back exports don't
|
||||||
|
// keep stale URLs around in the DOM.
|
||||||
|
window.scadalinkTransport = {
|
||||||
|
downloadBundle: function (filename, base64Data) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = "data:application/octet-stream;base64," + base64Data;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -43,6 +43,15 @@ public sealed record AuditEvent
|
|||||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||||
public string? SourceSiteId { get; init; }
|
public string? SourceSiteId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cluster node on which the event was emitted — `node-a` / `node-b` for
|
||||||
|
/// site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
|
||||||
|
/// for central-originated rows. Stamped by the writing node from
|
||||||
|
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that
|
||||||
|
/// has since been retired don't block ingest.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
|
||||||
/// <summary>Instance id where the action originated, when applicable.</summary>
|
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||||
public string? SourceInstanceId { get; init; }
|
public string? SourceInstanceId { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class AuditLogEntry
|
|||||||
public string EntityName { get; set; }
|
public string EntityName { get; set; }
|
||||||
public string? AfterStateJson { get; set; }
|
public string? AfterStateJson { get; set; }
|
||||||
public DateTimeOffset Timestamp { get; set; }
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
public Guid? BundleImportId { get; set; }
|
||||||
|
|
||||||
public AuditLogEntry(string user, string action, string entityType, string entityId, string entityName)
|
public AuditLogEntry(string user, string action, string entityType, string entityId, string entityName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ public sealed record SiteCall
|
|||||||
/// <summary>Site id that submitted the cached call.</summary>
|
/// <summary>Site id that submitted the cached call.</summary>
|
||||||
public required string SourceSite { get; init; }
|
public required string SourceSite { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cluster node on which the cached call was emitted — <c>node-a</c> /
|
||||||
|
/// <c>node-b</c> for site rows (qualified by <see cref="SourceSite"/>),
|
||||||
|
/// <c>central-a</c> / <c>central-b</c> for central-originated rows. Stamped
|
||||||
|
/// by the emitting node from <c>INodeIdentityProvider</c>; nullable so
|
||||||
|
/// reconciled rows from a node that has since been retired don't block ingest.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceNode { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lifecycle status — string form of
|
/// Lifecycle status — string form of
|
||||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank
|
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ public class Notification
|
|||||||
/// <summary>Resolved delivery targets snapshotted at delivery time, for audit.</summary>
|
/// <summary>Resolved delivery targets snapshotted at delivery time, for audit.</summary>
|
||||||
public string? ResolvedTargets { get; set; }
|
public string? ResolvedTargets { get; set; }
|
||||||
public string SourceSiteId { get; set; }
|
public string SourceSiteId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The cluster node on which the notification was emitted — `node-a` / `node-b`
|
||||||
|
/// for site rows (qualified by <see cref="SourceSiteId"/>), `central-a` / `central-b`
|
||||||
|
/// for central-originated rows. Carried from the site on the
|
||||||
|
/// <see cref="Commons.Messages.Notification.NotificationSubmit"/> and persisted at
|
||||||
|
/// central; nullable so rows submitted before the column existed don't block ingest.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceNode { get; set; }
|
||||||
public string? SourceInstanceId { get; set; }
|
public string? SourceInstanceId { get; set; }
|
||||||
public string? SourceScript { get; set; }
|
public string? SourceScript { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public interface IOperationTrackingStore
|
|||||||
string? targetSummary,
|
string? targetSummary,
|
||||||
string? sourceInstanceId,
|
string? sourceInstanceId,
|
||||||
string? sourceScript,
|
string? sourceScript,
|
||||||
|
string? sourceNode,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -175,4 +175,12 @@ public interface IAuditLogRepository
|
|||||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||||
Guid executionId,
|
Guid executionId,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
|
||||||
|
/// <c>AuditLog</c> table, in ascending order. Backs the Audit Log page's
|
||||||
|
/// "Node" multi-select filter dropdown — the Central UI caches the result
|
||||||
|
/// for ~60s so the repository is hit at most once per minute per circuit.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public interface ICentralUiRepository
|
|||||||
DateTimeOffset? to = null,
|
DateTimeOffset? to = null,
|
||||||
string? entityId = null,
|
string? entityId = null,
|
||||||
string? entityName = null,
|
string? entityName = null,
|
||||||
|
Guid? bundleImportId = null,
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 50,
|
int pageSize = 50,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surfaces the local node's semantic role-within-cluster name so downstream
|
||||||
|
/// audit writers can stamp it on the SourceNode column.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Conventional values follow the pattern <c>node-a</c>/<c>node-b</c> on site
|
||||||
|
/// nodes and <c>central-a</c>/<c>central-b</c> on central nodes. The value is
|
||||||
|
/// a free-form operator-supplied label — there is no enforced format. When the
|
||||||
|
/// configuration value is missing, empty, or whitespace, implementations
|
||||||
|
/// return <c>null</c> so audit writers can persist NULL rather than an empty
|
||||||
|
/// string.
|
||||||
|
/// </remarks>
|
||||||
|
public interface INodeIdentityProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The configured semantic node name, trimmed of surrounding whitespace.
|
||||||
|
/// <c>null</c> when unconfigured.
|
||||||
|
/// </summary>
|
||||||
|
string? NodeName { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scoped service the bundle importer sets to thread a BundleImportId through to
|
||||||
|
/// the audit log entries emitted by the audited repository methods invoked during
|
||||||
|
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
|
||||||
|
/// <para>
|
||||||
|
/// Re-entrancy / thread-safety: mutating <see cref="BundleImportId"/> is NOT
|
||||||
|
/// thread-safe. The service is registered scoped, and the assumed usage is a
|
||||||
|
/// single Blazor Server circuit (or single API request) at a time — within that
|
||||||
|
/// scope <see cref="BundleImporter.ApplyAsync"/> is the sole writer, and the
|
||||||
|
/// audit service is the sole reader, in a strictly sequential await chain.
|
||||||
|
/// Callers that perform concurrent imports within a shared scope (e.g. two
|
||||||
|
/// <c>ApplyAsync</c> calls awaited via <c>Task.WhenAll</c> on the same circuit)
|
||||||
|
/// MUST serialize access externally — there is no internal lock and the last
|
||||||
|
/// writer wins, which would cross-contaminate audit rows between imports.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuditCorrelationContext
|
||||||
|
{
|
||||||
|
Guid? BundleImportId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
public interface IBundleExporter
|
||||||
|
{
|
||||||
|
Task<Stream> ExportAsync(
|
||||||
|
ExportSelection selection,
|
||||||
|
string user,
|
||||||
|
string sourceEnvironment,
|
||||||
|
string? passphrase,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
public interface IBundleImporter
|
||||||
|
{
|
||||||
|
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
|
||||||
|
Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default);
|
||||||
|
Task<ImportResult> ApplyAsync(
|
||||||
|
Guid sessionId,
|
||||||
|
IReadOnlyList<ImportResolution> resolutions,
|
||||||
|
string user,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
public interface IBundleSessionStore
|
||||||
|
{
|
||||||
|
BundleSession Open(BundleSession session);
|
||||||
|
BundleSession? Get(Guid sessionId);
|
||||||
|
void Remove(Guid sessionId);
|
||||||
|
void EvictExpired();
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ public sealed record SiteCallQueryRequest(
|
|||||||
DateTime? ToUtc,
|
DateTime? ToUtc,
|
||||||
DateTime? AfterCreatedAtUtc,
|
DateTime? AfterCreatedAtUtc,
|
||||||
Guid? AfterId,
|
Guid? AfterId,
|
||||||
int PageSize);
|
int PageSize,
|
||||||
|
string? SourceNodeFilter = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
/// A single <c>SiteCalls</c> row summarised for the Site Calls UI grid. Carries
|
||||||
@@ -61,7 +62,8 @@ public sealed record SiteCallSummary(
|
|||||||
DateTime CreatedAtUtc,
|
DateTime CreatedAtUtc,
|
||||||
DateTime UpdatedAtUtc,
|
DateTime UpdatedAtUtc,
|
||||||
DateTime? TerminalAtUtc,
|
DateTime? TerminalAtUtc,
|
||||||
bool IsStuck);
|
bool IsStuck,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
/// Central -> Site Calls UI: paginated response for a <see cref="SiteCallQueryRequest"/>.
|
||||||
@@ -117,7 +119,8 @@ public sealed record SiteCallDetail(
|
|||||||
DateTime CreatedAtUtc,
|
DateTime CreatedAtUtc,
|
||||||
DateTime UpdatedAtUtc,
|
DateTime UpdatedAtUtc,
|
||||||
DateTime? TerminalAtUtc,
|
DateTime? TerminalAtUtc,
|
||||||
DateTime IngestedAtUtc);
|
DateTime IngestedAtUtc,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
|
/// Site Calls UI -> Central: request for the global <c>SiteCalls</c> KPI summary.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using ScadaLink.Commons.Types.Transport;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.Management;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports a bundle. Names rather than IDs in the selection so test scripts can
|
||||||
|
/// be written without an ID lookup step. <c>All=true</c> overrides the per-type
|
||||||
|
/// name lists and exports every entity of every supported type.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExportBundleCommand(
|
||||||
|
bool All,
|
||||||
|
IReadOnlyList<string>? TemplateNames,
|
||||||
|
IReadOnlyList<string>? SharedScriptNames,
|
||||||
|
IReadOnlyList<string>? ExternalSystemNames,
|
||||||
|
IReadOnlyList<string>? DatabaseConnectionNames,
|
||||||
|
IReadOnlyList<string>? NotificationListNames,
|
||||||
|
IReadOnlyList<string>? SmtpConfigurationNames,
|
||||||
|
IReadOnlyList<string>? ApiKeyNames,
|
||||||
|
IReadOnlyList<string>? ApiMethodNames,
|
||||||
|
bool IncludeDependencies,
|
||||||
|
string? Passphrase,
|
||||||
|
string SourceEnvironment);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle body returned as base64-encoded ZIP. <see cref="ByteCount"/> is the
|
||||||
|
/// pre-encoded size for sanity checks against the configured bundle cap.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExportBundleResult(
|
||||||
|
string Base64Bundle,
|
||||||
|
int ByteCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a bundle and returns its preview without applying anything. Useful
|
||||||
|
/// for tests that want to assert on the diff shape before committing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PreviewBundleCommand(
|
||||||
|
string Base64Bundle,
|
||||||
|
string? Passphrase);
|
||||||
|
|
||||||
|
public sealed record PreviewBundleResult(
|
||||||
|
IReadOnlyList<ImportPreviewItem> Items,
|
||||||
|
int AddCount,
|
||||||
|
int ModifiedCount,
|
||||||
|
int IdenticalCount,
|
||||||
|
int BlockerCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads, previews, and applies a bundle in a single call. The diff is built
|
||||||
|
/// internally; every <see cref="ConflictKind.Modified"/> row is resolved with
|
||||||
|
/// <see cref="DefaultConflictPolicy"/>, every <see cref="ConflictKind.New"/>
|
||||||
|
/// row gets <see cref="ResolutionAction.Add"/>, every
|
||||||
|
/// <see cref="ConflictKind.Identical"/> row gets <see cref="ResolutionAction.Skip"/>,
|
||||||
|
/// and any <see cref="ConflictKind.Blocker"/> row fails the call.
|
||||||
|
/// <para>
|
||||||
|
/// Valid <see cref="DefaultConflictPolicy"/> values: <c>"skip"</c>,
|
||||||
|
/// <c>"overwrite"</c>, <c>"rename"</c>. Rename mints a unique suffix per row.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ImportBundleCommand(
|
||||||
|
string Base64Bundle,
|
||||||
|
string? Passphrase,
|
||||||
|
string DefaultConflictPolicy);
|
||||||
@@ -18,6 +18,15 @@ namespace ScadaLink.Commons.Messages.Notification;
|
|||||||
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||||
/// before the field existed, or for non-routed runs.
|
/// before the field existed, or for non-routed runs.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="SourceNode">
|
||||||
|
/// The cluster node on which the notification was emitted — `node-a` / `node-b` for site
|
||||||
|
/// submissions, `central-a` / `central-b` for central-originated rows. Stamped by the
|
||||||
|
/// emitting node from <c>INodeIdentityProvider</c> and carried, inside the serialized
|
||||||
|
/// payload, through the site store-and-forward buffer so the central dispatcher can
|
||||||
|
/// persist it on the <c>Notifications</c> row and echo it onto the <c>NotifyDeliver</c>
|
||||||
|
/// audit rows. Additive trailing member — null for messages built before the field
|
||||||
|
/// existed.
|
||||||
|
/// </param>
|
||||||
public record NotificationSubmit(
|
public record NotificationSubmit(
|
||||||
string NotificationId,
|
string NotificationId,
|
||||||
string ListName,
|
string ListName,
|
||||||
@@ -28,7 +37,8 @@ public record NotificationSubmit(
|
|||||||
string? SourceScript,
|
string? SourceScript,
|
||||||
DateTimeOffset SiteEnqueuedAt,
|
DateTimeOffset SiteEnqueuedAt,
|
||||||
Guid? OriginExecutionId = null,
|
Guid? OriginExecutionId = null,
|
||||||
Guid? OriginParentExecutionId = null);
|
Guid? OriginParentExecutionId = null,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Site: ack sent after the notification row is persisted.
|
/// Central -> Site: ack sent after the notification row is persisted.
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public record NotificationOutboxQueryRequest(
|
|||||||
DateTimeOffset? From,
|
DateTimeOffset? From,
|
||||||
DateTimeOffset? To,
|
DateTimeOffset? To,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize);
|
int PageSize,
|
||||||
|
string? SourceNodeFilter = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single notification row summarised for outbox UI display.
|
/// A single notification row summarised for outbox UI display.
|
||||||
@@ -34,7 +35,8 @@ public record NotificationSummary(
|
|||||||
string? SourceInstanceId,
|
string? SourceInstanceId,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset? DeliveredAt,
|
DateTimeOffset? DeliveredAt,
|
||||||
bool IsStuck);
|
bool IsStuck,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.
|
/// Central -> Outbox UI: paginated response for a <see cref="NotificationOutboxQueryRequest"/>.
|
||||||
@@ -117,7 +119,8 @@ public record NotificationDetail(
|
|||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset? LastAttemptAt,
|
DateTimeOffset? LastAttemptAt,
|
||||||
DateTimeOffset? NextAttemptAt,
|
DateTimeOffset? NextAttemptAt,
|
||||||
DateTimeOffset? DeliveredAt);
|
DateTimeOffset? DeliveredAt,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
/// Outbox UI -> Central: request for the notification outbox KPI summary.
|
||||||
|
|||||||
@@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||||
/// Any field left <c>null</c> means "do not constrain on that column". The
|
/// Any field left <c>null</c> means "do not constrain on that column". The
|
||||||
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
|
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/>,
|
||||||
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
|
/// <see cref="SourceSiteIds"/> and <see cref="SourceNodes"/> dimensions are
|
||||||
/// list means "do not constrain", and a non-empty list is OR-combined within the
|
/// multi-value: a <c>null</c> OR empty list means "do not constrain", and a
|
||||||
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
/// non-empty list is OR-combined within the dimension (translated to a SQL
|
||||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
/// <c>IN (…)</c>). Time bounds are half-open in the spec sense —
|
||||||
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
/// <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is inclusive of
|
||||||
|
/// the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||||
/// respectively. All filter dimensions are AND-combined with one another. The
|
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||||
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
||||||
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="SourceNodes">
|
||||||
|
/// Restrict to rows whose <c>SourceNode</c> matches one of the supplied node
|
||||||
|
/// identifiers (e.g. <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). A null
|
||||||
|
/// or empty list means "do not constrain"; a non-empty list is translated to
|
||||||
|
/// SQL <c>SourceNode IN (…)</c>. Rows with NULL <c>SourceNode</c> are excluded
|
||||||
|
/// when the filter is set (the same SourceSiteIds contract).
|
||||||
|
/// </param>
|
||||||
public sealed record AuditLogQueryFilter(
|
public sealed record AuditLogQueryFilter(
|
||||||
IReadOnlyList<AuditChannel>? Channels = null,
|
IReadOnlyList<AuditChannel>? Channels = null,
|
||||||
IReadOnlyList<AuditKind>? Kinds = null,
|
IReadOnlyList<AuditKind>? Kinds = null,
|
||||||
@@ -26,4 +34,5 @@ public sealed record AuditLogQueryFilter(
|
|||||||
Guid? ExecutionId = null,
|
Guid? ExecutionId = null,
|
||||||
Guid? ParentExecutionId = null,
|
Guid? ParentExecutionId = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null);
|
DateTime? ToUtc = null,
|
||||||
|
IReadOnlyList<string>? SourceNodes = null);
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
/// keeps the "StuckOnly" filter honest so paging never returns under-filled
|
||||||
/// pages with a non-null next cursor.
|
/// pages with a non-null next cursor.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="SourceNode">
|
||||||
|
/// Restrict to cached calls originating at a specific cluster node (e.g.
|
||||||
|
/// <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c> means "do not
|
||||||
|
/// constrain". Rows with NULL <c>SourceNode</c> are excluded when set.
|
||||||
|
/// </param>
|
||||||
public sealed record SiteCallQueryFilter(
|
public sealed record SiteCallQueryFilter(
|
||||||
string? Channel = null,
|
string? Channel = null,
|
||||||
string? SourceSite = null,
|
string? SourceSite = null,
|
||||||
@@ -33,4 +38,5 @@ public sealed record SiteCallQueryFilter(
|
|||||||
string? Target = null,
|
string? Target = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null,
|
DateTime? ToUtc = null,
|
||||||
DateTime? StuckCutoffUtc = null);
|
DateTime? StuckCutoffUtc = null,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ namespace ScadaLink.Commons.Types.Notifications;
|
|||||||
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
|
/// <param name="StuckCutoff">Rows with <c>CreatedAt</c> older than this count as stuck.</param>
|
||||||
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
|
/// <param name="From">Inclusive lower bound on <c>CreatedAt</c>.</param>
|
||||||
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
|
/// <param name="To">Inclusive upper bound on <c>CreatedAt</c>.</param>
|
||||||
|
/// <param name="SourceNode">
|
||||||
|
/// Restrict to notifications originating at a specific cluster node (e.g.
|
||||||
|
/// <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). Exact match; <c>null</c>
|
||||||
|
/// means "do not constrain".
|
||||||
|
/// </param>
|
||||||
public record NotificationOutboxFilter(
|
public record NotificationOutboxFilter(
|
||||||
NotificationStatus? Status = null,
|
NotificationStatus? Status = null,
|
||||||
NotificationType? Type = null,
|
NotificationType? Type = null,
|
||||||
@@ -27,4 +32,5 @@ public record NotificationOutboxFilter(
|
|||||||
bool StuckOnly = false,
|
bool StuckOnly = false,
|
||||||
DateTimeOffset? StuckCutoff = null,
|
DateTimeOffset? StuckCutoff = null,
|
||||||
DateTimeOffset? From = null,
|
DateTimeOffset? From = null,
|
||||||
DateTimeOffset? To = null);
|
DateTimeOffset? To = null,
|
||||||
|
string? SourceNode = null);
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ namespace ScadaLink.Commons.Types;
|
|||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
|
/// <param name="Target">Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</param>
|
||||||
/// <param name="SourceSite">Site id that submitted the cached call.</param>
|
/// <param name="SourceSite">Site id that submitted the cached call.</param>
|
||||||
|
/// <param name="SourceNode">
|
||||||
|
/// The cluster node on which the cached call was emitted — <c>node-a</c> / <c>node-b</c>
|
||||||
|
/// for site rows (qualified by <paramref name="SourceSite"/>), <c>central-a</c> /
|
||||||
|
/// <c>central-b</c> for central-originated rows. Stamped by the emitting node from
|
||||||
|
/// <c>INodeIdentityProvider</c>; nullable so reconciled rows from a node that has since
|
||||||
|
/// been retired don't block ingest.
|
||||||
|
/// </param>
|
||||||
/// <param name="Status">
|
/// <param name="Status">
|
||||||
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>:
|
/// Lifecycle status — string form of <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>:
|
||||||
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
|
/// <c>Submitted</c>, <c>Retrying</c>, <c>Attempted</c>, <c>Delivered</c>,
|
||||||
@@ -37,6 +44,7 @@ public sealed record SiteCallOperational(
|
|||||||
string Channel,
|
string Channel,
|
||||||
string Target,
|
string Target,
|
||||||
string SourceSite,
|
string SourceSite,
|
||||||
|
string? SourceNode,
|
||||||
string Status,
|
string Status,
|
||||||
int RetryCount,
|
int RetryCount,
|
||||||
string? LastError,
|
string? LastError,
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ namespace ScadaLink.Commons.Types;
|
|||||||
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
|
/// <param name="TerminalAtUtc">UTC timestamp the row reached a terminal status; null while still active.</param>
|
||||||
/// <param name="SourceInstanceId">Instance id that issued the cached call, when known.</param>
|
/// <param name="SourceInstanceId">Instance id that issued the cached call, when known.</param>
|
||||||
/// <param name="SourceScript">Script that issued the cached call, when known.</param>
|
/// <param name="SourceScript">Script that issued the cached call, when known.</param>
|
||||||
|
/// <param name="SourceNode">
|
||||||
|
/// Cluster node that submitted the cached call (e.g. <c>"node-a"</c> /
|
||||||
|
/// <c>"node-b"</c>), captured at enqueue time. Null on rows persisted before
|
||||||
|
/// the SourceNode stamping migration; stamping itself is wired in a later task.
|
||||||
|
/// </param>
|
||||||
public sealed record TrackingStatusSnapshot(
|
public sealed record TrackingStatusSnapshot(
|
||||||
TrackedOperationId Id,
|
TrackedOperationId Id,
|
||||||
string Kind,
|
string Kind,
|
||||||
@@ -37,4 +42,5 @@ public sealed record TrackingStatusSnapshot(
|
|||||||
DateTime UpdatedAtUtc,
|
DateTime UpdatedAtUtc,
|
||||||
DateTime? TerminalAtUtc,
|
DateTime? TerminalAtUtc,
|
||||||
string? SourceInstanceId,
|
string? SourceInstanceId,
|
||||||
string? SourceScript);
|
string? SourceScript,
|
||||||
|
string? SourceNode);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Transport;
|
||||||
|
|
||||||
|
public sealed record BundleManifest(
|
||||||
|
int BundleFormatVersion,
|
||||||
|
string SchemaVersion,
|
||||||
|
DateTimeOffset CreatedAtUtc,
|
||||||
|
string SourceEnvironment,
|
||||||
|
string ExportedBy,
|
||||||
|
string ScadaLinkVersion,
|
||||||
|
string ContentHash,
|
||||||
|
EncryptionMetadata? Encryption,
|
||||||
|
BundleSummary Summary,
|
||||||
|
IReadOnlyList<ManifestContentEntry> Contents);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user