Compare commits
239 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb313cf58 | |||
| 295150751f | |||
| d7b05b40e9 | |||
| e54c4a6c2e | |||
| fc18239b97 | |||
| 1d5465f31c | |||
| 17e24ddd20 | |||
| 80ec16a6d0 | |||
| 3f37584728 | |||
| 733679a376 | |||
| 7044791a55 | |||
| 72e7bbe968 | |||
| f66dc031a4 | |||
| 7bba48a14a | |||
| 1c2dc45803 | |||
| 1822e3c76f | |||
| 6f1f6b8467 | |||
| d9caa3dd7e | |||
| 352c93d5a2 | |||
| 164d914ba8 | |||
| 4e446a7170 | |||
| 751248feb6 | |||
| 783da8e21a | |||
| 57f477fd28 | |||
| 85769486df | |||
| 4f90f952d0 | |||
| 1f86945d46 | |||
| 54338abdce | |||
| 78de4a6492 | |||
| 5c3dc79b8a | |||
| 552c9e4065 | |||
| a965d4a5bd | |||
| f05b03f1cc | |||
| f599809486 | |||
| 8b8b85c839 | |||
| 03a8c4a632 | |||
| fa86750717 | |||
| 91b786eb1c | |||
| 5615f3d0c7 | |||
| a968cefbc2 | |||
| 68548432b3 | |||
| 0139c9ca83 | |||
| 0b24b4537d | |||
| efba01d10a | |||
| 3ed05f0595 | |||
| 0528c65cba | |||
| 004c5da582 | |||
| cd0ec583e1 | |||
| 225817eac9 | |||
| cf9548e9ed | |||
| 7f01c5547a | |||
| e667ea2b50 | |||
| 1b98d37919 | |||
| eb1d6872ef | |||
| 8038aa7cb5 | |||
| e21791adb0 | |||
| 321ca0bbbf | |||
| b6e2ec8a50 | |||
| da2c0d714e | |||
| f7b10f2ff7 | |||
| ff5f5a10ef | |||
| 0805e18e9c | |||
| 22d91c858a | |||
| f89f234558 | |||
| 8faaa8fe2b | |||
| e6a5b558f3 | |||
| b60a8ef409 | |||
| 91450ec390 | |||
| 16f7ab0d0a | |||
| 084da55ad6 | |||
| cfb90d2078 | |||
| 9916aeaa47 | |||
| 505731fcef | |||
| 46260f30ee | |||
| 1c71d3342a | |||
| 304ebec121 | |||
| 496d2a68e3 | |||
| f98d29fc36 | |||
| 80d4d3e252 | |||
| b53221e44a | |||
| 4608adcd53 | |||
| 8fbf167389 | |||
| 90b252047e | |||
| 2220bfcf58 | |||
| b16606d97e | |||
| a9c4c2c655 | |||
| c906e73441 | |||
| da5fdf0e63 | |||
| f3386d0278 | |||
| b2eddd9713 | |||
| b4cb7e6f5f | |||
| 8e388a89c5 | |||
| f3b33e7e1d | |||
| d8e6f44616 | |||
| ca164dca03 | |||
| acead212b2 | |||
| 3587ab4fcb | |||
| 17e690f6ef | |||
| 8155dbc411 | |||
| d54013cb88 | |||
| ca3b34223d | |||
| c60aad9df4 | |||
| fc105acd7c | |||
| 39e6e0a525 | |||
| 4977f99a74 | |||
| 78165b3d99 | |||
| 20f60c88f9 | |||
| 3d28f0d2eb | |||
| a293f5a365 | |||
| 2c301c6fe1 | |||
| e3315781cb | |||
| 72b9f7e66e | |||
| 723ab61bd8 | |||
| e44bbc0caf | |||
| 1269054651 | |||
| 3dfc7180c5 | |||
| ff23f64cf8 | |||
| 44c6e4a553 | |||
| 4b1077d686 | |||
| 978ac79ad8 | |||
| e0b098d200 | |||
| 1d27ec3b85 | |||
| 80f407ae0d | |||
| 18387df8cb | |||
| 892204ea3a | |||
| daa01261f3 | |||
| 872d358ad3 | |||
| ec1d8f1393 | |||
| 5da779db17 | |||
| 9dccf8e72f | |||
| 8423915ba1 | |||
| 6df2cbdf90 | |||
| b3076e18db | |||
| de7c4067e4 | |||
| 5fdeaf613f | |||
| ff2784b862 | |||
| 0d03aec4f2 | |||
| d4397910f0 | |||
| 02a7e8abc6 | |||
| 65cc7b69cd | |||
| e84a831a02 | |||
| 5e2a4c9080 | |||
| 0abaa47de2 | |||
| a0a6bb4986 | |||
| 2b5dabb336 | |||
| 968fc4adc7 | |||
| 4c7fa03c07 | |||
| addbb6ffeb | |||
| f1537b62ca | |||
| 71894f4ba9 | |||
| 4426f3e928 | |||
| 08d511f609 | |||
| 4e5b5facec | |||
| f127efe6ea | |||
| d3a6ed5f68 | |||
| da4f29f6ee | |||
| 75648c0c76 | |||
| 4db93cae2b | |||
| eecd82b787 | |||
| b74e139a85 | |||
| 488a7b534b | |||
| 73fe618953 | |||
| 95168253fc | |||
| b3222cf30b | |||
| 64c914019d | |||
| 7f74b660b3 | |||
| 59d143e4c8 | |||
| b218773ab0 | |||
| 84b7b6a7a9 | |||
| a326a8cbde | |||
| a59d4ad76c | |||
| b6408726bc | |||
| c96e71c83c | |||
| fa33e1acf1 | |||
| bc4fc97652 | |||
| 161dc406ed | |||
| a0e036fb6b | |||
| ecf4b434c2 | |||
| af7335f9e2 | |||
| ce3942990e | |||
| b050371dd5 | |||
| dcdf79afdc | |||
| ea9c2857a7 | |||
| 847302e297 | |||
| 5de6c8d052 | |||
| e8df71ea64 | |||
| ab4e88f17f | |||
| 801c0c1df2 | |||
| da290fa4f8 | |||
| 46304678da | |||
| 04af03980e | |||
| 5ca1be328c | |||
| 6267ff882c | |||
| 5ec7f35150 | |||
| abb7579227 | |||
| efed8352c3 | |||
| ac44122bf7 | |||
| 2c99b370a0 | |||
| ec21a9a2a0 | |||
| a6c01d73e2 | |||
| 86a15c0a65 | |||
| 5a9574fb95 | |||
| 73b2b2f6d7 | |||
| 467fdc34d8 | |||
| 866c73dcd4 | |||
| 7bed4b901a | |||
| c5d4849bd3 | |||
| e2c204b62b | |||
| 7079f6eed4 | |||
| f4386bc518 | |||
| 779598d962 | |||
| 6d9bf594ec | |||
| 215cfa29f3 | |||
| 8ba75b50e8 | |||
| 9eb81180c0 | |||
| 16d1b95e9a | |||
| 64c92c63e5 | |||
| 0d63fb1105 | |||
| 08d2a07d8b | |||
| 4303f06fc3 | |||
| 683aea0fbe | |||
| 970d0a5cb3 | |||
| cd6efeea90 | |||
| 2810306415 | |||
| 512153646a | |||
| d3194e3634 | |||
| b3f8850711 | |||
| eeca930cbd | |||
| 416a03b782 | |||
| 3fe3c4161b | |||
| 49f042a937 | |||
| 2cd43b6992 | |||
| 25a6022f7b | |||
| 55a05914d0 | |||
| d70bbbe739 | |||
| 9b0a80dcbd | |||
| 64ee316609 | |||
| deb58e1f17 | |||
| 826cfbee31 |
@@ -7,8 +7,7 @@ This project contains design documentation for a distributed SCADA system built
|
||||
- `README.md` — Master index with component table and architecture diagrams.
|
||||
- `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/lmxproxy_protocol.md` — LmxProxy gRPC protocol specification.
|
||||
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, LmxFakeProxy, 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.
|
||||
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
|
||||
- `infra/` — Docker Compose and config files for local test services.
|
||||
@@ -43,7 +42,7 @@ This project contains design documentation for a distributed SCADA system built
|
||||
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
|
||||
3. Site Runtime — Site-side actor hierarchy (Deployment Manager singleton, Instance/Script/Alarm Actors), script compilation, Akka stream.
|
||||
4. Data Connection Layer — Protocol abstraction (OPC UA, custom), subscription management, clean data pipe.
|
||||
5. Central–Site Communication — Akka.NET ClusterClient/ClusterClientReceptionist, message patterns, debug streaming.
|
||||
5. Central–Site Communication — Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data), message patterns, debug streaming.
|
||||
6. Store-and-Forward Engine — Buffering, fixed-interval retry, parking, SQLite persistence, replication.
|
||||
7. External System Gateway — External system definitions, API method invocation, database connections.
|
||||
8. Notification Service — Notification lists, email delivery, store-and-forward integration.
|
||||
@@ -81,7 +80,8 @@ This project contains design documentation for a distributed SCADA system built
|
||||
- Tag path resolution retried periodically for devices still booting.
|
||||
- Static attribute writes persisted to local SQLite (survive restart/failover, reset on redeployment).
|
||||
- All timestamps are UTC throughout the system.
|
||||
- Inter-cluster communication uses ClusterClient/ClusterClientReceptionist. Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist. Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only.
|
||||
- Inter-cluster communication uses two transports: ClusterClient for command/control (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots) and gRPC server-streaming for real-time data (attribute values, alarm states). Both CentralCommunicationActor and SiteCommunicationActor registered with receptionist. Central creates one ClusterClient per site using NodeA/NodeB as contact points. Sites configure multiple central contact points for failover. Addresses cached in CentralCommunicationActor, refreshed periodically (60s) and on admin changes. Heartbeats serve health monitoring only.
|
||||
- gRPC streaming channel: SiteStreamGrpcServer on each site node (Kestrel HTTP/2, port 8083); central creates per-site SiteStreamGrpcClient via SiteStreamGrpcClientFactory. Site entity has GrpcNodeAAddress/GrpcNodeBAddress fields. Proto: sitestream.proto with SiteStreamService, SiteStreamEvent (oneof: AttributeValueUpdate, AlarmStateUpdate). DebugStreamEvent message removed (no longer flows through ClusterClient).
|
||||
|
||||
### External Integrations
|
||||
- External System Gateway: HTTP/REST only, JSON serialization, API key + Basic Auth.
|
||||
@@ -126,7 +126,7 @@ This project contains design documentation for a distributed SCADA system built
|
||||
### UI & Monitoring
|
||||
- Central UI: Blazor Server (ASP.NET Core + SignalR) with Bootstrap CSS. No third-party component frameworks (no Blazorise, MudBlazor, Radzen, etc.). Build custom Blazor components for tables, grids, forms, etc.
|
||||
- UI design: Clean, corporate, internal-use aesthetic. Not flashy. Use the `frontend-design` skill when designing UI pages/components.
|
||||
- Debug view: real-time streaming via DebugStreamBridgeActor. Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR.
|
||||
- Debug view: real-time streaming via DebugStreamBridgeActor + gRPC (events via SiteStreamGrpcClient, snapshot via ClusterClient). Health dashboard: 10s polling timer. Deployment status: real-time push via SignalR.
|
||||
- Health reports: 30s interval, 60s offline threshold, monotonic sequence numbers, raw error counts per interval.
|
||||
- Dead letter monitoring as a health metric.
|
||||
- Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
||||
@@ -159,5 +159,5 @@ This project contains design documentation for a distributed SCADA system built
|
||||
- **Test user**: `--username multi-role --password password` — has Admin, Design, and Deployment roles. The `admin` user only has the Admin role and cannot create templates, data connections, or deploy.
|
||||
- **Config file**: `~/.scadalink/config.json` — stores `managementUrl` and default format. See `docker/README.md` for a ready-to-use test config.
|
||||
- **Rebuild cluster**: `bash docker/deploy.sh` — builds the `scadalink:latest` image and recreates all containers. Run this after code changes to ManagementActor, Host, or any server-side component.
|
||||
- **Infrastructure services**: `cd infra && docker compose up -d` — starts LDAP, MS SQL, OPC UA, SMTP, REST API, and LmxFakeProxy. These are separate from the cluster containers in `docker/`.
|
||||
- **Infrastructure services**: `cd infra && docker compose up -d` — starts LDAP, MS SQL, OPC UA, SMTP, and REST API. These are separate from the cluster containers in `docker/`.
|
||||
- **All test LDAP passwords**: `password` (see `infra/glauth/config.toml` for users and groups).
|
||||
|
||||
@@ -34,11 +34,11 @@ This document serves as the master index for the SCADA system design. The system
|
||||
|
||||
| # | Component | Document | Description |
|
||||
|---|-----------|----------|-------------|
|
||||
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, and diff calculation. |
|
||||
| 1 | Template Engine | [docs/requirements/Component-TemplateEngine.md](docs/requirements/Component-TemplateEngine.md) | Template modeling, inheritance, composition, path-qualified member addressing, override granularity, locking, alarms, flattening, semantic validation, revision hashing, diff calculation, and folder organization (nested folders, drag-drop). |
|
||||
| 2 | Deployment Manager | [docs/requirements/Component-DeploymentManager.md](docs/requirements/Component-DeploymentManager.md) | Central-side deployment pipeline with deployment ID/idempotency, per-instance operation lock, state transition matrix, all-or-nothing site apply, system-wide artifact deployment with per-site status. |
|
||||
| 3 | Site Runtime | [docs/requirements/Component-SiteRuntime.md](docs/requirements/Component-SiteRuntime.md) | Site-side actor hierarchy with explicit supervision strategies, staggered startup, script trust model (constrained APIs), Tell/Ask conventions, concurrency serialization, and site-wide Akka stream with per-subscriber backpressure. |
|
||||
| 4 | Data Connection Layer | [docs/requirements/Component-DataConnectionLayer.md](docs/requirements/Component-DataConnectionLayer.md) | Common data connection interface (OPC UA, custom), Become/Stash connection actor model, auto-reconnect, immediate bad quality on disconnect, transparent re-subscribe, synchronous write failures, tag path resolution retry. |
|
||||
| 5 | Central–Site Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Akka.NET remoting/cluster topology, 8 message patterns with per-pattern timeouts, application-level correlation IDs, transport heartbeat config, message ordering, connection failure behavior. |
|
||||
| 5 | Central–Site Communication | [docs/requirements/Component-Communication.md](docs/requirements/Component-Communication.md) | Dual transport: Akka.NET ClusterClient (command/control) + gRPC server-streaming (real-time data). 8 message patterns with per-pattern timeouts, SiteStreamGrpcServer/Client, application-level correlation IDs, transport heartbeat config, gRPC keepalive, message ordering, connection failure behavior. |
|
||||
| 6 | Store-and-Forward Engine | [docs/requirements/Component-StoreAndForward.md](docs/requirements/Component-StoreAndForward.md) | Buffering (transient failures only), fixed-interval retry, parking, async best-effort replication, SQLite persistence at sites. |
|
||||
| 7 | External System Gateway | [docs/requirements/Component-ExternalSystemGateway.md](docs/requirements/Component-ExternalSystemGateway.md) | HTTP/REST + JSON, API key/Basic Auth, per-system timeout, dual call modes (Call/CachedCall), transient/permanent error classification, dedicated blocking I/O dispatcher, ADO.NET connection pooling. |
|
||||
| 8 | Notification Service | [docs/requirements/Component-NotificationService.md](docs/requirements/Component-NotificationService.md) | SMTP with OAuth2 (M365) or Basic Auth, BCC delivery, plain text, transient/permanent SMTP error classification, store-and-forward integration. |
|
||||
@@ -90,6 +90,8 @@ This document serves as the master index for the SCADA system design. The system
|
||||
│ └──────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Akka.NET Communication Layer │ │
|
||||
│ │ ClusterClient: command/control │ │
|
||||
│ │ gRPC Client: real-time streams │ │
|
||||
│ │ (correlation IDs, per-pattern │ │
|
||||
│ │ timeouts, message ordering) │ │
|
||||
│ └──────────────┬────────────────────┘ │
|
||||
@@ -98,7 +100,8 @@ This document serves as the master index for the SCADA system design. The system
|
||||
│ └───────────────────────────────────┘ (Config DB)│
|
||||
│ │ Machine Data DB│
|
||||
└─────────────────┼───────────────────────────────────┘
|
||||
│ Akka.NET Remoting
|
||||
│ Akka.NET Remoting (command/control)
|
||||
│ gRPC HTTP/2 (real-time data, port 8083)
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
@@ -112,6 +115,9 @@ This document serves as the master index for the SCADA system design. The system
|
||||
│ │Site │ │ │ │Site │ │ │ │Site │ │
|
||||
│ │Runtm│ │ │ │Runtm│ │ │ │Runtm│ │
|
||||
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
|
||||
│ │gRPC │ │ │ │gRPC │ │ │ │gRPC │ │
|
||||
│ │Srvr │ │ │ │Srvr │ │ │ │Srvr │ │
|
||||
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
|
||||
│ │S&F │ │ │ │S&F │ │ │ │S&F │ │
|
||||
│ │Engine│ │ │ │Engine│ │ │ │Engine│ │
|
||||
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
|
||||
|
||||
@@ -41,5 +41,6 @@
|
||||
<Project Path="tests/ScadaLink.ManagementService.Tests/ScadaLink.ManagementService.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj" />
|
||||
<Project Path="tests/ScadaLink.PerformanceTests/ScadaLink.PerformanceTests.csproj" />
|
||||
<Project Path="tests/ScadaLink.CentralUI.PlaywrightTests/ScadaLink.CentralUI.PlaywrightTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# WinDev — Windows Development VM
|
||||
|
||||
Remote Windows 10 VM used for development and testing.
|
||||
|
||||
- **ESXi host**: See [esxi.md](/Users/dohertj2/Desktop/netfix/esxi.md) — VM name `WW_DEV_VM` on ESXi 8.0.3 at 10.2.0.12
|
||||
- **Backup**: See [veeam.md](/Users/dohertj2/Desktop/netfix/veeam.md) — Veeam B&R 12.3 at 10.100.0.30. Dedicated job "Backup WW_DEV_VM" targeting NAS repo. First restore point (2026-03-21) = **Baseline**: Win10 + .NET 10 SDK + .NET Fx 4.8 + Git + 7-Zip + Chrome + Claude Code + csharp-ls.
|
||||
|
||||
## Connection Details
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Hostname** | DESKTOP-6JL3KKO |
|
||||
| **IP** | 10.100.0.48 |
|
||||
| **OS** | Windows 10 Enterprise (10.0.19045), 64-bit |
|
||||
| **CPU** | Intel Xeon E5-2697 v4 @ 2.30GHz |
|
||||
| **RAM** | ~12 GB |
|
||||
| **Disk** | C: 235 GB free / 256 GB total |
|
||||
| **User** | `dohertj2` (local administrator) |
|
||||
| **SSH** | OpenSSH Server (passwordless via ed25519 key) |
|
||||
| **Default shell** | cmd.exe |
|
||||
|
||||
## SSH Access
|
||||
|
||||
Passwordless SSH is configured. An alias `windev` is set up in `~/.ssh/config`.
|
||||
|
||||
```bash
|
||||
# Connect
|
||||
ssh windev
|
||||
|
||||
# Run a command
|
||||
ssh windev "hostname"
|
||||
|
||||
# Run PowerShell
|
||||
ssh windev "powershell -Command \"Get-Process\""
|
||||
```
|
||||
|
||||
### SSH Config Entry (`~/.ssh/config`)
|
||||
|
||||
```
|
||||
Host windev
|
||||
HostName 10.100.0.48
|
||||
User dohertj2
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
### How Passwordless Auth Works
|
||||
|
||||
Since `dohertj2` is in the local Administrators group, Windows OpenSSH uses a special authorized keys file instead of the per-user `~/.ssh/authorized_keys`:
|
||||
|
||||
```
|
||||
C:\ProgramData\ssh\administrators_authorized_keys
|
||||
```
|
||||
|
||||
This is configured in `C:\ProgramData\ssh\sshd_config` via the `Match Group administrators` block. If you need to add another key, append it to that file and ensure ACLs are correct:
|
||||
|
||||
```powershell
|
||||
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
|
||||
```
|
||||
|
||||
## File Transfer
|
||||
|
||||
```bash
|
||||
# Copy file to Windows
|
||||
scp localfile.txt windev:C:/Users/dohertj2/Desktop/
|
||||
|
||||
# Copy file from Windows
|
||||
scp windev:C:/Users/dohertj2/Desktop/file.txt ./
|
||||
|
||||
# Copy directory recursively
|
||||
scp -r ./mydir windev:C:/Users/dohertj2/Desktop/mydir
|
||||
```
|
||||
|
||||
## Running Commands
|
||||
|
||||
The default shell is `cmd.exe`. For PowerShell, prefix commands explicitly.
|
||||
|
||||
```bash
|
||||
# cmd (default)
|
||||
ssh windev "dir C:\Users\dohertj2"
|
||||
|
||||
# PowerShell
|
||||
ssh windev "powershell -Command \"Get-Service | Where-Object { \$_.Status -eq 'Running' }\""
|
||||
|
||||
# Multi-line PowerShell script
|
||||
ssh windev "powershell -File C:\scripts\myscript.ps1"
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
```bash
|
||||
# List services
|
||||
ssh windev "sc query state= all"
|
||||
|
||||
# Start/stop a service
|
||||
ssh windev "sc stop ServiceName"
|
||||
ssh windev "sc start ServiceName"
|
||||
|
||||
# Check a specific service
|
||||
ssh windev "sc query ServiceName"
|
||||
```
|
||||
|
||||
### Process Management
|
||||
|
||||
```bash
|
||||
# List processes
|
||||
ssh windev "tasklist"
|
||||
|
||||
# Kill a process
|
||||
ssh windev "taskkill /F /PID 1234"
|
||||
ssh windev "taskkill /F /IM process.exe"
|
||||
```
|
||||
|
||||
## Installed Software
|
||||
|
||||
### Package Manager
|
||||
|
||||
| Tool | Version | Install Path |
|
||||
|------|---------|-------------|
|
||||
| **winget** | v1.28.190 | AppX package |
|
||||
|
||||
The `msstore` source has been removed (requires interactive agreement acceptance). Only the `winget` community source is configured. To install packages:
|
||||
|
||||
```bash
|
||||
ssh windev "winget install --id <PackageId> --silent --disable-interactivity"
|
||||
```
|
||||
|
||||
### Development Tools
|
||||
|
||||
| Tool | Version | Install Path |
|
||||
|------|---------|-------------|
|
||||
| **7-Zip** | 26.00 (x64) | `C:\Program Files\7-Zip\` |
|
||||
| **.NET Framework** | 4.8.1 (Developer Pack) | GAC / Reference Assemblies (v4.8.1 ref assemblies present) |
|
||||
| **.NET SDK** | 10.0.201 | `C:\Program Files\dotnet\` |
|
||||
| **.NET Runtime** | 10.0.5 (Core + ASP.NET + Desktop) | `C:\Program Files\dotnet\` |
|
||||
| **Git** | 2.53.0.2 | `C:\Program Files\Git\` |
|
||||
| **Claude Code** | 2.1.81 | `C:\Users\dohertj2\.local\bin\claude.exe` |
|
||||
|
||||
Launch with `cc` alias (cmd or Git Bash) which runs `claude --dangerously-skip-permissions --chrome`.
|
||||
|
||||
**C# LSP** — `csharp-ls` v0.22.0 installed as dotnet global tool (`C:\Users\dohertj2\.dotnet\tools\csharp-ls.exe`). Configured via the `csharp-lsp@claude-plugins-official` plugin. Provides `goToDefinition`, `findReferences`, `hover`, `documentSymbol`, `workspaceSymbol`, `goToImplementation`, and call hierarchy operations on `.cs` files. First invocation in a session is slow (~1-2 min) while the solution loads.
|
||||
|
||||
Git is configured with `credential.helper=store` (not GCM — the bundled Git Credential Manager was removed from system config to avoid OAuth/tty issues over SSH). Credentials are stored in `C:\Users\dohertj2\.git-credentials`.
|
||||
|
||||
**Gitea** (`gitea.dohertylan.com`) is pre-authenticated — no login prompts. Clone repos with:
|
||||
|
||||
```bash
|
||||
ssh windev "git clone https://gitea.dohertylan.com/dohertj2/<repo>.git C:\src\<repo>"
|
||||
```
|
||||
|
||||
### Applications
|
||||
|
||||
| App | Version | Default For |
|
||||
|-----|---------|-------------|
|
||||
| **Google Chrome** | 146.0.7680.154 | HTTP, HTTPS, .htm, .html, .pdf |
|
||||
| **Notepad++** | 8.9.2 | — |
|
||||
|
||||
Defaults set via Group Policy `DefaultAssociationsConfiguration` pointing to `C:\Windows\System32\DefaultAssociations.xml`.
|
||||
|
||||
### Not Installed
|
||||
|
||||
- **Git** — `winget install Git.Git`
|
||||
- **Python** — `winget install Python.Python.3.12`
|
||||
- **Visual Studio** — `winget install Microsoft.VisualStudio.2022.BuildTools`
|
||||
|
||||
## Network
|
||||
|
||||
Single network interface:
|
||||
|
||||
| Interface | IP |
|
||||
|-----------|-----|
|
||||
| Ethernet0 | 10.100.0.48 (static) |
|
||||
|
||||
## Backup (Veeam)
|
||||
|
||||
Veeam job "Backup WW_DEV_VM" on the Veeam server (10.100.0.30). Targets the NAS repo (`nfs41://10.50.0.25:/mnt/mypool/veeam`).
|
||||
|
||||
```bash
|
||||
# Incremental backup (changed blocks only)
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\""
|
||||
|
||||
# Full backup
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM') -FullBackup\""
|
||||
|
||||
# Check status
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\""
|
||||
|
||||
# List restore points
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\""
|
||||
```
|
||||
|
||||
### Restore Points
|
||||
|
||||
| ID | Date | Type | Notes |
|
||||
|----|------|------|-------|
|
||||
| `f2cd44a9` | 2026-03-21 14:28 | Full | **Baseline** — Win10 + .NET 10 SDK + .NET Fx 4.8 + Git + 7-Zip + Chrome + Claude Code + csharp-ls (old UUID) |
|
||||
| `2879a744` | 2026-03-21 15:15 | Increment | UUID fixed to `1BFC4D56-8DFA-A897-D1E4-BF1FD7F0096C`, static IP 10.100.0.48 |
|
||||
| `b4e87cfe` | 2026-03-21 16:43 | Increment | **Pre-licensing** — Notepad++ added, firewall/Defender disabled, licensing backups staged |
|
||||
| `f38a8aed` | 2026-03-21 17:01 | Increment | **Post-licensing** — WPS2020 licensing applied and verified working |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Permission denied" on SSH key auth
|
||||
|
||||
Windows OpenSSH is strict about file permissions on `administrators_authorized_keys`. Re-run:
|
||||
|
||||
```powershell
|
||||
icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"
|
||||
```
|
||||
|
||||
### Host key changed error
|
||||
|
||||
If the VM is rebuilt, clear the old key:
|
||||
|
||||
```bash
|
||||
ssh-keygen -R 10.100.0.48
|
||||
```
|
||||
|
||||
### Firewall blocking SSH
|
||||
|
||||
If the VM becomes unreachable, RDP in and check Windows Firewall or disable it:
|
||||
|
||||
```powershell
|
||||
Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False
|
||||
```
|
||||
+1
-1
@@ -22,7 +22,7 @@ COPY src/ScadaLink.InboundAPI/ScadaLink.InboundAPI.csproj src/ScadaLink.InboundA
|
||||
COPY src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj src/ScadaLink.ConfigurationDatabase/
|
||||
COPY src/ScadaLink.ManagementService/ScadaLink.ManagementService.csproj src/ScadaLink.ManagementService/
|
||||
|
||||
# Restore NuGet packages via Host project (follows ProjectReferences to all 17 dependencies)
|
||||
# Restore NuGet packages via Host project (follows ProjectReferences to all dependencies)
|
||||
# This layer is cached until any .csproj changes — source-only changes skip restore entirely
|
||||
RUN dotnet restore src/ScadaLink.Host/ScadaLink.Host.csproj
|
||||
|
||||
|
||||
+19
-14
@@ -29,7 +29,8 @@ Local Docker deployment of the full ScadaLink cluster topology: a 2-node central
|
||||
│ (Test Plant A) │ │ (Test Plant B) │ │ (Test Plant C) │
|
||||
│ │ │ │ │ │
|
||||
│ node-a ◄──► node-b│ │ node-a ◄──► node-b│ │ node-a ◄──► node-b│
|
||||
│ :9021 :9022 │ │ :9031 :9032 │ │ :9041 :9042 │
|
||||
│ Akka :9021 :9022 │ │ Akka :9031 :9032 │ │ Akka :9041 :9042 │
|
||||
│ gRPC :9023 :9024 │ │ gRPC :9033 :9034 │ │ gRPC :9043 :9044 │
|
||||
└────────────────────┘ └────────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
@@ -39,7 +40,7 @@ Runs the web UI (Blazor Server), Template Engine, Deployment Manager, Security,
|
||||
|
||||
### Site Clusters (active/standby each)
|
||||
|
||||
Each site cluster runs Site Runtime, Data Connection Layer, Store-and-Forward, and Site Event Logging. Sites connect to OPC UA for device data and to the central cluster via Akka.NET remoting. Deployed configurations and S&F buffers are stored in local SQLite databases per node.
|
||||
Each site cluster runs Site Runtime, Data Connection Layer, Store-and-Forward, and Site Event Logging. Sites connect to OPC UA for device data and to the central cluster via Akka.NET remoting. Each site node also hosts a gRPC streaming server (port 8083) that central nodes connect to for real-time attribute value and alarm state streams. Deployed configurations and S&F buffers are stored in local SQLite databases per node.
|
||||
|
||||
| Site Cluster | Site Identifier | Central UI Name |
|
||||
|-------------|-----------------|-----------------|
|
||||
@@ -51,19 +52,19 @@ Each site cluster runs Site Runtime, Data Connection Layer, Store-and-Forward, a
|
||||
|
||||
### Application Nodes
|
||||
|
||||
| Node | Container Name | Host Web Port | Host Akka Port | Internal Ports |
|
||||
|------|---------------|---------------|----------------|----------------|
|
||||
| Traefik LB | `scadalink-traefik` | 9000 | — | 80 (proxy), 8080 (dashboard) |
|
||||
| Central A | `scadalink-central-a` | 9001 | 9011 | 5000 (web), 8081 (Akka) |
|
||||
| Central B | `scadalink-central-b` | 9002 | 9012 | 5000 (web), 8081 (Akka) |
|
||||
| Site-A A | `scadalink-site-a-a` | — | 9021 | 8082 (Akka) |
|
||||
| Site-A B | `scadalink-site-a-b` | — | 9022 | 8082 (Akka) |
|
||||
| Site-B A | `scadalink-site-b-a` | — | 9031 | 8082 (Akka) |
|
||||
| Site-B B | `scadalink-site-b-b` | — | 9032 | 8082 (Akka) |
|
||||
| Site-C A | `scadalink-site-c-a` | — | 9041 | 8082 (Akka) |
|
||||
| Site-C B | `scadalink-site-c-b` | — | 9042 | 8082 (Akka) |
|
||||
| Node | Container Name | Host Web Port | Host Akka Port | Host gRPC Port | Internal Ports |
|
||||
|------|---------------|---------------|----------------|----------------|----------------|
|
||||
| Traefik LB | `scadalink-traefik` | 9000 | — | — | 80 (proxy), 8080 (dashboard) |
|
||||
| Central A | `scadalink-central-a` | 9001 | 9011 | — | 5000 (web), 8081 (Akka) |
|
||||
| Central B | `scadalink-central-b` | 9002 | 9012 | — | 5000 (web), 8081 (Akka) |
|
||||
| Site-A A | `scadalink-site-a-a` | — | 9021 | 9023 | 8082 (Akka), 8083 (gRPC) |
|
||||
| Site-A B | `scadalink-site-a-b` | — | 9022 | 9024 | 8082 (Akka), 8083 (gRPC) |
|
||||
| Site-B A | `scadalink-site-b-a` | — | 9031 | 9033 | 8082 (Akka), 8083 (gRPC) |
|
||||
| Site-B B | `scadalink-site-b-b` | — | 9032 | 9034 | 8082 (Akka), 8083 (gRPC) |
|
||||
| Site-C A | `scadalink-site-c-a` | — | 9041 | 9043 | 8082 (Akka), 8083 (gRPC) |
|
||||
| Site-C B | `scadalink-site-c-b` | — | 9042 | 9044 | 8082 (Akka), 8083 (gRPC) |
|
||||
|
||||
Port block pattern: `90X1`/`90X2` where X = 0 (central), 1 (web), 2 (site-a), 3 (site-b), 4 (site-c).
|
||||
Port block pattern: `90X1`/`90X2` (Akka), `90X3`/`90X4` (gRPC) where X = 0 (central), 2 (site-a), 3 (site-b), 4 (site-c). gRPC streaming ports are used by central nodes to subscribe to real-time site data streams.
|
||||
|
||||
### Infrastructure Services (from `infra/docker-compose.yml`)
|
||||
|
||||
@@ -85,6 +86,7 @@ docker/
|
||||
├── docker-compose.yml # 8-node application stack
|
||||
├── build.sh # Build Docker image
|
||||
├── deploy.sh # Build + deploy all containers
|
||||
├── seed-sites.sh # Create test sites with Akka + gRPC addresses
|
||||
├── teardown.sh # Stop and remove containers
|
||||
├── central-node-a/
|
||||
│ ├── appsettings.Central.json # Central node A configuration
|
||||
@@ -130,6 +132,9 @@ cd infra && docker compose up -d && cd ..
|
||||
|
||||
# 2. Build and deploy all 8 ScadaLink nodes
|
||||
docker/deploy.sh
|
||||
|
||||
# 3. Seed test sites (first-time only, after cluster is healthy)
|
||||
docker/seed-sites.sh
|
||||
```
|
||||
|
||||
### After Code Changes
|
||||
|
||||
@@ -26,4 +26,7 @@ echo " Active node check: http://localhost:9001/health/active"
|
||||
echo " Traefik dashboard: http://localhost:8180"
|
||||
echo " Management API: http://localhost:9000/management"
|
||||
echo ""
|
||||
echo "To seed test sites (first-time setup):"
|
||||
echo " docker/seed-sites.sh"
|
||||
echo ""
|
||||
echo "Logs: docker compose -f $SCRIPT_DIR/docker-compose.yml logs -f"
|
||||
|
||||
@@ -40,6 +40,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9021:8082" # Akka remoting (host access for debugging)
|
||||
- "9023:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-a-node-a/data:/app/data
|
||||
@@ -55,6 +56,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9022:8082" # Akka remoting
|
||||
- "9024:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-a-node-b/data:/app/data
|
||||
@@ -70,6 +72,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9031:8082" # Akka remoting
|
||||
- "9033:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-b-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-b-node-a/data:/app/data
|
||||
@@ -85,6 +88,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9032:8082" # Akka remoting
|
||||
- "9034:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-b-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-b-node-b/data:/app/data
|
||||
@@ -100,6 +104,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9041:8082" # Akka remoting
|
||||
- "9043:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-c-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-c-node-a/data:/app/data
|
||||
@@ -115,6 +120,7 @@ services:
|
||||
SCADALINK_CONFIG: Site
|
||||
ports:
|
||||
- "9042:8082" # Akka remoting
|
||||
- "9044:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-c-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./site-c-node-b/data:/app/data
|
||||
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Regenerates the gRPC C# files from sitestream.proto.
|
||||
#
|
||||
# Background: protoc (linux/arm64) segfaults inside our Docker build container
|
||||
# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs +
|
||||
# SitestreamGrpc.cs are checked into src/ScadaLink.Communication/SiteStreamGrpc/
|
||||
# and the Protobuf ItemGroup in the .csproj is commented out — Docker just
|
||||
# compiles the checked-in C# files.
|
||||
#
|
||||
# Run this script ON YOUR DEV MACHINE whenever Protos/sitestream.proto changes:
|
||||
#
|
||||
# 1. Temporarily uncomments the Protobuf ItemGroup so Grpc.Tools runs.
|
||||
# 2. dotnet build (regen writes fresh files to obj/).
|
||||
# 3. Copies the regenerated files back into SiteStreamGrpc/.
|
||||
# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe.
|
||||
#
|
||||
# Once we move to a Dockerfile base image that ships a working linux/arm64
|
||||
# protoc, this script can be retired and Docker can regen the proto on every
|
||||
# build like every other normal .NET project.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMM_DIR="$REPO_ROOT/src/ScadaLink.Communication"
|
||||
CSPROJ="$COMM_DIR/ScadaLink.Communication.csproj"
|
||||
GEN_DIR="$COMM_DIR/SiteStreamGrpc"
|
||||
|
||||
echo "=== Regenerating gRPC files from sitestream.proto ==="
|
||||
|
||||
if [[ ! -f "$CSPROJ" ]]; then
|
||||
echo "ERROR: csproj not found at $CSPROJ" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Backup so we can always restore the comment state on failure.
|
||||
BACKUP="$(mktemp)"
|
||||
cp "$CSPROJ" "$BACKUP"
|
||||
trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR
|
||||
|
||||
# 1. Uncomment the Protobuf ItemGroup (strip the surrounding <!-- ... --> wrapper).
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
# Find the commented Protobuf block and unwrap it.
|
||||
new = re.sub(
|
||||
r"<!--\s*\n(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)\s*\n\s*-->",
|
||||
r"\1",
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
if new == src:
|
||||
raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.")
|
||||
p.write_text(new)
|
||||
PY
|
||||
|
||||
# 2. Delete the stale files so any failure to regen is obvious.
|
||||
rm -f "$GEN_DIR/Sitestream.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
||||
|
||||
# 3. Regenerate by building.
|
||||
echo "Building Communication project (regen)..."
|
||||
dotnet build "$CSPROJ" --nologo -v minimal | tail -5
|
||||
|
||||
# 4. Copy generated files back into the source tree.
|
||||
mkdir -p "$GEN_DIR"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs"
|
||||
cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs"
|
||||
echo "Copied regenerated files to $GEN_DIR/"
|
||||
|
||||
# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working.
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
new = re.sub(
|
||||
r"(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)",
|
||||
r"\n <!--\1\n -->",
|
||||
src,
|
||||
count=1,
|
||||
)
|
||||
p.write_text(new)
|
||||
PY
|
||||
|
||||
rm -f "$BACKUP"
|
||||
trap - ERR
|
||||
|
||||
echo ""
|
||||
echo "Done. Review and commit:"
|
||||
echo " git diff src/ScadaLink.Communication/Protos/sitestream.proto"
|
||||
echo " git diff src/ScadaLink.Communication/SiteStreamGrpc/"
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Seed the three test sites with Akka and gRPC addresses.
|
||||
# Run after deploy.sh once the central cluster is healthy.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Infrastructure services running (infra/docker-compose up -d)
|
||||
# - Application containers running (docker/deploy.sh)
|
||||
# - Central cluster healthy (curl http://localhost:9000/health/ready)
|
||||
#
|
||||
# Usage:
|
||||
# docker/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:9000"
|
||||
|
||||
echo "=== Seeding ScadaLink Sites ==="
|
||||
|
||||
echo ""
|
||||
echo "Creating Site-A (Test Plant A)..."
|
||||
$CLI $URL $AUTH site create \
|
||||
--name "Test Plant A" \
|
||||
--identifier "site-a" \
|
||||
--description "Test site A - two-node cluster" \
|
||||
--node-a-address "akka.tcp://scadalink@scadalink-site-a-a:8082" \
|
||||
--node-b-address "akka.tcp://scadalink@scadalink-site-a-b:8082" \
|
||||
--grpc-node-a-address "http://scadalink-site-a-a:8083" \
|
||||
--grpc-node-b-address "http://scadalink-site-a-b:8083" \
|
||||
|| echo " (Site-A may already exist)"
|
||||
|
||||
echo ""
|
||||
echo "Creating Site-B (Test Plant B)..."
|
||||
$CLI $URL $AUTH site create \
|
||||
--name "Test Plant B" \
|
||||
--identifier "site-b" \
|
||||
--description "Test site B - two-node cluster" \
|
||||
--node-a-address "akka.tcp://scadalink@scadalink-site-b-a:8082" \
|
||||
--node-b-address "akka.tcp://scadalink@scadalink-site-b-b:8082" \
|
||||
--grpc-node-a-address "http://scadalink-site-b-a:8083" \
|
||||
--grpc-node-b-address "http://scadalink-site-b-b:8083" \
|
||||
|| echo " (Site-B may already exist)"
|
||||
|
||||
echo ""
|
||||
echo "Creating Site-C (Test Plant C)..."
|
||||
$CLI $URL $AUTH site create \
|
||||
--name "Test Plant C" \
|
||||
--identifier "site-c" \
|
||||
--description "Test site C - two-node cluster" \
|
||||
--node-a-address "akka.tcp://scadalink@scadalink-site-c-a:8082" \
|
||||
--node-b-address "akka.tcp://scadalink@scadalink-site-c-b:8082" \
|
||||
--grpc-node-a-address "http://scadalink-site-c-a:8083" \
|
||||
--grpc-node-b-address "http://scadalink-site-c-b:8083" \
|
||||
|| echo " (Site-C may already exist)"
|
||||
|
||||
echo ""
|
||||
echo "=== Site seeding complete ==="
|
||||
echo ""
|
||||
echo "Verify with: $CLI $URL $AUTH site list"
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-a-a",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-a-b",
|
||||
"SiteId": "site-a",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-b-a",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-b-b",
|
||||
"SiteId": "site-b",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-c-a",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"Role": "Site",
|
||||
"NodeHostname": "scadalink-site-c-b",
|
||||
"SiteId": "site-c",
|
||||
"RemotingPort": 8082
|
||||
"RemotingPort": 8082,
|
||||
"GrpcPort": 8083
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# LmxFakeProxy: OPC UA-Backed Test Proxy for LmxProxy Protocol
|
||||
|
||||
**Date:** 2026-03-19
|
||||
**Status:** Approved
|
||||
|
||||
## Purpose
|
||||
|
||||
Create a test-infrastructure gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the existing OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of the `RealLmxProxyClient` and the LmxProxy DCL adapter against real data without requiring a Windows-hosted LmxProxy deployment.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ gRPC (50051) ┌──────────────────┐ OPC UA (50000) ┌─────────────────┐
|
||||
│ RealLmxProxyClient │ ◄──────────────────────► │ LmxFakeProxy │ ◄───────────────────► │ OPC PLC Server │
|
||||
│ (ScadaLink DCL) │ scada.ScadaService │ (infra service) │ OPC Foundation SDK │ (Docker) │
|
||||
└─────────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
- Full proto parity: implements every RPC in `scada.proto`
|
||||
- Configurable OPC UA endpoint prefix (`--opc-prefix`, default `ns=3;s=`)
|
||||
- Optional API key enforcement (`--api-key`, default accept-all)
|
||||
- Full session tracking with validation
|
||||
- Native OPC UA MonitoredItems for subscription streaming
|
||||
- OPC UA reconnection with bad-quality push on disconnect
|
||||
- Runs as Docker service (port 50051) or standalone via `dotnet run`
|
||||
|
||||
## Tag Address Mapping
|
||||
|
||||
Configurable prefix prepend. Default maps LMX flat addresses to OPC PLC namespace 3:
|
||||
|
||||
| LMX Tag | OPC UA NodeId |
|
||||
|---------|--------------|
|
||||
| `Motor.Speed` | `ns=3;s=Motor.Speed` |
|
||||
| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` |
|
||||
| `Tank.HighLevel` | `ns=3;s=Tank.HighLevel` |
|
||||
|
||||
Mapping: `opcNodeId = $"{prefix}{lmxTag}"`
|
||||
|
||||
**Value conversions:**
|
||||
- OPC UA value → VtqMessage: `ToString()` for value, `DateTime.UtcNow.Ticks` for timestamp, StatusCode mapped to `"Good"` / `"Uncertain"` / `"Bad"`
|
||||
- Write value parsing (string → typed): attempt `double` → `bool` → `uint` → fall back to `string`
|
||||
- Quality mapping: StatusCode 0 = Good, high bit set = Bad, else Uncertain
|
||||
|
||||
## gRPC Service Implementation
|
||||
|
||||
### Connection Management
|
||||
- **Connect** — Validate API key (if configured), generate Guid session ID, store in `ConcurrentDictionary<string, SessionInfo>`. Return success + session ID.
|
||||
- **Disconnect** — Remove session. No-op for unknown sessions.
|
||||
- **GetConnectionState** — Look up session, return connection info. Return `is_connected=false` for unknown sessions.
|
||||
- **CheckApiKey** — Return `is_valid=true` if no key configured or key matches.
|
||||
|
||||
### Read Operations
|
||||
- **Read** — Validate session, map tag to OPC UA NodeId, read via OPC UA client, return VtqMessage.
|
||||
- **ReadBatch** — Same for multiple tags, sequential reads.
|
||||
|
||||
### Write Operations
|
||||
- **Write** — Validate session, parse string value to typed, write via OPC UA.
|
||||
- **WriteBatch** — Write each item, collect per-item results.
|
||||
- **WriteBatchAndWait** — Write all items, poll `flag_tag` at `poll_interval_ms` until match or timeout.
|
||||
|
||||
### Subscription
|
||||
- **Subscribe** — Validate session, create OPC UA MonitoredItems for each tag with `sampling_ms` as the OPC UA SamplingInterval. Stream VtqMessage on each data change notification. Stream stays open until client cancels. On cancellation, remove monitored items.
|
||||
|
||||
### Error Handling
|
||||
- Invalid session → `success=false`, `message="Invalid or expired session"`
|
||||
- OPC UA failure → `success=false` with status code in message
|
||||
- OPC UA disconnected → active streams get Bad quality push then close, RPCs return failure
|
||||
|
||||
## OPC UA Client Bridge
|
||||
|
||||
Single shared OPC UA session to the backend server, reused across all gRPC client sessions.
|
||||
|
||||
**`OpcUaBridge` class (behind `IOpcUaBridge` interface):**
|
||||
- `ConnectAsync()` — Establish OPC UA session (always `MessageSecurityMode.None`, auto-accept certs)
|
||||
- `ReadAsync(nodeId)` — Single node read
|
||||
- `WriteAsync(nodeId, value)` — Single node write
|
||||
- `AddMonitoredItems(nodeIds, samplingMs, callback)` — Add to shared subscription
|
||||
- `RemoveMonitoredItems(handles)` — Remove from shared subscription
|
||||
|
||||
**Reconnection:**
|
||||
- Detect disconnection via `Session.KeepAlive` event
|
||||
- On disconnect: set `_connected = false`, push Bad quality VtqMessage to all active subscription streams, close streams
|
||||
- Background reconnect loop at 5-second fixed interval
|
||||
- On reconnection: re-create subscription, re-add monitored items for still-active gRPC streams
|
||||
- RPCs while disconnected return `success=false, "OPC UA backend unavailable"`
|
||||
|
||||
**Single session rationale:** OPC PLC is local/lightweight, mirrors how real LmxProxy shares MXAccess, simpler lifecycle.
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
Accept-any by default, optional enforcement:
|
||||
- If `--api-key` is not set, all requests are accepted regardless of key
|
||||
- If `--api-key` is set, the `x-api-key` gRPC metadata header must match on every call
|
||||
- Validation happens in a gRPC interceptor (mirrors the real LmxProxy's `ApiKeyInterceptor`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
infra/lmxfakeproxy/
|
||||
├── LmxFakeProxy.csproj
|
||||
├── Program.cs # Host builder, CLI args / env vars, Kestrel on 50051
|
||||
├── Services/
|
||||
│ └── ScadaServiceImpl.cs # gRPC service implementation
|
||||
├── Bridge/
|
||||
│ └── OpcUaBridge.cs # IOpcUaBridge + implementation
|
||||
├── Sessions/
|
||||
│ └── SessionManager.cs # ConcurrentDictionary session tracking
|
||||
├── Protos/
|
||||
│ └── scada.proto # Copied from DCL (generates server stubs)
|
||||
├── Dockerfile # Multi-stage SDK → runtime
|
||||
├── README.md
|
||||
└── tests/
|
||||
└── LmxFakeProxy.Tests/
|
||||
├── LmxFakeProxy.Tests.csproj
|
||||
├── SessionManagerTests.cs
|
||||
├── TagMappingTests.cs
|
||||
└── ScadaServiceTests.cs
|
||||
```
|
||||
|
||||
**NuGet dependencies:**
|
||||
- `Grpc.AspNetCore` — gRPC server hosting
|
||||
- `OPCFoundation.NetStandard.Opc.Ua.Client` — OPC UA SDK
|
||||
- `Microsoft.Extensions.Hosting` — generic host
|
||||
- Tests: `xunit`, `NSubstitute`, `Grpc.Net.Client`
|
||||
|
||||
**CLI arguments / environment variables:**
|
||||
| Arg | Env Var | Default |
|
||||
|-----|---------|---------|
|
||||
| `--port` | `PORT` | `50051` |
|
||||
| `--opc-endpoint` | `OPC_ENDPOINT` | `opc.tcp://localhost:50000` |
|
||||
| `--opc-prefix` | `OPC_PREFIX` | `ns=3;s=` |
|
||||
| `--api-key` | `API_KEY` | *(none — accept all)* |
|
||||
|
||||
Env vars take precedence over CLI args.
|
||||
|
||||
## Docker & Infrastructure Integration
|
||||
|
||||
**docker-compose.yml addition:**
|
||||
```yaml
|
||||
lmxfakeproxy:
|
||||
build: ./lmxfakeproxy
|
||||
container_name: scadalink-lmxfakeproxy
|
||||
ports:
|
||||
- "50051:50051"
|
||||
environment:
|
||||
OPC_ENDPOINT: "opc.tcp://opcua:50000"
|
||||
OPC_PREFIX: "ns=3;s="
|
||||
depends_on:
|
||||
- opcua
|
||||
networks:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**Dockerfile (multi-stage):**
|
||||
```dockerfile
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
EXPOSE 50051
|
||||
ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"]
|
||||
```
|
||||
|
||||
**Documentation updates:**
|
||||
- `docs/test_infra/test_infra.md` — Add LmxFakeProxy to services table (6th service)
|
||||
- `infra/README.md` — Add to quick-start table
|
||||
- New `docs/test_infra/test_infra_lmxfakeproxy.md` — Dedicated per-service doc
|
||||
- `docs/requirements/Component-DataConnectionLayer.md` — Note fake proxy availability for LmxProxy testing
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### SessionManagerTests.cs
|
||||
- `Connect_ReturnsUniqueSessionId`
|
||||
- `Connect_WithValidApiKey_Succeeds`
|
||||
- `Connect_WithInvalidApiKey_Fails`
|
||||
- `Connect_WithNoKeyConfigured_AcceptsAnyKey`
|
||||
- `Disconnect_RemovesSession`
|
||||
- `Disconnect_UnknownSession_ReturnsFalse`
|
||||
- `ValidateSession_ValidId_ReturnsTrue`
|
||||
- `ValidateSession_InvalidId_ReturnsFalse`
|
||||
- `GetConnectionState_ReturnsCorrectInfo`
|
||||
- `GetConnectionState_UnknownSession_ReturnsNotConnected`
|
||||
|
||||
### TagMappingTests.cs
|
||||
- `ToOpcNodeId_PrependsPrefix`
|
||||
- `ToOpcNodeId_CustomPrefix`
|
||||
- `ToOpcNodeId_EmptyPrefix_PassesThrough`
|
||||
- `ConvertWriteValue_ParsesDouble`
|
||||
- `ConvertWriteValue_ParsesBool`
|
||||
- `ConvertWriteValue_ParsesUint`
|
||||
- `ConvertWriteValue_FallsBackToString`
|
||||
- `MapStatusCode_Good_ReturnsGood`
|
||||
- `MapStatusCode_Bad_ReturnsBad`
|
||||
- `MapStatusCode_Uncertain_ReturnsUncertain`
|
||||
- `ToVtqMessage_ConvertsCorrectly`
|
||||
|
||||
### ScadaServiceTests.cs (mocked IOpcUaBridge)
|
||||
- `Read_ValidSession_ReturnsVtq`
|
||||
- `Read_InvalidSession_ReturnsFailure`
|
||||
- `ReadBatch_ReturnsAllTags`
|
||||
- `Write_ValidSession_Succeeds`
|
||||
- `Write_InvalidSession_ReturnsFailure`
|
||||
- `WriteBatch_ReturnsPerItemResults`
|
||||
- `Subscribe_StreamsUpdatesUntilCancelled`
|
||||
- `Subscribe_InvalidSession_ThrowsRpcException`
|
||||
- `CheckApiKey_Valid_ReturnsTrue`
|
||||
- `CheckApiKey_Invalid_ReturnsFalse`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
cd infra/lmxfakeproxy
|
||||
dotnet test tests/LmxFakeProxy.Tests/
|
||||
|
||||
# Docker build
|
||||
cd infra
|
||||
docker compose build lmxfakeproxy
|
||||
docker compose up -d lmxfakeproxy
|
||||
|
||||
# Integration smoke test (using RealLmxProxyClient from ScadaLink)
|
||||
# Connect, read Motor.Speed, write Motor.Speed=42.0, read back, subscribe
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-19-lmxfakeproxy-implementation.md",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeId": "3", "subject": "Task 1: Project Scaffolding", "status": "pending"},
|
||||
{"id": 2, "nativeId": "4", "subject": "Task 2: TagMapper Utility + Tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": "5", "subject": "Task 3: SessionManager + Tests", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "nativeId": "6", "subject": "Task 4: IOpcUaBridge + OpcUaBridge Implementation", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 5, "nativeId": "7", "subject": "Task 5: ScadaServiceImpl + Tests", "status": "pending", "blockedBy": [2, 3, 4]},
|
||||
{"id": 6, "nativeId": "8", "subject": "Task 6: Program.cs Host Builder", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "nativeId": "9", "subject": "Task 7: Dockerfile + Docker Compose", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": "10", "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 9, "nativeId": "11", "subject": "Task 9: Integration Smoke Test", "status": "pending", "blockedBy": [5, 7]},
|
||||
{"id": 10, "nativeId": "12", "subject": "Task 10: End-to-End Verification", "status": "pending", "blockedBy": [7, 8, 9]}
|
||||
],
|
||||
"lastUpdated": "2026-03-19T00:00:00Z"
|
||||
}
|
||||
@@ -92,7 +92,7 @@ Also add `<FrameworkReference Include="Microsoft.AspNetCore.App" />` if not alre
|
||||
|
||||
**Step 3: Generate C# stubs**
|
||||
|
||||
Run `protoc` locally to generate stubs. Check generated files into `src/ScadaLink.Communication/SiteStreamGrpc/`. Follow the same pattern as `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyGrpc/` — pre-generated, no protoc at build time.
|
||||
Run `protoc` locally to generate stubs. Check generated files into `src/ScadaLink.Communication/SiteStreamGrpc/` — pre-generated and checked in, no `protoc` at build time.
|
||||
|
||||
**Step 4: Verify build**
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Primary/Backup Data Connection Endpoints — Design
|
||||
|
||||
**Date:** 2026-03-22
|
||||
**Status:** Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Data connections currently support a single endpoint. If that endpoint goes down, the connection retries indefinitely at 5s intervals against the same address. When redundant infrastructure exists (e.g., two OPC UA servers), there is no way to automatically fail over to a backup.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Failover mode | Automatic after N failed retries |
|
||||
| Failback | No auto-failback; stay on active until it fails (round-robin) |
|
||||
| Backup required? | Optional — single-endpoint connections work unchanged |
|
||||
| Failover trigger | After configurable retry count (default 3) |
|
||||
| Entity model | Separate `PrimaryConfiguration` and `BackupConfiguration` columns |
|
||||
| UI approach | Two JSON text areas; backup collapsible |
|
||||
| Failover logic location | DataConnectionActor (adapters stay single-endpoint) |
|
||||
| Observability | Health reports + site event log entries |
|
||||
|
||||
## Entity Model
|
||||
|
||||
**`DataConnection` changes:**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `PrimaryConfiguration` | string? (max 4000) | Renamed from `Configuration` |
|
||||
| `BackupConfiguration` | string? (max 4000) | New. Null = no backup |
|
||||
| `FailoverRetryCount` | int (default 3) | New. Retries before switching |
|
||||
|
||||
Both endpoints use the same `Protocol`. EF Core migration renames `Configuration` → `PrimaryConfiguration` (data-preserving).
|
||||
|
||||
**`DataConnectionArtifact` changes:**
|
||||
- `ConfigurationJson` → `PrimaryConfigurationJson` + `BackupConfigurationJson`
|
||||
|
||||
## Failover State Machine
|
||||
|
||||
The `DataConnectionActor` Reconnecting state is extended:
|
||||
|
||||
```
|
||||
Connected
|
||||
│ disconnect detected
|
||||
▼
|
||||
Push bad quality to all subscribers
|
||||
│
|
||||
▼
|
||||
Retry active endpoint (5s interval)
|
||||
│ failure
|
||||
▼
|
||||
_consecutiveFailures++
|
||||
│
|
||||
├─ < FailoverRetryCount → retry same endpoint
|
||||
│
|
||||
├─ ≥ FailoverRetryCount AND backup exists
|
||||
│ → dispose adapter, switch _activeEndpoint, reset counter
|
||||
│ → create fresh adapter with other config
|
||||
│ → attempt connect
|
||||
│
|
||||
└─ ≥ FailoverRetryCount AND no backup
|
||||
→ keep retrying indefinitely (current behavior)
|
||||
```
|
||||
|
||||
**On successful reconnect (either endpoint):**
|
||||
1. Reset `_consecutiveFailures = 0`
|
||||
2. `ReSubscribeAll()` — re-create all subscriptions on the new adapter
|
||||
3. Transition to Connected
|
||||
4. Log failover event if endpoint changed
|
||||
5. Report active endpoint in health metrics
|
||||
|
||||
**Round-robin on failure:** primary → backup → primary → backup...
|
||||
|
||||
**Adapter lifecycle on failover:** Actor disposes current `IDataConnection` adapter and creates a fresh one via `DataConnectionFactory.Create()` with the other endpoint's config. Clean slate — no stale state.
|
||||
|
||||
## Actor State
|
||||
|
||||
New fields in `DataConnectionActor`:
|
||||
|
||||
- `IDictionary<string, string> _primaryConfig`
|
||||
- `IDictionary<string, string>? _backupConfig`
|
||||
- `ActiveEndpoint _activeEndpoint` (enum: Primary, Backup)
|
||||
- `int _consecutiveFailures`
|
||||
- `int _failoverRetryCount`
|
||||
|
||||
`CreateConnectionCommand` gains: `primaryConfig`, `backupConfig`, `failoverRetryCount`.
|
||||
|
||||
`DataConnectionFactory` is unchanged — still creates single-endpoint adapters.
|
||||
|
||||
## Health & Observability
|
||||
|
||||
**`DataConnectionHealthReport`** gains:
|
||||
- `ActiveEndpoint` (string): `"Primary"`, `"Backup"`, or `"Primary (no backup)"`
|
||||
|
||||
**Site event log entries:**
|
||||
- `DataConnectionFailover` — connection name, from-endpoint, to-endpoint, reason
|
||||
- `DataConnectionRestored` — connection name, active endpoint
|
||||
|
||||
Uses existing `ISiteEventLogger`.
|
||||
|
||||
## Central UI
|
||||
|
||||
**List page:** Add `Active Endpoint` column from health reports.
|
||||
|
||||
**Form (Create/Edit):**
|
||||
- "Primary Endpoint Configuration" label (renamed from "Configuration")
|
||||
- "Add Backup Endpoint" button reveals second JSON text area
|
||||
- "Remove Backup" button in edit mode when backup exists
|
||||
- "Failover Retry Count" numeric input (default 3, min 1, max 20) — visible only when backup configured
|
||||
- Vertical stacking, collapsible backup subsection
|
||||
|
||||
## CLI
|
||||
|
||||
- `--configuration` renamed to `--primary-config` (hidden alias for backwards compat)
|
||||
- `--backup-config` (optional)
|
||||
- `--failover-retry-count` (optional, default 3)
|
||||
- `data-connection get` shows both configs and active endpoint
|
||||
|
||||
## Management API
|
||||
|
||||
- `CreateDataConnectionCommand` / `UpdateDataConnectionCommand` gain `PrimaryConfiguration`, `BackupConfiguration`, `FailoverRetryCount`
|
||||
- Setting `BackupConfiguration` to null removes the backup
|
||||
- `GetDataConnectionResponse` returns both configs
|
||||
|
||||
## Deployment Flow
|
||||
|
||||
`DataConnectionArtifact` carries `PrimaryConfigurationJson` and `BackupConfigurationJson`. Site-side deployment handler passes both to `CreateConnectionCommand`.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit tests:**
|
||||
- Actor: failover after N failures, round-robin, single-endpoint retries forever, counter reset, ReSubscribeAll on failover
|
||||
- Manager actor: updated CreateConnectionCommand
|
||||
- Factory: unchanged registration
|
||||
|
||||
**Integration test (manual with test infra):**
|
||||
1. Primary=`opc.tcp://localhost:50000`, backup=`opc.tcp://localhost:50010`
|
||||
2. Subscribe to `Motor.Speed`
|
||||
3. `docker compose stop opcua` → verify failover to opcua2 after 3 retries
|
||||
4. `docker compose stop opcua2 && docker compose start opcua` → verify round-robin back
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
1. **#4** Entity model & database (foundation)
|
||||
2. **#6** CreateConnectionCommand & DataConnectionManagerActor (blocked by #4)
|
||||
3. **#5** DataConnectionActor failover state machine (blocked by #4, #6)
|
||||
4. **#7** Health reporting & site event log (blocked by #5)
|
||||
5. **#8** Central UI (blocked by #4)
|
||||
6. **#9** CLI, Management API, deployment (blocked by #4)
|
||||
7. **#10** Documentation (blocked by #5)
|
||||
8. **#11** Tests (blocked by #5)
|
||||
@@ -0,0 +1,695 @@
|
||||
# Primary/Backup Data Connection Endpoints — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add optional backup endpoints to data connections with automatic failover after configurable retry count.
|
||||
|
||||
**Architecture:** The `DataConnectionActor` gains failover logic in its Reconnecting state — after N failed retries on the active endpoint, it disposes the adapter and creates a fresh one with the other endpoint's config. Adapters remain single-endpoint. Entity model splits `Configuration` into `PrimaryConfiguration` + `BackupConfiguration`.
|
||||
|
||||
**Tech Stack:** C# / .NET 10, Akka.NET, EF Core, Blazor Server, System.CommandLine
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-22-primary-backup-data-connections-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Entity Model & Database Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Entities/Sites/DataConnection.cs`
|
||||
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/SiteConfiguration.cs` (lines 32-56)
|
||||
- Modify: `src/ScadaLink.Commons/Messages/Artifacts/DataConnectionArtifact.cs`
|
||||
|
||||
### Step 1: Update DataConnection entity
|
||||
|
||||
In `DataConnection.cs`, rename `Configuration` to `PrimaryConfiguration`, add `BackupConfiguration` and `FailoverRetryCount`:
|
||||
|
||||
```csharp
|
||||
public class DataConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SiteId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Protocol { get; set; }
|
||||
public string? PrimaryConfiguration { get; set; }
|
||||
public string? BackupConfiguration { get; set; }
|
||||
public int FailoverRetryCount { get; set; } = 3;
|
||||
|
||||
public DataConnection(int siteId, string name, string protocol)
|
||||
{
|
||||
SiteId = siteId;
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Protocol = protocol ?? throw new ArgumentNullException(nameof(protocol));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Update EF Core mapping
|
||||
|
||||
In `SiteConfiguration.cs`, update the DataConnection mapping (around lines 46-47):
|
||||
|
||||
- Rename `Configuration` property mapping to `PrimaryConfiguration` (MaxLength 4000)
|
||||
- Add `BackupConfiguration` property (optional, MaxLength 4000)
|
||||
- Add `FailoverRetryCount` property (required, default 3)
|
||||
|
||||
```csharp
|
||||
builder.Property(d => d.PrimaryConfiguration).HasMaxLength(4000);
|
||||
builder.Property(d => d.BackupConfiguration).HasMaxLength(4000);
|
||||
builder.Property(d => d.FailoverRetryCount).HasDefaultValue(3);
|
||||
```
|
||||
|
||||
### Step 3: Create EF Core migration
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd src/ScadaLink.ConfigurationDatabase
|
||||
dotnet ef migrations add AddDataConnectionBackupEndpoint \
|
||||
--startup-project ../ScadaLink.Host
|
||||
```
|
||||
|
||||
Verify the migration renames `Configuration` → `PrimaryConfiguration` (should use `RenameColumn`, not drop+add). If the scaffolded migration drops and recreates, manually fix it:
|
||||
|
||||
```csharp
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "Configuration",
|
||||
table: "DataConnections",
|
||||
newName: "PrimaryConfiguration");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BackupConfiguration",
|
||||
table: "DataConnections",
|
||||
maxLength: 4000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FailoverRetryCount",
|
||||
table: "DataConnections",
|
||||
nullable: false,
|
||||
defaultValue: 3);
|
||||
```
|
||||
|
||||
### Step 4: Update DataConnectionArtifact
|
||||
|
||||
In `DataConnectionArtifact.cs`, replace single `ConfigurationJson` with both:
|
||||
|
||||
```csharp
|
||||
public record DataConnectionArtifact(
|
||||
string Name,
|
||||
string Protocol,
|
||||
string? PrimaryConfigurationJson,
|
||||
string? BackupConfigurationJson,
|
||||
int FailoverRetryCount = 3);
|
||||
```
|
||||
|
||||
### Step 5: Build and fix compile errors
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx`
|
||||
|
||||
This will surface all references to the old `Configuration` and `ConfigurationJson` fields across the codebase. Fix each one — this includes:
|
||||
- ManagementActor handlers
|
||||
- CLI commands
|
||||
- UI pages
|
||||
- Deployment/flattening code
|
||||
- Tests
|
||||
|
||||
Fix only the field name renames in this step (use `PrimaryConfiguration` where `Configuration` was). Don't add backup logic yet — just make it compile.
|
||||
|
||||
### Step 6: Run tests, fix failures
|
||||
|
||||
Run: `dotnet test ScadaLink.slnx`
|
||||
|
||||
Fix any test failures caused by the rename.
|
||||
|
||||
### Step 7: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(dcl): rename Configuration to PrimaryConfiguration, add BackupConfiguration and FailoverRetryCount"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update CreateConnectionCommand & Manager Actor
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Messages/DataConnection/CreateConnectionCommand.cs`
|
||||
- Modify: `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionManagerActor.cs` (lines 39-62)
|
||||
|
||||
### Step 1: Update CreateConnectionCommand message
|
||||
|
||||
```csharp
|
||||
public record CreateConnectionCommand(
|
||||
string ConnectionName,
|
||||
string ProtocolType,
|
||||
IDictionary<string, string> PrimaryConnectionDetails,
|
||||
IDictionary<string, string>? BackupConnectionDetails = null,
|
||||
int FailoverRetryCount = 3);
|
||||
```
|
||||
|
||||
### Step 2: Update DataConnectionManagerActor.HandleCreateConnection
|
||||
|
||||
Update the handler (around line 39-62) to pass both configs to DataConnectionActor:
|
||||
|
||||
```csharp
|
||||
private void HandleCreateConnection(CreateConnectionCommand command)
|
||||
{
|
||||
if (_connectionActors.ContainsKey(command.ConnectionName))
|
||||
{
|
||||
_log.Warning("Connection {0} already exists", command.ConnectionName);
|
||||
return;
|
||||
}
|
||||
|
||||
var adapter = _factory.Create(command.ProtocolType, command.PrimaryConnectionDetails);
|
||||
|
||||
var props = Props.Create(() => new DataConnectionActor(
|
||||
command.ConnectionName,
|
||||
adapter,
|
||||
_options,
|
||||
_healthCollector,
|
||||
command.ProtocolType,
|
||||
command.PrimaryConnectionDetails,
|
||||
command.BackupConnectionDetails,
|
||||
command.FailoverRetryCount));
|
||||
|
||||
var actorName = new string(command.ConnectionName
|
||||
.Select(c => char.IsLetterOrDigit(c) || "-_.*$+:@&=,!~';()".Contains(c) ? c : '-')
|
||||
.ToArray());
|
||||
var actorRef = Context.ActorOf(props, actorName);
|
||||
_connectionActors[command.ConnectionName] = actorRef;
|
||||
|
||||
_log.Info("Created DataConnectionActor for {0} (protocol={1}, backup={2})",
|
||||
command.ConnectionName, command.ProtocolType, command.BackupConnectionDetails != null ? "yes" : "none");
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update all callers of CreateConnectionCommand
|
||||
|
||||
Search for all places that construct `CreateConnectionCommand` and update them to use the new signature. The primary caller is the site-side deployment handler.
|
||||
|
||||
### Step 4: Build and test
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx && dotnet test tests/ScadaLink.DataConnectionLayer.Tests`
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(dcl): extend CreateConnectionCommand with backup config and failover retry count"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: DataConnectionActor Failover State Machine
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs`
|
||||
- Modify: `src/ScadaLink.DataConnectionLayer/DataConnectionFactory.cs`
|
||||
|
||||
This is the core change. The actor gains failover logic in its Reconnecting state.
|
||||
|
||||
### Step 1: Add new state fields to DataConnectionActor
|
||||
|
||||
Add these fields alongside the existing ones (around line 30):
|
||||
|
||||
```csharp
|
||||
private readonly string _protocolType;
|
||||
private readonly IDictionary<string, string> _primaryConfig;
|
||||
private readonly IDictionary<string, string>? _backupConfig;
|
||||
private readonly int _failoverRetryCount;
|
||||
private readonly IDataConnectionFactory _factory;
|
||||
private ActiveEndpoint _activeEndpoint = ActiveEndpoint.Primary;
|
||||
private int _consecutiveFailures;
|
||||
|
||||
public enum ActiveEndpoint { Primary, Backup }
|
||||
```
|
||||
|
||||
### Step 2: Update constructor
|
||||
|
||||
Extend the constructor to accept both configs and the factory:
|
||||
|
||||
```csharp
|
||||
public DataConnectionActor(
|
||||
string connectionName,
|
||||
IDataConnection adapter,
|
||||
DataConnectionOptions options,
|
||||
ISiteHealthCollector healthCollector,
|
||||
string protocolType,
|
||||
IDictionary<string, string> primaryConfig,
|
||||
IDictionary<string, string>? backupConfig = null,
|
||||
int failoverRetryCount = 3)
|
||||
{
|
||||
_connectionName = connectionName;
|
||||
_adapter = adapter;
|
||||
_options = options;
|
||||
_healthCollector = healthCollector;
|
||||
_protocolType = protocolType;
|
||||
_primaryConfig = primaryConfig;
|
||||
_backupConfig = backupConfig;
|
||||
_failoverRetryCount = failoverRetryCount;
|
||||
_connectionDetails = primaryConfig; // start with primary
|
||||
}
|
||||
```
|
||||
|
||||
Note: The actor also needs `IDataConnectionFactory` injected to create new adapters on failover. Pass it through the constructor or resolve via DI. The `DataConnectionManagerActor` already has the factory — pass it through to the actor constructor.
|
||||
|
||||
### Step 3: Extend HandleReconnectResult with failover logic
|
||||
|
||||
Replace the reconnect failure handling (around lines 279-296) to include failover:
|
||||
|
||||
```csharp
|
||||
private void HandleReconnectResult(ConnectResult result)
|
||||
{
|
||||
if (result.Success)
|
||||
{
|
||||
_consecutiveFailures = 0;
|
||||
_log.Info("Reconnected {0} on {1} endpoint", _connectionName, _activeEndpoint);
|
||||
ReSubscribeAll();
|
||||
BecomeConnected();
|
||||
return;
|
||||
}
|
||||
|
||||
_consecutiveFailures++;
|
||||
_log.Warning("Reconnect attempt {0}/{1} failed for {2} on {3}: {4}",
|
||||
_consecutiveFailures, _failoverRetryCount, _connectionName, _activeEndpoint, result.Error);
|
||||
|
||||
if (_consecutiveFailures >= _failoverRetryCount && _backupConfig != null)
|
||||
{
|
||||
// Switch endpoint
|
||||
var previousEndpoint = _activeEndpoint;
|
||||
_activeEndpoint = _activeEndpoint == ActiveEndpoint.Primary
|
||||
? ActiveEndpoint.Backup
|
||||
: ActiveEndpoint.Primary;
|
||||
_consecutiveFailures = 0;
|
||||
|
||||
var newConfig = _activeEndpoint == ActiveEndpoint.Primary ? _primaryConfig : _backupConfig;
|
||||
|
||||
_log.Warning("Failing over {0} from {1} to {2}", _connectionName, previousEndpoint, _activeEndpoint);
|
||||
|
||||
// Dispose old adapter, create new one
|
||||
_ = _adapter.DisposeAsync();
|
||||
_adapter = _factory.Create(_protocolType, newConfig);
|
||||
_connectionDetails = newConfig;
|
||||
|
||||
// Wire up disconnect handler on new adapter
|
||||
_adapter.Disconnected += () => _self.Tell(new AdapterDisconnected());
|
||||
}
|
||||
|
||||
// Schedule next retry
|
||||
Context.System.Scheduler.ScheduleTellOnce(
|
||||
_options.ReconnectInterval, Self, AttemptConnect.Instance, ActorRefs.NoSender);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Pass IDataConnectionFactory to DataConnectionActor
|
||||
|
||||
Update `DataConnectionManagerActor.HandleCreateConnection` to pass the factory:
|
||||
|
||||
```csharp
|
||||
var props = Props.Create(() => new DataConnectionActor(
|
||||
command.ConnectionName, adapter, _options, _healthCollector,
|
||||
_factory, // pass factory for failover adapter creation
|
||||
command.ProtocolType, command.PrimaryConnectionDetails,
|
||||
command.BackupConnectionDetails, command.FailoverRetryCount));
|
||||
```
|
||||
|
||||
And update the DataConnectionActor constructor to store `_factory`.
|
||||
|
||||
### Step 5: Build and run existing tests
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx && dotnet test tests/ScadaLink.DataConnectionLayer.Tests`
|
||||
|
||||
Existing tests must pass (they use single-endpoint configs, so no failover triggered).
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(dcl): add failover state machine to DataConnectionActor with round-robin endpoint switching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Failover Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ScadaLink.DataConnectionLayer.Tests/DataConnectionActorTests.cs`
|
||||
|
||||
### Step 1: Write test — failover after N retries
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Reconnecting_AfterFailoverRetryCount_SwitchesToBackup()
|
||||
{
|
||||
// Arrange: create actor with primary + backup, failoverRetryCount = 2
|
||||
var primaryAdapter = Substitute.For<IDataConnection>();
|
||||
var backupAdapter = Substitute.For<IDataConnection>();
|
||||
var factory = Substitute.For<IDataConnectionFactory>();
|
||||
factory.Create("OpcUa", Arg.Is<IDictionary<string, string>>(d => d["endpoint"] == "backup"))
|
||||
.Returns(backupAdapter);
|
||||
|
||||
// Primary connects then disconnects
|
||||
primaryAdapter.ConnectAsync(Arg.Any<IDictionary<string, string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
primaryAdapter.Status.Returns(ConnectionHealth.Connected);
|
||||
|
||||
var primaryConfig = new Dictionary<string, string> { ["endpoint"] = "primary" };
|
||||
var backupConfig = new Dictionary<string, string> { ["endpoint"] = "backup" };
|
||||
|
||||
// Create actor, connect on primary
|
||||
// ... (use test kit patterns from existing tests)
|
||||
// Simulate disconnect, verify 2 failures then factory.Create called with backup config
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Write test — single endpoint retries forever
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Reconnecting_NoBackup_RetriesIndefinitely()
|
||||
{
|
||||
// Arrange: create actor with primary only, no backup
|
||||
// Simulate 10 reconnect failures
|
||||
// Verify: factory.Create never called with backup, just keeps retrying
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Write test — round-robin back to primary after backup fails
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Reconnecting_BackupFails_SwitchesBackToPrimary()
|
||||
{
|
||||
// Arrange: primary + backup, failoverRetryCount = 1
|
||||
// Simulate: primary fails 1x → switch to backup → backup fails 1x → switch to primary
|
||||
// Verify: round-robin pattern
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Write test — successful reconnect resets counter
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Reconnecting_SuccessfulConnect_ResetsConsecutiveFailures()
|
||||
{
|
||||
// Arrange: failoverRetryCount = 3
|
||||
// Simulate: 2 failures on primary, then success
|
||||
// Verify: no failover, counter reset
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Write test — ReSubscribeAll called after failover
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Failover_ReSubscribesAllTagsOnNewAdapter()
|
||||
{
|
||||
// Arrange: actor with subscriptions, then failover
|
||||
// Verify: new adapter receives SubscribeAsync calls for all previously subscribed tags
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Run all tests
|
||||
|
||||
Run: `dotnet test tests/ScadaLink.DataConnectionLayer.Tests -v`
|
||||
|
||||
### Step 7: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test(dcl): add failover state machine tests for DataConnectionActor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Health Reporting & Site Event Logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Messages/DataConnection/DataConnectionHealthReport.cs`
|
||||
- Modify: `src/ScadaLink.DataConnectionLayer/Actors/DataConnectionActor.cs` (ReplyWithHealthReport, HandleReconnectResult)
|
||||
|
||||
### Step 1: Add ActiveEndpoint to health report
|
||||
|
||||
```csharp
|
||||
public record DataConnectionHealthReport(
|
||||
string ConnectionName,
|
||||
ConnectionHealth Status,
|
||||
int TotalSubscribedTags,
|
||||
int ResolvedTags,
|
||||
string ActiveEndpoint,
|
||||
DateTimeOffset Timestamp);
|
||||
```
|
||||
|
||||
### Step 2: Update ReplyWithHealthReport in DataConnectionActor
|
||||
|
||||
Update the health report method (around line 516) to include the active endpoint:
|
||||
|
||||
```csharp
|
||||
private void ReplyWithHealthReport()
|
||||
{
|
||||
var endpointLabel = _backupConfig == null
|
||||
? "Primary (no backup)"
|
||||
: _activeEndpoint.ToString();
|
||||
|
||||
Sender.Tell(new DataConnectionHealthReport(
|
||||
_connectionName, _adapter.Status,
|
||||
_subscriptionsByInstance.Values.Sum(s => s.Count),
|
||||
_resolvedTags,
|
||||
endpointLabel,
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add site event logging on failover
|
||||
|
||||
In `HandleReconnectResult`, after switching endpoints, log a site event:
|
||||
|
||||
```csharp
|
||||
if (_siteEventLogger != null)
|
||||
{
|
||||
_ = _siteEventLogger.LogEventAsync(
|
||||
"connection", "Warning", null, _connectionName,
|
||||
$"Failover from {previousEndpoint} to {_activeEndpoint}",
|
||||
$"After {_failoverRetryCount} consecutive failures");
|
||||
}
|
||||
```
|
||||
|
||||
Note: The actor needs `ISiteEventLogger` injected. Add it as an optional constructor parameter.
|
||||
|
||||
### Step 4: Add site event logging on successful reconnect after failover
|
||||
|
||||
In `HandleReconnectResult` success path, if the endpoint changed from last known good:
|
||||
|
||||
```csharp
|
||||
if (_siteEventLogger != null)
|
||||
{
|
||||
_ = _siteEventLogger.LogEventAsync(
|
||||
"connection", "Info", null, _connectionName,
|
||||
$"Connection restored on {_activeEndpoint} endpoint", null);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Build and test
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx && dotnet test tests/ScadaLink.DataConnectionLayer.Tests`
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(dcl): add active endpoint to health reports and log failover events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Central UI Changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor`
|
||||
|
||||
### Step 1: Update DataConnections list page
|
||||
|
||||
Add `Active Endpoint` column to the table (around line 28-64). Insert after the Protocol column:
|
||||
|
||||
```html
|
||||
<th>Active Endpoint</th>
|
||||
```
|
||||
|
||||
And in the row template:
|
||||
|
||||
```html
|
||||
<td>@connection.ActiveEndpoint</td>
|
||||
```
|
||||
|
||||
This requires the list page to fetch health data alongside the connection list. Add a health status lookup or include `ActiveEndpoint` in the data connection response.
|
||||
|
||||
### Step 2: Update DataConnectionForm — rename Configuration label
|
||||
|
||||
Change the "Configuration" label to "Primary Endpoint Configuration" (around line 44-61).
|
||||
|
||||
### Step 3: Add backup endpoint section
|
||||
|
||||
Below the primary config field, add:
|
||||
|
||||
```html
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mt-2"
|
||||
@onclick="() => _showBackup = true">
|
||||
Add Backup Endpoint
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<label class="form-label">Backup Endpoint Configuration</label>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="RemoveBackup">
|
||||
Remove Backup
|
||||
</button>
|
||||
</div>
|
||||
<textarea class="form-control" rows="4"
|
||||
@bind="_model.BackupConfiguration"
|
||||
placeholder='{"Host": "backup-host", "Port": 50101}' />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Failover Retry Count</label>
|
||||
<input type="number" class="form-control" min="1" max="20"
|
||||
@bind="_model.FailoverRetryCount" />
|
||||
<small class="text-muted">Retries before switching to backup (default: 3)</small>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update form model and save logic
|
||||
|
||||
Add `BackupConfiguration` and `FailoverRetryCount` to the form model. Update the save method to pass both configs to the management API.
|
||||
|
||||
In edit mode, set `_showBackup = true` if `BackupConfiguration` is not null.
|
||||
|
||||
### Step 5: Build and verify visually
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx`
|
||||
|
||||
Visual verification requires running the cluster — document as manual test.
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(ui): add primary/backup endpoint fields to data connection form"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: CLI, Management API, and Deployment
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ScadaLink.Commons/Messages/Management/DataConnectionCommands.cs`
|
||||
- Modify: `src/ScadaLink.CLI/Commands/DataConnectionCommands.cs`
|
||||
- Modify: `src/ScadaLink.ManagementService/ManagementActor.cs` (lines 689-711)
|
||||
- Modify: Deployment/flattening code that creates DataConnectionArtifact
|
||||
|
||||
### Step 1: Update management command messages
|
||||
|
||||
```csharp
|
||||
public record CreateDataConnectionCommand(
|
||||
int SiteId, string Name, string Protocol,
|
||||
string? PrimaryConfiguration,
|
||||
string? BackupConfiguration = null,
|
||||
int FailoverRetryCount = 3);
|
||||
|
||||
public record UpdateDataConnectionCommand(
|
||||
int DataConnectionId, string Name, string Protocol,
|
||||
string? PrimaryConfiguration,
|
||||
string? BackupConfiguration = null,
|
||||
int FailoverRetryCount = 3);
|
||||
```
|
||||
|
||||
### Step 2: Update ManagementActor handlers
|
||||
|
||||
In `HandleCreateDataConnection` (around line 689): set `PrimaryConfiguration`, `BackupConfiguration`, `FailoverRetryCount` from command.
|
||||
|
||||
In `HandleUpdateDataConnection` (around line 699): same fields.
|
||||
|
||||
### Step 3: Update CLI commands
|
||||
|
||||
In `BuildCreate` (around line 75-98):
|
||||
- Rename `--configuration` to `--primary-config`
|
||||
- Add hidden alias `--configuration` pointing to same option
|
||||
- Add `--backup-config` option (optional)
|
||||
- Add `--failover-retry-count` option (optional, default 3)
|
||||
|
||||
In `BuildUpdate` (around line 36-59): same changes.
|
||||
|
||||
In `BuildGet` (around line 22-34): update output to show both configs.
|
||||
|
||||
### Step 4: Update deployment artifact creation
|
||||
|
||||
Find where `DataConnectionArtifact` is constructed (in deployment/flattening code). Update to pass `PrimaryConfigurationJson` and `BackupConfigurationJson` from the entity.
|
||||
|
||||
### Step 5: Build and test CLI
|
||||
|
||||
Run: `dotnet build ScadaLink.slnx`
|
||||
|
||||
Test CLI manually:
|
||||
```bash
|
||||
scadalink data-connection create --site-id 1 --name "Test" --protocol OpcUa \
|
||||
--primary-config '{"endpoint":"opc.tcp://localhost:50000"}' \
|
||||
--backup-config '{"endpoint":"opc.tcp://localhost:50010"}' \
|
||||
--failover-retry-count 3
|
||||
```
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(cli): add --primary-config, --backup-config, --failover-retry-count to data connection commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Documentation Updates
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/requirements/Component-DataConnectionLayer.md`
|
||||
- Modify: `docs/requirements/HighLevelReqs.md`
|
||||
- Modify: `docs/requirements/Component-CentralUI.md`
|
||||
- Modify: `docs/test_infra/test_infra.md`
|
||||
|
||||
### Step 1: Update Component-DataConnectionLayer.md
|
||||
|
||||
Add new section "Endpoint Redundancy" covering:
|
||||
- Optional backup endpoints
|
||||
- Failover state machine (include ASCII diagram from design doc)
|
||||
- Configuration model (PrimaryConfiguration + BackupConfiguration)
|
||||
- Failover retry count and round-robin behavior
|
||||
- Subscription re-creation on failover
|
||||
- Health reporting (ActiveEndpoint field)
|
||||
- Site event logging (DataConnectionFailover, DataConnectionRestored)
|
||||
|
||||
Update the configuration reference tables to show the new entity fields.
|
||||
|
||||
### Step 2: Update HighLevelReqs.md
|
||||
|
||||
Add requirement: "Data connections support optional backup endpoints with automatic failover after configurable retry count. On failover, all subscriptions are transparently re-created on the new endpoint."
|
||||
|
||||
### Step 3: Update Component-CentralUI.md
|
||||
|
||||
Update the Data Connections workflow section to describe:
|
||||
- Primary/backup config fields on the form
|
||||
- Collapsible backup section
|
||||
- Failover retry count field
|
||||
- Active endpoint column on list page
|
||||
|
||||
### Step 4: Update test_infra.md
|
||||
|
||||
Add a note in the Remote Test Infrastructure section that the dual OPC UA servers (50000/50010) enable primary/backup testing.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "docs(dcl): document primary/backup endpoint redundancy across requirements and test infra"
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-22-primary-backup-data-connections.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Entity Model & Database Migration", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Update CreateConnectionCommand & Manager Actor", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: DataConnectionActor Failover State Machine", "status": "pending", "blockedBy": [1, 2]},
|
||||
{"id": 4, "subject": "Task 4: Failover Tests", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 5, "subject": "Task 5: Health Reporting & Site Event Logging", "status": "pending", "blockedBy": [3]},
|
||||
{"id": 6, "subject": "Task 6: Central UI Changes", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 7, "subject": "Task 7: CLI, Management API, and Deployment", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 8, "subject": "Task 8: Documentation Updates", "status": "pending", "blockedBy": [3]}
|
||||
],
|
||||
"lastUpdated": "2026-03-22T12:00:00Z"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-23-treeview-component.md",
|
||||
"tasks": [
|
||||
{"id": 22, "subject": "Task 1: Create TreeView.razor — Core Rendering (R1-R4, R14)", "status": "pending"},
|
||||
{"id": 23, "subject": "Task 2: Add Selection Support (R5)", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 3: Add Session Storage Persistence (R11)", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 4: Add ExpandAll, CollapseAll, RevealNode (R12, R13)", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 5: Add Context Menu (R15)", "status": "pending", "blockedBy": [25]},
|
||||
{"id": 27, "subject": "Task 6: Add External Filtering Tests (R8)", "status": "pending", "blockedBy": [26]},
|
||||
{"id": 28, "subject": "Task 7: Integrate TreeView into Data Connections Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 29, "subject": "Task 8: Integrate TreeView into Areas Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 30, "subject": "Task 9: Integrate TreeView into Instances Page", "status": "pending", "blockedBy": [27]},
|
||||
{"id": 31, "subject": "Task 10: Full Build Verification", "status": "pending", "blockedBy": [28, 29, 30]}
|
||||
],
|
||||
"lastUpdated": "2026-03-23T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# Data Connections page — Topology-style refresh
|
||||
|
||||
Date: 2026-05-11
|
||||
Status: Design
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the Data Connections admin page up to the same UX standard as the new Topology page (`/deployment/topology`). The page already uses TreeView and the form already navigates as a separate page, so the refresh is a layered enhancement, not a rewrite.
|
||||
|
||||
## Decisions (captured from Q&A)
|
||||
|
||||
1. **Features to add** (others explicitly excluded):
|
||||
- Search with dim non-matches (opacity 0.4, shape preserved — Topology behavior)
|
||||
- Toolbar: **+ Connection**, **Refresh**, **Expand**, **Collapse**
|
||||
- **No** per-node icons / protocol badges beyond what's already rendered
|
||||
- **No** selection persistence via sessionStorage (selection is in-memory only)
|
||||
2. **Site context menu** gains an "Add Connection here" item that navigates to the create form with `?siteId=N` preselecting and locking the Site field.
|
||||
3. **+ Connection toolbar button** is **disabled until a site is selected**. Selecting either a site node or one of its connection nodes resolves to that site; the create form then preselects and locks Site.
|
||||
4. **No move support** — moving a connection between sites is out of scope (would require a net-new service method and has knock-on effects on `InstanceConnectionBinding`).
|
||||
5. **Empty sites still appear** at the top level (so they can be right-clicked to add a connection).
|
||||
6. **URL renames**:
|
||||
- List page: `/admin/connections` (primary) + `/admin/data-connections` (legacy secondary).
|
||||
- Form: `/admin/connections/create` and `/admin/connections/{Id}/edit` (primary) + `/admin/data-connections/create` and `/admin/data-connections/{Id}/edit` (legacy secondaries).
|
||||
- Nav menu label changes from "Data Connections" to **"Connections"**.
|
||||
7. **Form cleanup** to match the canonical `SiteForm.razor` style (per `feedback_form_layout` memory):
|
||||
- Add explicit `<h6 class="text-muted border-bottom pb-1">` subsection headers: **Primary Endpoint** and **Backup Endpoint**.
|
||||
- Move Failover Retry Count inside the Backup subsection (it only applies when backup is enabled).
|
||||
- Site field stays first; read-only in edit mode; preselected & disabled when `?siteId=` is passed on create.
|
||||
|
||||
## Files to modify
|
||||
|
||||
### `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`
|
||||
|
||||
- Add primary route `@page "/admin/connections"` and secondary legacy `@page "/admin/data-connections"`.
|
||||
- Inject `IJSRuntime` only if needed (search doesn't need it; no sessionStorage).
|
||||
- Add toolbar row above the tree:
|
||||
- Search input (`@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged"`)
|
||||
- btn-group with: **+ Connection** (disabled-bind to `!HasSiteSelected`), **Refresh**, **Expand**, **Collapse**.
|
||||
- TreeView wiring:
|
||||
- Add `@ref="_tree"` and use `_tree?.ExpandAll()` / `CollapseAll()`.
|
||||
- Set `Selectable="true"` and `SelectedKeyChanged="OnTreeNodeSelected"`. Keep selected key in `_selectedKey` (in-memory only).
|
||||
- Search dim:
|
||||
- Recompute a `HashSet<string> _matchKeys` of keys whose own label or any descendant's label contains the search text.
|
||||
- In `NodeContent`, wrap the label `<span>` with `style="opacity: 0.4"` if a search is active and the node is not in `_matchKeys`.
|
||||
- Always-show-empty sites: current code already creates a Site node per Site regardless of children — keep as-is.
|
||||
- Site context menu: add an item **"Add Connection here"** that navigates to `/admin/connections/create?siteId=@node.SiteId`.
|
||||
- Connection context menu: keep Edit + Delete; update the Edit href to the new `/admin/connections/{id}/edit` path.
|
||||
|
||||
### `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor`
|
||||
|
||||
- Add primary routes:
|
||||
```razor
|
||||
@page "/admin/connections/create"
|
||||
@page "/admin/connections/{Id:int}/edit"
|
||||
@page "/admin/data-connections/create"
|
||||
@page "/admin/data-connections/{Id:int}/edit"
|
||||
```
|
||||
- Add `[SupplyParameterFromQuery] public int? SiteId { get; set; }`.
|
||||
- On `OnInitializedAsync`, if `Id` is null and `SiteId` has a value, set `_formSiteId = SiteId.Value` and render the Site field as a disabled `<input>` (same pattern as edit mode) — also set `_siteName` for display.
|
||||
- Reorganize fields to subsections per `SiteForm.razor` reference:
|
||||
- Site (already first), Name, Protocol.
|
||||
- `<h6 class="text-muted border-bottom pb-1">Primary Endpoint</h6>` then Primary Endpoint Configuration.
|
||||
- `<h6 class="text-muted border-bottom pb-1">Backup Endpoint</h6>` — collapsed (Add Backup Endpoint button) by default; when toggled on, render: Backup Configuration, Failover Retry Count, Remove Backup button.
|
||||
- `GoBack()` → `NavigationManager.NavigateTo("/admin/connections")`.
|
||||
|
||||
### `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
|
||||
|
||||
- Change `<NavLink class="nav-link" href="/admin/data-connections">Data Connections</NavLink>` to:
|
||||
```razor
|
||||
<NavLink class="nav-link" href="/admin/connections">Connections</NavLink>
|
||||
```
|
||||
|
||||
### `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs`
|
||||
|
||||
- Update the AdminNavLinks theory: `[InlineData("Data Connections", "/admin/data-connections")]` → `[InlineData("Connections", "/admin/connections")]`.
|
||||
|
||||
## New tests
|
||||
|
||||
### `tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs` (new)
|
||||
|
||||
bUnit rendering tests, modeled after `TopologyPageTests`:
|
||||
|
||||
1. `Renders_EmptyState_WhenNoSites` — no sites configured.
|
||||
2. `Renders_EmptySite_AsTopLevelNode` — site with no connections still appears.
|
||||
3. `Renders_SiteConnection_Nesting` — connection nested under site after click-expand.
|
||||
4. `Search_DimsNonMatches_PreservesShape` — typing in search dims unmatched siblings.
|
||||
5. `AddConnectionButton_DisabledUntilSiteSelected` — toolbar `+ Connection` is `disabled` initially, becomes enabled after clicking a site row.
|
||||
6. `LegacyDataConnectionsRoute_IsDeclaredOnListPage` — both `/admin/connections` and `/admin/data-connections` routes are present (reflection check).
|
||||
|
||||
JSInterop stubs (TreeView calls `treeviewStorage.load`/`save` even when `StorageKey` isn't supplied — verify):
|
||||
- `JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);`
|
||||
- `JSInterop.SetupVoid("treeviewStorage.save", _ => true);`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Moving connections between sites (would require new service method + binding consequences).
|
||||
- Connection status indicators (live state) — DCL connection state isn't surfaced in this page; deferred.
|
||||
- Drag-and-drop reorder.
|
||||
- Selection persistence across page reloads.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `dotnet build` clean.
|
||||
2. `dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj` — all green incl. new tests.
|
||||
3. Existing Playwright NavigationTests pass with the updated label/URL.
|
||||
4. Browser smoke (after `bash docker/deploy.sh`):
|
||||
- `/admin/data-connections` (legacy bookmark) loads the same page as `/admin/connections`.
|
||||
- + Connection disabled until a site is selected; then navigates with `?siteId=N`; Site field is locked in the form.
|
||||
- Right-click on an empty site → "Add Connection here" works.
|
||||
- Search "OPC" dims non-matching connections (label-based search, case-insensitive).
|
||||
- Expand / Collapse buttons work; Refresh re-fetches from repos.
|
||||
- Form sections "Primary Endpoint" / "Backup Endpoint" render with the SiteForm-style headers; Failover Retry Count appears inside the Backup section only when backup is enabled.
|
||||
|
||||
## Critical files
|
||||
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
|
||||
- `tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs`
|
||||
- `tests/ScadaLink.CentralUI.Tests/DataConnectionsPageTests.cs` (new)
|
||||
|
||||
## Reference patterns
|
||||
|
||||
- TreeView usage with toolbar/search: `src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor`
|
||||
- Form layout convention: `src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor`
|
||||
- bUnit harness for tree page: `tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs`
|
||||
@@ -0,0 +1,266 @@
|
||||
# Deployment Topology Page — Design
|
||||
|
||||
A single page under `/deployment` that owns the Site → Area → Instance hierarchy: structural management (create, rename, move, delete) and instance lifecycle (deploy, enable/disable, configure, diff), built on the existing `TreeView` component with the same V1–V7 visual identity as the templates page.
|
||||
|
||||
This page **replaces** both `/deployment/instances` (current read-mostly tree) and `/admin/areas*` (current flat-list CRUD for areas).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Page identity | Replace both `/deployment/instances` and `/admin/areas*` with one new page |
|
||||
| Route | `/deployment/topology` |
|
||||
| Empty containers | Always shown (so they're valid move/create targets) |
|
||||
| Instance configuration | Stays on dedicated `/deployment/instances/{id}/configure` page |
|
||||
| Filters | Search-only (single input above the tree) |
|
||||
| Search semantics | Dim non-matches (50% opacity), preserve tree shape |
|
||||
| Single-click behavior | Select-only; nothing navigates |
|
||||
| Rename UX | Inline (F2 / double-click) for areas only. Instance rename is out of scope (see "Instance rename" below). |
|
||||
| Site-node menu | Add Area, Create Instance here |
|
||||
| Area-node menu | Add Sub-area, Create Instance here, Move to Area…, Rename…, Delete |
|
||||
| Instance-node menu | Deploy/Redeploy, Enable/Disable, Configure, Diff, Move to Area…, Delete |
|
||||
| Delete-area cascade | Keep server semantics — block on any non-empty subtree |
|
||||
| Top-of-page buttons | Create Area, Create Instance, Refresh |
|
||||
| Move structural scope | Same-site only (instance↔area, area↔area). Cross-site moves out of scope. |
|
||||
| Backend area re-parenting | New `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` |
|
||||
| State persistence | Expanded nodes + selected key, both in sessionStorage |
|
||||
| Glyphs | Site `bi-building`, Area `bi-diagram-3`, Instance `bi-box` |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Topology │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Search box ............................. ] │
|
||||
│ [Create Area] [Create Instance] [Refresh] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ▾ 🏢 Plant-A │
|
||||
│ ▾ ▦ Line-1 │
|
||||
│ ▸ ▦ Station-3 │
|
||||
│ □ Pump-001 [Enabled] [Current] │
|
||||
│ □ Pump-002 [Disabled] │
|
||||
│ ▾ ▦ Line-2 │
|
||||
│ □ Conveyor-01 [NotDeployed] │
|
||||
│ ▾ 🏢 Plant-B │
|
||||
│ ▸ ▦ (empty area, still shown) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Visual identity
|
||||
|
||||
Follows the existing `Component-TreeView.md` V1–V7 guide. Glyphs adopted:
|
||||
|
||||
| Node | Glyph | Color hook |
|
||||
|---|---|---|
|
||||
| Site | `bi-building` | default |
|
||||
| Area | `bi-diagram-3` | default |
|
||||
| Instance | `bi-box` | default; state badge to the right |
|
||||
|
||||
Instance state badges (kept from current page):
|
||||
|
||||
| State | Badge |
|
||||
|---|---|
|
||||
| Enabled | `bg-success` |
|
||||
| Disabled | `bg-secondary` |
|
||||
| NotDeployed | `bg-light text-dark` |
|
||||
| Stale (deployed but template revision drifted) | `bg-warning text-dark` |
|
||||
| Current | `bg-light text-dark` |
|
||||
|
||||
Search dimming: non-matches receive `opacity: 0.4`. Matches keep full opacity. Tree shape is preserved; ancestors of matches are auto-expanded on first keystroke.
|
||||
|
||||
## Context menus
|
||||
|
||||
### Site
|
||||
- **Add Area** → opens "Create Area" dialog with this site pre-selected (parent = root)
|
||||
- **Create Instance here** → navigates `/deployment/instances/create?siteId={id}`
|
||||
|
||||
### Area
|
||||
- **Add Sub-area** → "Create Area" dialog with this area pre-selected as parent
|
||||
- **Create Instance here** → navigates `/deployment/instances/create?siteId={siteId}&areaId={id}`
|
||||
- **Move to Area…** → opens `MoveAreaDialog`. Destination list = areas in the same site, excluding self and descendants. Plus "(root of site)" option.
|
||||
- divider
|
||||
- **Rename…** → opens `RenameAreaDialog` (also reachable via F2 / double-click for inline edit)
|
||||
- **Delete** → calls `DeleteAreaAsync`; server rejects if non-empty, error surfaced via toast
|
||||
|
||||
### Instance
|
||||
- **Deploy** / **Redeploy** (label depends on `IsStale`)
|
||||
- **Enable** / **Disable** (state-dependent)
|
||||
- **Configure** → navigates `/deployment/instances/{id}/configure`
|
||||
- **Diff** → opens the existing diff modal (ported from current Instances page)
|
||||
- **Move to Area…** → opens `MoveInstanceDialog`. Destination list = areas in the same site + "(no area, site root)".
|
||||
- divider
|
||||
- **Delete**
|
||||
|
||||
## Inline rename
|
||||
|
||||
Applies to **Area rows only**. Instance rows do not support rename on this page (see "Instance rename" below).
|
||||
|
||||
- `F2` or double-click on the label of an Area row replaces the label span with an `<input>` bound to a local edit buffer.
|
||||
- `Enter` commits via `AreaService.UpdateAreaAsync(areaId, name, user)`.
|
||||
- `Escape` cancels.
|
||||
- On commit failure (e.g., name collision at the same level), the toast shows the server error and the input stays open with the bad value highlighted.
|
||||
|
||||
## Instance rename
|
||||
|
||||
**Out of scope for this page.** `InstanceService` currently has no rename method. Adding one is non-trivial:
|
||||
|
||||
- `Instance.UniqueName` is also the identity of the site-side `InstanceActor` (Akka actor name).
|
||||
- It appears in deployment records, audit history, and deploy paths.
|
||||
- Renaming a deployed instance would require coordinated site-side actor stop/restart, deployment-record rebinding, and potentially redeployment.
|
||||
|
||||
This warrants its own design pass. For now: an instance row's label is read-only on the topology page. If a rename is needed, the user can delete + recreate (with the limitation that deployment history is lost).
|
||||
|
||||
The Area-rename context-menu item ("Rename…") is **not** added to the instance menu.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` — NEW
|
||||
|
||||
Parallel to `InstanceService.AssignToAreaAsync`. Validates:
|
||||
|
||||
1. Area exists.
|
||||
2. `newParentAreaId` is null OR refers to an area in the **same site** as the area being moved.
|
||||
3. `newParentAreaId != areaId` (not self).
|
||||
4. The new parent is not a descendant of the area being moved (cycle prevention) — reuse the existing descendant-walking helper that `DeleteAreaAsync` uses.
|
||||
5. No sibling area at the new level has the same name (case-insensitive).
|
||||
|
||||
On success: updates `ParentAreaId`, persists, audits as `"Move"` on entity `"Area"`.
|
||||
|
||||
`UpdateAreaAsync` stays name-only.
|
||||
|
||||
### `Templates.razor` parent-immutability pattern is **not** repeated here
|
||||
Areas can be moved freely (subject to validation). Templates are different because re-parenting changes inheritance semantics; areas are pure organizational containers.
|
||||
|
||||
### No change to:
|
||||
- `InstanceService.AssignToAreaAsync` (already supports re-parenting; will be called by `MoveInstanceDialog`)
|
||||
- `AreaService.DeleteAreaAsync` (keep current block-on-non-empty semantics)
|
||||
- `AreaService.UpdateAreaAsync` (stays name-only)
|
||||
- `InstanceService` lifecycle methods (already used by current Instances page)
|
||||
|
||||
### CLI / ManagementService parity (optional follow-up)
|
||||
- Add `MoveAreaCommand` message + `ManagementService` handler that wraps `MoveAreaAsync`.
|
||||
- Add CLI: `cli area move --id X --parent-id Y --username … --password …` (omit `--parent-id` to move to site root).
|
||||
|
||||
Not strictly required to ship the UI page, but worth doing for parity with how the rest of the app exposes admin ops.
|
||||
|
||||
## Routes affected
|
||||
|
||||
| Route | Before | After |
|
||||
|---|---|---|
|
||||
| `/deployment/topology` | — | **NEW** (this page — canonical route) |
|
||||
| `/deployment/instances` | tree + lifecycle page | **secondary `@page` directive on `Topology.razor`** — old bookmarks continue to work. NavMenu and all internal back-navs retarget to `/deployment/topology`. |
|
||||
| `/admin/areas` | flat list | **removed** |
|
||||
| `/admin/areas/add` | dialog page | **removed** (Create Area dialog lives on topology page) |
|
||||
| `/admin/areas/edit/{id}` | edit page | **removed** (rename via inline / context menu) |
|
||||
| `/admin/areas/delete/{id}` | confirm page | **removed** (confirm via shared `ConfirmDialog`) |
|
||||
| `/deployment/instances/create` | unchanged | accepts new `?siteId=` and `?areaId=` query params for preselection |
|
||||
| `/deployment/instances/{id}/configure` | unchanged | unchanged |
|
||||
|
||||
The admin nav entry for "Areas" gets removed; "Topology" goes under the Deployment nav group.
|
||||
|
||||
## Files to add
|
||||
|
||||
```
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor (~500 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor (~50 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor (~55 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor (~60 lines)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/RenameAreaDialog.razor (~45 lines) (optional if inline-only)
|
||||
```
|
||||
|
||||
## Files to modify
|
||||
|
||||
```
|
||||
src/ScadaLink.TemplateEngine/Services/AreaService.cs (+ MoveAreaAsync, ~40 lines)
|
||||
src/ScadaLink.Commons/Interfaces/... (interface for AreaService if exposed)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceCreate.razor
|
||||
(+ SiteId, AreaId query-param SupplyParameterFromQuery;
|
||||
retarget back-nav to /deployment/topology — 3 sites)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor
|
||||
(retarget back-nav to /deployment/topology — 1 site)
|
||||
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor (replace 'Instances' nav with 'Topology' at /deployment/topology;
|
||||
remove 'Areas' nav under Admin)
|
||||
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
|
||||
(update InlineData: 'Instances' → 'Topology', '/deployment/instances' → '/deployment/topology')
|
||||
docs/requirements/Component-TreeView.md (rewrite §1 'Instances Page' → 'Topology Page' with new route;
|
||||
remove §3 'Areas Page')
|
||||
```
|
||||
|
||||
Note: `CLAUDE.md` does **not** reference `/deployment/instances` today, so no edit required there.
|
||||
|
||||
## Files to remove
|
||||
|
||||
```
|
||||
src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor (replaced by Topology.razor; old route preserved as secondary @page)
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaAdd.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaEdit.razor
|
||||
src/ScadaLink.CentralUI/Components/Pages/Admin/AreaDelete.razor
|
||||
tests/ScadaLink.CentralUI.Tests/InstancesPageTests.cs (if it exists)
|
||||
tests/ScadaLink.CentralUI.Tests/AreaPageTests.cs (if it exists)
|
||||
```
|
||||
|
||||
Verified there are no other references to `/admin/areas*` in CLI, ManagementService, requirement docs (other than `Component-TreeView.md` §3, which is updated above), or tests.
|
||||
|
||||
## State persistence
|
||||
|
||||
- `topology-tree` (sessionStorage) — expansion state (Set of node keys), already supported by `TreeView.StorageKey`.
|
||||
- `topology-tree-selected` (sessionStorage) — selected node key. New; the `TreeView` already exposes `SelectedKey` two-way binding, but the page is responsible for persisting it. Pattern: write in `SelectedKeyChanged`, read on `OnAfterRenderAsync` after data load.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit (`tests/ScadaLink.TemplateEngine.Tests/AreaServiceTests.cs`)
|
||||
- `MoveArea_ToOtherArea_Succeeds`
|
||||
- `MoveArea_ToSiteRoot_Succeeds` (newParentAreaId = null)
|
||||
- `MoveArea_ToSelf_Fails`
|
||||
- `MoveArea_ToDescendant_FailsWithCycleError`
|
||||
- `MoveArea_DifferentSite_Fails`
|
||||
- `MoveArea_NameCollidesAtNewParent_Fails`
|
||||
- `MoveArea_NameUniqueAtNewParent_Succeeds`
|
||||
- `MoveArea_AuditLogged`
|
||||
|
||||
### bUnit (`tests/ScadaLink.CentralUI.Tests/TopologyPageTests.cs`)
|
||||
- `Renders_EmptyState_WhenNoSites`
|
||||
- `Renders_EmptySite_WhenSiteHasNoAreasOrInstances` (empty containers visible)
|
||||
- `Renders_SiteAreaInstance_Nesting`
|
||||
- `Search_DimsNonMatches_PreservesShape`
|
||||
- `F2_OnAreaRow_EntersRenameMode`
|
||||
- `F2_OnInstanceRow_DoesNothing` (rename out of scope)
|
||||
- `EscapeDuringInlineRename_Cancels`
|
||||
- `ContextMenu_AreaMove_OpensDialogWithCycleFreeOptions`
|
||||
- `ContextMenu_InstanceMove_OpensDialogWithSameSiteAreasOnly`
|
||||
- `ContextMenu_SiteCreateInstance_NavigatesWithSiteIdQuery`
|
||||
- `LegacyInstancesRoute_RoutesToTopologyPage` (visiting `/deployment/instances` resolves to the same component)
|
||||
|
||||
### Removal cleanup
|
||||
- Drop `InstancesPageTests` and any `AreaPageTests` along with the source files.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Two sites with the same area name at root** — fine. Same-site uniqueness is the rule; areas in different sites are independent.
|
||||
- **Move an area while it has an instance assigned at its root** — allowed. The instance keeps the same `AreaId`; the area's new parent doesn't affect it.
|
||||
- **Site with no areas, just root instances** — instance rows render directly under the site node.
|
||||
- **Concurrent rename of a node by another user** — last-write-wins (consistent with template policy).
|
||||
- **Search match inside a collapsed branch** — auto-expand the ancestor chain so the highlighted match is visible.
|
||||
- **Network failure during inline rename** — leave the input open with the pending value; show the error in a toast; user can retry or Escape.
|
||||
- **Deleting an area, then immediately Ctrl+Z** — not supported (no undo); destructive actions are confirmed via `ConfirmDialog` and audited.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Cross-site moves (would need new `Instance.SiteId` rebinding semantics, deployment-record handling, name-collision check at new site).
|
||||
- Drag-and-drop reordering of areas (no ordinal column today; arbitrary alpha-sort).
|
||||
- Bulk operations (select multiple instances and move/deploy together).
|
||||
- Search across templates / sites / instances from the same input (the search is scoped to this page's tree).
|
||||
- **Instance rename.** No `RenameInstanceAsync` in `InstanceService` today; adding one requires a separate design pass (site-side actor identity, deployment-record rebinding, audit history continuity). Users wanting to rename should delete + recreate.
|
||||
|
||||
## Out-of-band consistency tasks
|
||||
|
||||
When this lands, the following docs need a touch-up:
|
||||
|
||||
- `README.md` — component table; verify no reference to the removed Instances/Areas pages remains.
|
||||
- `docs/requirements/Component-CentralUI.md` (or the routing section if one exists) — route table.
|
||||
- `src/ScadaLink.CLI/README.md` — if existing CLI examples reference `area` subcommands, align with the optional CLI `area move` addition.
|
||||
|
||||
Confirmed clean (no edit needed):
|
||||
- `CLAUDE.md` does not reference `/deployment/instances` or `/admin/areas` today.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Templates Page — Folder & Hierarchy Reorganization
|
||||
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Design approved, ready for implementation planning
|
||||
**Scope:** `/design/templates` page in Central UI, plus supporting data model, services, message contracts, and migration.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current single-list view at `/design/templates` with a tree-organized browser modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and navigate to a dedicated edit page (`/design/templates/{id}`) when authoring a specific template. The tree page itself does not host the editor.
|
||||
|
||||
## Reference
|
||||
|
||||
The reference image (Wonderware Template Toolbox) shows three distinct concepts that this design carries over:
|
||||
|
||||
- **Folders** (yellow folder glyphs) — purely organizational, can be nested arbitrarily deep.
|
||||
- **Templates** (`$Name`) — placed inside folders or at the tree root.
|
||||
- **Composition children** — rendered inline under their owning template (e.g., `$TestMachine` shows `DelmiaReceiver` and `MESReceiver`).
|
||||
|
||||
Inheritance is **not** rendered as tree nesting in the image, and it is not rendered as tree nesting in this design. Inheritance remains metadata on the template node label ("inherits $Parent").
|
||||
|
||||
## Locked decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Inheritance in tree | Not shown as nesting; **not shown on the node label either** (label is name only). Inheritance is visible in the TemplateEdit page when a template is selected. |
|
||||
| Folder model | New `TemplateFolder` entity with self-referencing `ParentFolderId`. `Template.FolderId` nullable. |
|
||||
| Reorganization UX | **Right-click context menus only** (no drag-drop). Modal dialog pickers for move targets. |
|
||||
| Composition rendering | Read-only leaves with navigation; right-click → Open composed template / Remove composition. |
|
||||
| Root-level templates | Allowed (`FolderId` nullable). Existing templates migrate with `FolderId = null`. |
|
||||
| Folder delete with contents | Blocked; structured error lists child counts. |
|
||||
| Page layout | **Tree browser only** — no split-pane editor. Selecting a template navigates to `/design/templates/{id}` (TemplateEdit page); creating navigates to `/design/templates/create`. |
|
||||
| Tree node visuals | Per `Component-TreeView.md` Visual Design Guide V7: Bootstrap Icons (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`), name-only labels (no count/inherit badges on template nodes; composition rows also name-only — the glyph signals the kind), folder child-count pill. |
|
||||
|
||||
## Data model
|
||||
|
||||
**New entity** in `src/ScadaLink.Commons/Entities/Templates/TemplateFolder.cs`:
|
||||
|
||||
```csharp
|
||||
public class TemplateFolder
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } // unique among siblings of the same parent (case-insensitive)
|
||||
public int? ParentFolderId { get; set; } // null = root
|
||||
public int SortOrder { get; set; } // reserved for future manual ordering; defaults to 0
|
||||
// Audit fields follow existing entity conventions.
|
||||
|
||||
public TemplateFolder(string name)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Modification to `Template`:**
|
||||
|
||||
```csharp
|
||||
public int? FolderId { get; set; } // null = root
|
||||
```
|
||||
|
||||
**Invariants (server-enforced):**
|
||||
- Folder name unique among siblings of the same parent (case-insensitive).
|
||||
- `ParentFolderId` graph is acyclic.
|
||||
- A folder cannot be deleted if it has any child folders or child templates.
|
||||
- Moving a template into a folder is a single FK update; folders carry no semantic meaning to the template engine.
|
||||
|
||||
**Repository surface** (in `ITemplateEngineRepository` or a new `ITemplateFolderRepository`):
|
||||
- `GetAllFoldersAsync()`
|
||||
- `GetFolderAsync(int id)`
|
||||
- `AddFolderAsync(TemplateFolder)`
|
||||
- `UpdateFolderAsync(TemplateFolder)`
|
||||
- `DeleteFolderAsync(int id)`
|
||||
- `MoveFolderAsync(int folderId, int? newParentId)`
|
||||
- `MoveTemplateAsync(int templateId, int? newFolderId)`
|
||||
|
||||
**Migration:** EF Core migration adds a `TemplateFolders` table and a nullable `FolderId` column on `Templates`. Existing templates retain `FolderId = null` (root). No data movement.
|
||||
|
||||
**Audit:** All folder mutations and template-folder moves go through `IAuditService` with the same conventions as existing template operations.
|
||||
|
||||
## Server-side service
|
||||
|
||||
**`TemplateFolderService`** (new, in `src/ScadaLink.TemplateEngine/`), mirroring `TemplateService`:
|
||||
|
||||
- `CreateFolderAsync(name, parentFolderId?, user) → Result<TemplateFolder>`
|
||||
- `RenameFolderAsync(id, newName, user) → Result<TemplateFolder>`
|
||||
- `MoveFolderAsync(id, newParentId?, user) → Result<TemplateFolder>` — cycle check: walk parent chain from `newParentId` upward, reject if `id` appears.
|
||||
- `DeleteFolderAsync(id, user) → Result<Unit>` — structured failure with `(childFolderCount, childTemplateCount)` when non-empty.
|
||||
- `MoveTemplateAsync(templateId, newFolderId?, user) → Result<Template>` — also accessible from `TemplateService`.
|
||||
|
||||
Validations on all paths: non-empty name, name unique among siblings, parent exists (when not null).
|
||||
|
||||
## Management Service contracts
|
||||
|
||||
In `src/ScadaLink.Commons/Messages/Management/`:
|
||||
|
||||
- `CreateTemplateFolderRequest` / `Response`
|
||||
- `RenameTemplateFolderRequest` / `Response`
|
||||
- `MoveTemplateFolderRequest` / `Response`
|
||||
- `DeleteTemplateFolderRequest` / `Response`
|
||||
- `ListTemplateFoldersRequest` / `Response`
|
||||
- `MoveTemplateToFolderRequest` / `Response`
|
||||
|
||||
Additive-only evolution rules apply. Management actor handlers delegate to `TemplateFolderService`. Required for parity with the rest of the management API and makes future CLI support free (CLI is out of scope here).
|
||||
|
||||
**Authorization:** All folder operations require the `Design` policy.
|
||||
|
||||
## Tree model
|
||||
|
||||
Page-level tree node (`TmplNode`) consolidates all three node kinds into one structure for the generic `TreeView`:
|
||||
|
||||
```csharp
|
||||
private enum TmplNodeKind { Folder, Template, Composition }
|
||||
|
||||
private record TmplNode(
|
||||
string Key, // "f:{id}" | "t:{id}" | "c:{id}" — uniqueness across kinds
|
||||
TmplNodeKind Kind,
|
||||
int EntityId, // FolderId, TemplateId, or CompositionId
|
||||
string Label,
|
||||
int? ParentFolderId, // folders + templates
|
||||
int? OwnerTemplateId, // composition leaves: the template that owns this composition
|
||||
Template? Template, // populated for Template nodes (for inline metadata)
|
||||
TemplateComposition? Composition, // populated for Composition nodes
|
||||
List<TmplNode> Children);
|
||||
```
|
||||
|
||||
**Build order in `LoadTreeAsync()`:**
|
||||
1. `GetAllFoldersAsync()` + `GetAllTemplatesAsync()` (and `GetAllCompositionsAsync()` if compositions aren't eager-loaded by the list call).
|
||||
2. Build folder nodes keyed `f:{id}`, attach by `ParentFolderId`.
|
||||
3. For each template, build a Template node and attach its compositions as `c:{compositionId}` leaves.
|
||||
4. Attach each template to its `FolderId` folder, or to `_roots` if `FolderId == null`.
|
||||
5. Sort siblings: folders first (alphabetical by name), then templates (alphabetical by name). Compositions sort alphabetical by `InstanceName`.
|
||||
|
||||
**`TreeView` wiring:**
|
||||
|
||||
| Param | Value |
|
||||
|---|---|
|
||||
| `Items` | `_roots` |
|
||||
| `ChildrenSelector` | `n => n.Children` |
|
||||
| `HasChildrenSelector` | `n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0` |
|
||||
| `KeySelector` | `n => (object)n.Key` |
|
||||
| `StorageKey` | `"templates-tree"` (preserved from current usage) |
|
||||
| `Selectable` | `true` |
|
||||
| `SelectedKeyChanged` | dispatch on key prefix: `t:` → `NavigationManager.NavigateTo($"/design/templates/{id}")` (TemplateEdit page); `f:` → no-op; `c:` → `NavigateTo` the composed template's edit page |
|
||||
|
||||
**Inline node labels** (see `Component-TreeView.md` V7 for the canonical recipe):
|
||||
- Folder: `<i class="bi bi-folder">` (closed) or `<i class="bi bi-folder2-open">` (expanded) + name (semibold when has children) + count-pill badge of direct children.
|
||||
- Template: `<i class="bi bi-file-earmark-text">` + `$Name` (semibold when has compositions). **No** inheritance hint, **no** attr/alarm/script count, **no** composition count on the node.
|
||||
- Composition: `<i class="bi bi-arrow-return-right">` + composition instance name only. The composed template name is intentionally omitted from the tree — open the owning template's edit page to see/manage compositions.
|
||||
|
||||
**Search/filter:** out of scope for v1; the underlying component supports external filtering (per `Component-TreeView.md` R8) so it can be added later without component changes.
|
||||
|
||||
## Page layout
|
||||
|
||||
`/design/templates` is a **single-column tree browser** — no inline editor, no split pane.
|
||||
|
||||
```
|
||||
+--------------------------------------------+
|
||||
| Templates |
|
||||
| [+Folder] [+Template] [Expand] [Collapse] |
|
||||
| |
|
||||
| ▶ 📁 _Default Templates |
|
||||
| ▼ 📂 Dev |
|
||||
| 📄 $TestMachine |
|
||||
| ↪ DelmiaReceiver |
|
||||
| ↪ MESReceiver |
|
||||
| 📄 $TestObject |
|
||||
| ▶ 📁 System |
|
||||
| 📄 $UnfiledTemplate |
|
||||
+--------------------------------------------+
|
||||
```
|
||||
|
||||
- Tree scrollable region: `max-height: calc(100vh - 160px); overflow-y: auto`. The 25–33% sidebar width constraint is removed; the tree uses the page's main container width.
|
||||
- Selecting a template node navigates to `/design/templates/{id}` (TemplateEdit page).
|
||||
- Selecting a composition node navigates to the composed template's edit page.
|
||||
- Selecting a folder node is a no-op (still allowed; expansion and context-menu still work).
|
||||
- Creating a template: toolbar "+ Template" button (or folder context-menu "New Template") navigates to `/design/templates/create?folderId={id}`. After successful create, the create page navigates to `/design/templates/{newId}`.
|
||||
- URL contract for deep links: `/design/templates/{id}` resolves to the TemplateEdit page directly — the browser doesn't need to be on the tree page first.
|
||||
|
||||
## Context menus
|
||||
|
||||
The context menu is the **only** reorganization mechanism. Per-node-kind `ContextMenu` fragment driven by `node.Kind`:
|
||||
|
||||
**Folder:** New Folder · New Template · Rename · Move to Folder… · Delete
|
||||
**Template:** Edit · Move to Folder… · Delete
|
||||
**Composition:** Open composed template · Remove composition
|
||||
|
||||
- **Move to Folder…** opens a modal (`MoveFolderDialog` / `MoveTemplateDialog`) with a flat folder picker. The list includes "(Root)" as the first entry. For folder-move, the dialog client-side prunes the folder being moved and its descendants from the candidate list to prevent obvious cycles; the server still validates (authoritative). For template-move, all folders are valid targets.
|
||||
- **Edit** on a template navigates to `/design/templates/{id}` (TemplateEdit page) — equivalent to clicking the node, kept in the menu for discoverability.
|
||||
- Root-level "+ Folder" and "+ Template" buttons live in the toolbar above the tree.
|
||||
|
||||
**Server-side validation (authoritative)**:
|
||||
- Folder onto descendant → reject (cycle).
|
||||
- Folder onto itself → no-op (client prunes).
|
||||
- Template-onto-template → not a valid target (templates aren't shown in the folder picker).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Deep-link route `/design/templates/{id}` resolves directly to the TemplateEdit page; the tree page is not involved. If the user navigates back, the tree's sessionStorage-persisted expansion state is restored.
|
||||
- Stale `f:{id}` keys in `sessionStorage` after folder delete are harmless (ignored on next render).
|
||||
- Selected template moved to another folder → tree rebuilds; selection preserved by stable key.
|
||||
- Template deleted from the TemplateEdit page → page navigates back to `/design/templates`; the tree rebuilds without the deleted node.
|
||||
- Last-write-wins on concurrent folder edits, matching existing template policy.
|
||||
- Tree fully rebuilt on every CRUD; expected scale (dozens to low hundreds) makes this trivially cheap.
|
||||
|
||||
## Validation summary
|
||||
|
||||
| Operation | Check | Failure mode |
|
||||
|---|---|---|
|
||||
| Create folder | name non-empty, unique among siblings | structured error |
|
||||
| Rename folder | same as create | structured error |
|
||||
| Move folder | parent exists or null; no cycle; name still unique in new parent | structured error |
|
||||
| Delete folder | no child folders, no child templates | error with counts |
|
||||
| Move template | target folder exists or null | structured error |
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (`tests/ScadaLink.TemplateEngine.Tests/`):**
|
||||
- `TemplateFolderServiceTests` — create / rename / move (happy + cycle + duplicate) / delete (happy + non-empty).
|
||||
- `TemplateServiceTests` — `MoveTemplateAsync` happy + missing target.
|
||||
- Migration test confirming nullable `FolderId` and existing templates retaining null.
|
||||
|
||||
**bUnit (`tests/ScadaLink.CentralUI.Tests/`):**
|
||||
- Tree renders folders / templates / compositions in correct nesting.
|
||||
- Empty state when no roots exist (no folders, no root templates).
|
||||
- Selecting a template node invokes `NavigationManager.NavigateTo($"/design/templates/{id}")`.
|
||||
- Selecting a composition node invokes `NavigateTo` for the composed template's edit page.
|
||||
- Selecting a folder node is a no-op (no navigation).
|
||||
- Right-click menus differ by node kind (Folder / Template / Composition each have distinct items).
|
||||
- Folder context menu includes "Move to Folder…"; the dialog excludes the folder being moved and its descendants from candidates.
|
||||
- Folder-delete-non-empty surfaces a structured error toast.
|
||||
- Bootstrap Icons render in the glyph slot for each node kind (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`).
|
||||
|
||||
**Manual smoke (per `CLAUDE.md`):** nested folder creation, context-menu reorg (folder + template Move-to-Folder dialogs), cycle rejection, refresh persistence, composition navigation, navigation from tree to TemplateEdit and back.
|
||||
|
||||
## Documentation updates
|
||||
|
||||
- `docs/requirements/Component-CentralUI.md` — describe the templates page tree layout.
|
||||
- `docs/requirements/Component-TemplateEngine.md` — add `TemplateFolder` entity + folder operations.
|
||||
- `docs/requirements/Component-ConfigurationDatabase.md` — add `TemplateFolders` table + `Templates.FolderId` column.
|
||||
- `docs/requirements/Component-ManagementService.md` — add new message contracts.
|
||||
- `README.md` — note folder organization in the Template Engine row's responsibilities.
|
||||
|
||||
## Out of scope (for v1)
|
||||
|
||||
- Tree search / filter input (component already supports it; add when needed).
|
||||
- CLI commands for folder operations (message contracts make this trivial later).
|
||||
- Sibling reorder (sort stays alphabetical).
|
||||
- Root context menu (right-click in empty tree area).
|
||||
- (Removed from out-of-scope.) Bootstrap Icons are now adopted (static files at `wwwroot/lib/bootstrap-icons/`) — see `Component-TreeView.md` V4.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-11-templates-folder-hierarchy.md",
|
||||
"tasks": [
|
||||
{"id": 7, "subject": "Task 0: Confirm baseline + create work branch", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 1: Add TemplateFolder entity + Template.FolderId", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 2: EF configuration for TemplateFolder + Template.FolderId", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 3: Generate EF migration AddTemplateFolders", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 4: Repository methods for TemplateFolder", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 5: TemplateFolderService.CreateFolderAsync (TDD)", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "subject": "Task 6: TemplateFolderService.RenameFolderAsync", "status": "pending", "blockedBy": [12]},
|
||||
{"id": 14, "subject": "Task 7: TemplateFolderService.MoveFolderAsync with cycle detection", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 8: TemplateFolderService.DeleteFolderAsync (non-empty check)", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 9: TemplateService.MoveTemplateAsync", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 17, "subject": "Task 10: DI registration for TemplateFolderService", "status": "pending", "blockedBy": [15, 16]},
|
||||
{"id": 18, "subject": "Task 11: Management command records for TemplateFolder", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 19, "subject": "Task 12: ManagementActor authorization + handlers", "status": "pending", "blockedBy": [18]},
|
||||
{"id": 20, "subject": "Task 13: Templates.razor — load folders alongside templates", "status": "pending", "blockedBy": [17]},
|
||||
{"id": 21, "subject": "Task 14: Build new TmplNode tree model", "status": "pending", "blockedBy": [20]},
|
||||
{"id": 22, "subject": "Task 15: Split-pane layout + new TreeView wiring", "status": "pending", "blockedBy": [21]},
|
||||
{"id": 23, "subject": "Task 16: Per-kind context menus", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 24, "subject": "Task 17: New-folder, new-template, move-template dialogs", "status": "pending", "blockedBy": [23]},
|
||||
{"id": 25, "subject": "Task 18: Drag-drop reorganization", "status": "pending", "blockedBy": [24]},
|
||||
{"id": 26, "subject": "Task 19: Deep-link reveal on load", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 27, "subject": "Task 20: bUnit tests for the new page", "status": "pending", "blockedBy": [22]},
|
||||
{"id": 28, "subject": "Task 21: Documentation updates", "status": "pending", "blockedBy": [25, 26, 27]},
|
||||
{"id": 29, "subject": "Task 22: Final smoke + green-suite check", "status": "pending", "blockedBy": [25, 26, 27, 28]}
|
||||
],
|
||||
"lastUpdated": "2026-05-11"
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
# Derive-on-compose template specialization
|
||||
|
||||
## Goal
|
||||
|
||||
Match Aveva System Platform's composition model: composing template
|
||||
`$Sensor` into template `$Pump` no longer references `$Sensor` directly. Instead
|
||||
the system creates a derived template that **inherits** from `$Sensor`, then the
|
||||
composition references the derived template. The derived template lives under
|
||||
the owning parent and can:
|
||||
|
||||
- override attribute default values
|
||||
- override script bodies
|
||||
- add new attributes / scripts the base doesn't have
|
||||
- be prevented from overriding fields the base marks as locked
|
||||
|
||||
This is the user-selected approach (Option C "Always-derive") from the
|
||||
brainstorming session, with all four customization scopes enabled.
|
||||
|
||||
## Why
|
||||
|
||||
- Per-composition customization is a real SCADA use case (Pump's TempSensor
|
||||
needs different alarm thresholds from Motor's TempSensor).
|
||||
- Single parent always at design time: removes the multi-parent picker we just
|
||||
added.
|
||||
- Industry-standard mental model for users coming from Aveva / Wonderware.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing the existing `ParentTemplateId` inheritance chain — we reuse it.
|
||||
- Versioning of base templates separately from derived (out of scope; can layer
|
||||
later).
|
||||
- Cross-template attribute references (already covered by Children/Parent).
|
||||
|
||||
## Data model changes
|
||||
|
||||
`Template` gains:
|
||||
|
||||
```csharp
|
||||
public bool IsDerived { get; set; } // hides from main tree
|
||||
public int? OwnerCompositionId { get; set; } // back-ref to composition
|
||||
```
|
||||
|
||||
`TemplateAttribute` gains:
|
||||
|
||||
```csharp
|
||||
public bool IsInherited { get; set; } // value came from base
|
||||
public bool LockedInDerived { get; set; } // base marks "no override"
|
||||
```
|
||||
|
||||
`TemplateScript` gains the same `IsInherited` / `LockedInDerived` pair.
|
||||
|
||||
`TemplateComposition` is unchanged in shape — `ComposedTemplateId` now points
|
||||
at the **derived** template, not the base. The base is reachable via
|
||||
`derived.ParentTemplateId`.
|
||||
|
||||
**Why a separate `IsDerived` flag rather than just "has a parent and is composed
|
||||
once":** explicit marker keeps the tree-view filtering trivial and signals
|
||||
intent independent of current composition state.
|
||||
|
||||
**Why `OwnerCompositionId` instead of inferring from `TemplateComposition`
|
||||
back-pointers:** O(1) lookup for cascade-delete and forbid-direct-edit paths.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
Compose "$Sensor" into "$Pump" as instance "TempSensor":
|
||||
1. Create new template { Name: "Pump.TempSensor", ParentTemplateId: $Sensor.Id,
|
||||
IsDerived: true, Description: from $Sensor }
|
||||
2. Copy $Sensor.Attributes into the new template marked IsInherited=true
|
||||
3. Copy $Sensor.Scripts into the new template marked IsInherited=true
|
||||
4. Create TemplateComposition { TemplateId: $Pump.Id,
|
||||
ComposedTemplateId: newTemplate.Id,
|
||||
InstanceName: "TempSensor" }
|
||||
5. Set newTemplate.OwnerCompositionId = the new composition's Id
|
||||
```
|
||||
|
||||
Delete composition or owning parent → cascade-delete the derived template.
|
||||
|
||||
Rename composition InstanceName → rename the derived template (`Pump.NewName`).
|
||||
|
||||
Edit base attribute that is `IsInherited=true` on derivatives → the derivatives
|
||||
pick up the change *if* they haven't overridden that field. Override sets
|
||||
`IsInherited=false`.
|
||||
|
||||
## Lock semantics
|
||||
|
||||
Existing `IsLocked` on `TemplateAttribute` already exists with the meaning
|
||||
"this attribute on this template is locked for editing." Add a second flag
|
||||
`LockedInDerived` meaning "derived templates may not override the value
|
||||
inherited from this attribute." These compose:
|
||||
|
||||
| State on base | What derived can do |
|
||||
|---|---|
|
||||
| neither flag set | Override value freely |
|
||||
| `LockedInDerived` only | Cannot override; inherited value is final |
|
||||
| `IsLocked` only | Base itself can't be edited; derived can still override |
|
||||
| both | Locked everywhere |
|
||||
|
||||
## Flattening implications
|
||||
|
||||
`FlatteningService.ResolveInheritedScripts` already walks a template chain via
|
||||
`ParentTemplateId`. That logic already handles "child overrides parent;
|
||||
parent's `IsLocked` blocks override." We extend the same with
|
||||
`LockedInDerived` for both attributes and scripts.
|
||||
|
||||
`ResolveComposedScripts` walks compositions → composed templates. Today the
|
||||
prefix is the `InstanceName`. With derived templates the prefix is still the
|
||||
`InstanceName` (the derived template's name `Pump.TempSensor` doesn't show up
|
||||
in canonical paths — paths use the slot name, not the template name).
|
||||
|
||||
The `ResolvedScript.Scope` we landed for Phase 2 of the previous design still
|
||||
applies: `SelfPath = "TempSensor"`, `ParentPath = ""`. No change.
|
||||
|
||||
## UI changes
|
||||
|
||||
### Template tree
|
||||
|
||||
Hide `IsDerived` templates from the main list. They're reachable via:
|
||||
- the Compositions tab on the parent template (click the row → opens the
|
||||
derived template's edit page)
|
||||
- a "Show derived templates" toggle on the tree page (off by default)
|
||||
|
||||
### TemplateEdit for a derived template
|
||||
|
||||
Top banner: *"Derived from `$Sensor` — composed inside `$Pump` as `TempSensor`."*
|
||||
|
||||
Attributes table renders three columns of state:
|
||||
- **Override / Inherited** badge per row
|
||||
- Locked-from-base attributes render readonly with a 🔒 icon and tooltip
|
||||
*"Locked by base — cannot override."*
|
||||
|
||||
Scripts table same treatment.
|
||||
|
||||
Adding a new attribute or script on the derived template is allowed (creates
|
||||
a row with `IsInherited = false`).
|
||||
|
||||
Removing an inherited row reverts it to the base value (the row goes back to
|
||||
inherited state). Removing an own-added row deletes it.
|
||||
|
||||
### TemplateEdit for a base template
|
||||
|
||||
Two extra columns on attribute / script tables:
|
||||
- 🔒 toggle for `LockedInDerived` — "Lock this against per-slot override"
|
||||
|
||||
### Compositions tab
|
||||
|
||||
Today: lists composition rows with InstanceName + ComposedTemplate name.
|
||||
After: each row links to *its derived template* (not the base). InstanceName
|
||||
becomes the visible label.
|
||||
|
||||
Renaming a composition renames the derived template too.
|
||||
|
||||
### Composition picker (when adding a composition)
|
||||
|
||||
Today: pick a template + provide an instance name.
|
||||
After: pick a **base** template + provide an instance name. The system creates
|
||||
the derived template behind the scenes.
|
||||
|
||||
The picker filters out `IsDerived` templates — you can only compose bases.
|
||||
|
||||
## Editor metadata implications
|
||||
|
||||
The multi-parent picker becomes mostly irrelevant:
|
||||
|
||||
- **Derived template**: always single parent (the composition it's owned by).
|
||||
`Parent.*` resolves to that one. No picker.
|
||||
- **Base template**: still has no direct parent (it's a library entry).
|
||||
`Parent.*` autocompletion is suppressed. Scripts on bases that use
|
||||
`Parent.*` get a warning *"Parent access on a base template is ambiguous —
|
||||
override this script in the derived template instead."*
|
||||
|
||||
`TemplateEdit.BuildParentContextsAsync` simplifies to: "if derived, return the
|
||||
single owning parent; else return null."
|
||||
|
||||
`GetTemplatesComposingAsync` repository method still useful (e.g., for "find
|
||||
all uses of this base"), but the editor metadata path doesn't need it.
|
||||
|
||||
## Migration
|
||||
|
||||
One-shot for existing data:
|
||||
|
||||
```sql
|
||||
-- pseudo-SQL describing intent
|
||||
FOREACH composition IN TemplateComposition:
|
||||
derived := INSERT INTO Templates (
|
||||
Name = parent.Name + "." + composition.InstanceName,
|
||||
ParentTemplateId = composition.ComposedTemplateId,
|
||||
IsDerived = true,
|
||||
OwnerCompositionId = composition.Id
|
||||
)
|
||||
-- Copy attributes from base, mark IsInherited=true
|
||||
INSERT INTO TemplateAttributes
|
||||
SELECT @derived.Id, Name, Value, DataType, true, ... FROM base.Attributes
|
||||
-- Same for scripts
|
||||
UPDATE TemplateComposition SET ComposedTemplateId = derived.Id WHERE Id = composition.Id
|
||||
```
|
||||
|
||||
EF Core migration in `ScadaLink.ConfigurationDatabase/Migrations/`.
|
||||
|
||||
Rollback strategy: the migration is one-way for new derivations, but old
|
||||
composition data can be reconstructed from `IsDerived` templates' `ParentTemplateId`.
|
||||
|
||||
## Phased rollout
|
||||
|
||||
Each phase is independently shippable and reviewable.
|
||||
|
||||
1. **Schema + entities.** Add the new fields. Empty migration. EF mappings.
|
||||
No behavior changes. Existing data unaffected.
|
||||
|
||||
2. **Composition flow change.** Modify `TemplateService.AddCompositionAsync`
|
||||
to derive on compose for *new* compositions. Existing data still has direct
|
||||
compositions and continues to work. Two modes coexist during the cutover.
|
||||
|
||||
3. **Migration.** EF Core migration script that walks existing compositions
|
||||
and creates the derived templates retroactively. After this all
|
||||
compositions are derived.
|
||||
|
||||
4. **Inherit/override resolution.** Update `FlatteningService` to merge
|
||||
inherited and overridden fields. Tests for the override semantics.
|
||||
|
||||
5. **Lock semantics.** Wire `LockedInDerived` through `TemplateService`
|
||||
update paths. Tests.
|
||||
|
||||
6. **Template tree UI.** Hide derived templates from the main listing;
|
||||
surface them through the parent's Compositions tab.
|
||||
|
||||
7. **Derived TemplateEdit UI.** Banner, inherited/override badges,
|
||||
readonly-when-locked, override/revert actions.
|
||||
|
||||
8. **Base TemplateEdit UI.** Add the LockedInDerived toggle column.
|
||||
|
||||
9. **Editor metadata simplification.** Replace the multi-parent picker with
|
||||
the single-parent resolver. Base templates suppress `Parent.*` assistance
|
||||
and warn on use.
|
||||
|
||||
## Out of scope (for now)
|
||||
|
||||
- Versioning of base templates with explicit "update derived templates to
|
||||
base v2" workflow.
|
||||
- Reverse-flow: editing a derived value and asking "promote to base."
|
||||
- Multiple inheritance levels for derivation (e.g., `$Sensor → $Sensor.Pump →
|
||||
$Sensor.Pump.HighTemp`) — the data model supports it via
|
||||
`ParentTemplateId`, but the UX hasn't been designed.
|
||||
- Cross-tenant template libraries.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Naming**: dot-separated (`Pump.TempSensor`). Matches the canonical-path
|
||||
format used in flattening. Visible in audit logs / error messages.
|
||||
- **Delete base with derivatives**: block the delete and list the derivatives.
|
||||
User must remove or repoint them first.
|
||||
- **Migration of existing data**: EF Core migration on next startup
|
||||
auto-derives every existing composition. After deploy all compositions are
|
||||
derived; no mixed-mode code paths.
|
||||
- **Tree UX**: derived templates hidden by default. "Show derived templates"
|
||||
toggle on the tree page reveals them indented under their base. Always
|
||||
reachable from the parent's Compositions tab.
|
||||
|
||||
## Confirmed semantics
|
||||
|
||||
- **Re-composing the same base on the same parent in two slots** (e.g. Pump
|
||||
composes Sensor twice as `IntakeSensor` and `OutletSensor`) produces two
|
||||
derived templates: `Pump.IntakeSensor` and `Pump.OutletSensor`, both
|
||||
inheriting from `Sensor`.
|
||||
|
||||
- **Inheritance updates flow downward**: if a base attribute changes value
|
||||
later and the derivative has `IsInherited = true` for that attribute, the
|
||||
derived value updates. Once overridden (`IsInherited = false`), changes to
|
||||
the base no longer affect that field.
|
||||
|
||||
- **Subsequent `LockedInDerived` after overrides exist**: surface as a
|
||||
validation error at deploy time; do not force-revert silently.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Derive-on-compose: implementation status
|
||||
|
||||
> **For Claude resuming later:** All nine phases are implemented. This
|
||||
> file is the change-record for the work, not a plan. See the companion
|
||||
> design doc `2026-05-12-derive-on-compose-design.md` for rationale.
|
||||
|
||||
## Where we are
|
||||
|
||||
**Branch**: `feature/templates-folder-hierarchy`.
|
||||
|
||||
**Last commit on this feature**: `a965d4a` — *Phase 9 complete,
|
||||
single-parent editor context*.
|
||||
|
||||
**All nine phases done**. Live verification against SQL Server (phase-3
|
||||
migration shape) and a UI smoke test are still recommended before merge.
|
||||
|
||||
**All test suites currently green**:
|
||||
- `tests/ScadaLink.CentralUI.Tests` — 159 passing
|
||||
- `tests/ScadaLink.SiteRuntime.Tests` — 129 passing
|
||||
- `tests/ScadaLink.TemplateEngine.Tests` — 212 passing (+13 derive-on-compose tests)
|
||||
|
||||
## Design decisions already made (from the brainstorm)
|
||||
|
||||
User picked the **full Aveva model** with all four customization scopes:
|
||||
|
||||
- **Naming**: dot-separated → `Pump.TempSensor`
|
||||
- **Delete base with derivatives**: block with a list of the dependents
|
||||
- **Migration of existing compositions**: auto-migrate all on the EF Core
|
||||
migration step in Phase 3
|
||||
- **Tree UX**: derived templates hidden by default; toggle to reveal
|
||||
- **Customization scope**: override attribute values, override script bodies,
|
||||
add new attrs/scripts per slot, lock fields against override
|
||||
|
||||
## Done — Phase 1: Additive schema
|
||||
|
||||
Commits: `6854843` (design doc) + `a968cef` (decisions recorded) + `5615f3d`.
|
||||
|
||||
## Done — Phase 2: Compose flow change
|
||||
|
||||
Commit: `fa86750`.
|
||||
|
||||
- `TemplateService.AddCompositionAsync` builds a derived template
|
||||
(`"<parent>.<slot>"`), copies base attributes/scripts with
|
||||
`IsInherited=true`, then composes the derived (not the base). Sets
|
||||
`OwnerCompositionId` back-ref after the composition's Id is known.
|
||||
- Composing a derived template is rejected — only bases can be composed.
|
||||
- `DeleteCompositionAsync` cascade-deletes the slot-owned derived
|
||||
template (`IsDerived=true` and `OwnerCompositionId==compositionId`).
|
||||
- `DeleteTemplateAsync` blocks direct deletion of derived templates and
|
||||
splits the inheritor check into regular children vs. derivatives — the
|
||||
derivative branch labels each by `'OwnerName' (as 'SlotName')`.
|
||||
- `TemplateDeletionService.CanDeleteTemplateAsync` mirrors the same
|
||||
derivative-aware checks.
|
||||
|
||||
## Done — Phase 3: Migration of existing compositions
|
||||
|
||||
Commit: `03a8c4a`. Migration `20260512122746_MigrateCompositionsToDerived`.
|
||||
|
||||
- Pre-flight aborts with a descriptive error if any
|
||||
`<parent>.<slot>` derived name would collide.
|
||||
- Cursor-walks every `TemplateComposition` whose target is `IsDerived=0`,
|
||||
inserts a derived template, copies attributes/scripts with
|
||||
`IsInherited=1`, then repoints `ComposedTemplateId`.
|
||||
- Idempotent (only touches non-derived targets), so re-runs are safe.
|
||||
- `Down()` reverses by repointing compositions to `ParentTemplateId` and
|
||||
dropping the derived templates.
|
||||
|
||||
The migration was NOT verified against a live SQL Server in this
|
||||
session — run `bash docker/deploy.sh` (or `dotnet ef database update`)
|
||||
once with seeded test data to confirm shape.
|
||||
|
||||
Files touched in `5615f3d`:
|
||||
|
||||
- `src/ScadaLink.Commons/Entities/Templates/Template.cs`
|
||||
- Added `IsDerived: bool`
|
||||
- Added `OwnerCompositionId: int?` (plain int — not an EF nav prop)
|
||||
- `src/ScadaLink.Commons/Entities/Templates/TemplateAttribute.cs`
|
||||
- Added `IsInherited: bool`
|
||||
- Added `LockedInDerived: bool`
|
||||
- `src/ScadaLink.Commons/Entities/Templates/TemplateScript.cs`
|
||||
- Same two fields
|
||||
- `src/ScadaLink.ConfigurationDatabase/Migrations/20260512121446_AddDerivedTemplateFields.cs`
|
||||
- EF Core migration. Six new columns, all NOT NULL DEFAULT 0 (or nullable
|
||||
int). No data transform — existing rows get defaults.
|
||||
- `ScadaLinkDbContextModelSnapshot.cs` regenerated.
|
||||
|
||||
**No behavior changes**. New fields are never read or written yet.
|
||||
|
||||
## Done — Phase 4+5: Flattening + lock enforcement
|
||||
|
||||
Commit: `f599809`.
|
||||
|
||||
- `FlatteningService.ResolveInheritedAttributes` / `ResolveInheritedScripts`
|
||||
treat `IsInherited=true` rows as placeholders that don't shadow the
|
||||
resolved base value. Override (`IsInherited=false`) wins as before.
|
||||
- `ValidateLockedInDerived` runs once per chain (main + every composed
|
||||
chain) and returns a flatten-time failure if a derived row overrides
|
||||
a `LockedInDerived` base member.
|
||||
- `TemplateService.UpdateAttributeAsync` / `UpdateScriptAsync` reject
|
||||
derived-side overrides of `LockedInDerived` base members, and now
|
||||
persist `IsInherited` (on derived) / `LockedInDerived` (on base) from
|
||||
the proposed payload so the UI can drive override state.
|
||||
|
||||
## Done — Phase 6: Template tree hides derived
|
||||
|
||||
Commit: `f05b03f` (combined with phases 7+8).
|
||||
|
||||
`Templates.razor` filters `t.IsDerived` from the main tree. A "Show
|
||||
derived" form-switch in the page header flips the filter — derived
|
||||
templates surface in the flat list so users can still reach them.
|
||||
|
||||
## Done — Phase 7+8: Derived/base TemplateEdit UI
|
||||
|
||||
Commit: `f05b03f`.
|
||||
|
||||
- Derived banner: links to base + slot owner / instance name from
|
||||
`OwnerCompositionId`.
|
||||
- Attributes / Scripts tables grew a context-aware column:
|
||||
* Derived: Source badge (Inherited / Override / Local), plus a
|
||||
"🔒 Base-locked" badge when `LockedInDerived`.
|
||||
* Base: a form-switch that flips `LockedInDerived` through
|
||||
`UpdateAttribute` / `UpdateScript`.
|
||||
- Effective Value / Code resolves from the base when the derived row
|
||||
carries an inherited (potentially stale) copy — matches the runtime
|
||||
flatten behavior so the UI doesn't lie.
|
||||
- Override and Revert-to-base actions live on the row kebab. Delete is
|
||||
hidden on inherited rows (the base owns those).
|
||||
- "When a base toggles LockedInDerived while derivatives override the
|
||||
field, warn via toast" is NOT implemented — kept out of scope; flatten
|
||||
validation already surfaces it at deploy time.
|
||||
|
||||
## Done — Phase 9: Single-parent editor context
|
||||
|
||||
Commit: `a965d4a`.
|
||||
|
||||
- `BuildParentContextsAsync` resolves the editor's `Parent.*` context
|
||||
to exactly one entry for derived templates (via `OwnerCompositionId`)
|
||||
and to an empty list for base templates.
|
||||
- Multi-parent `<select>` dropdown removed from the Add Script form.
|
||||
- `_selectedParentIndex` / `OnParentContextChanged` deleted;
|
||||
`ActiveEditorParent` collapses to `_editorParents.FirstOrDefault()`.
|
||||
- The SCADA008 hint diagnostic on `Parent.*` use within base templates
|
||||
was NOT added in this pass — the analyzer simply emits no completions
|
||||
when the parent context is empty. Add it later if users want a
|
||||
positive nudge.
|
||||
|
||||
## Still to verify
|
||||
|
||||
- Apply the Phase-3 migration against a real SQL Server (run
|
||||
`bash docker/deploy.sh` or `dotnet ef database update`) with seeded
|
||||
data to confirm `MigrateCompositionsToDerived` produces the right
|
||||
shape and respects the collision pre-check.
|
||||
- Smoke-test the UI flows: add a composition, override an attribute,
|
||||
revert, toggle `LockedInDerived` on a base, edit a script on a
|
||||
derived template (single-parent context).
|
||||
|
||||
## How to resume
|
||||
|
||||
A future session should:
|
||||
|
||||
1. Read this file and the design doc.
|
||||
2. Run `git log --oneline -15` to confirm the branch is at `a965d4a` or
|
||||
later.
|
||||
3. Run the three test suites named above.
|
||||
4. Ask the user whether to ship or to address one of the deferred items
|
||||
("when base toggles LockedInDerived while derivatives override",
|
||||
SCADA008 base-Parent hint, or the live-DB / UI smoke verifications).
|
||||
|
||||
## Quick sanity script
|
||||
|
||||
```bash
|
||||
git status --short # should be clean
|
||||
git log --oneline -10 # top should include a965d4a
|
||||
dotnet build src/ScadaLink.CentralUI src/ScadaLink.TemplateEngine src/ScadaLink.ConfigurationDatabase
|
||||
dotnet test tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj
|
||||
dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj
|
||||
dotnet test tests/ScadaLink.SiteRuntime.Tests/ScadaLink.SiteRuntime.Tests.csproj
|
||||
```
|
||||
|
||||
Note: the full `dotnet build` of the solution fails with NU1608 in
|
||||
`ScadaLink.IntegrationTests` and `ScadaLink.Host.Tests` due to a
|
||||
pre-existing `Microsoft.CodeAnalysis.Common` 4.13 vs 5.0 mismatch — not
|
||||
related to the derive-on-compose work. Build the three suites listed in
|
||||
"Where we are" individually.
|
||||
@@ -0,0 +1,293 @@
|
||||
# OPC UA Endpoint Config Model & Form Refactor — Design
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Branch**: `feature/templates-folder-hierarchy` (and successors)
|
||||
**Status**: Design approved, ready for implementation planning
|
||||
|
||||
## Problem
|
||||
|
||||
`DataConnection.PrimaryConfiguration` and `BackupConfiguration` are free-form JSON strings. Today:
|
||||
|
||||
- The site-side runtime (`OpcUaDataConnection.cs:44-90`) parses them as a flat `IDictionary<string,string>` and string-fishes ~12 keys (`endpoint` / `EndpointUrl`, `SessionTimeoutMs`, `SecurityMode`, `AutoAcceptUntrustedCerts`, etc.).
|
||||
- The Central UI form (`DataConnectionForm.razor`) edits them as plain textareas. Its placeholder hints are inconsistent: `{"endpoint":"opc.tcp://..."}` for primary but `{"Host":"backup-host","Port":50101}` for backup — the latter is **not** actually parsed by the runtime.
|
||||
- There is no schema, no validator, no documentation that's actually checked by code.
|
||||
- The form's Protocol dropdown still offers "Custom" although no backend adapter exists — selecting it produces a deploy-time `"Unknown protocol type: Custom"` failure.
|
||||
|
||||
We want a strongly-typed model for OPC UA endpoint configuration, a validator that's the single source of truth for what's legal, and a form that renders typed controls per field instead of a JSON blob.
|
||||
|
||||
## Decision summary
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|----------|--------|
|
||||
| 1 | Scope of the model | **Single source of truth** — used by both UI and runtime. Drops the dictionary-key string-fishing in `OpcUaDataConnection.cs`. |
|
||||
| 2 | Field coverage in the form | **All fields, grouped**: Connection / Timing / Subscription / Heartbeat. Sensible defaults pre-filled. |
|
||||
| 3 | Custom protocol option | **Remove from dropdown**. OPC UA is the only supported protocol today. |
|
||||
| 4 | Storage format | **Typed nested JSON** via System.Text.Json with camelCase + `JsonStringEnumConverter`. |
|
||||
| 5 | Model location | **`ScadaLink.Commons/Types/DataConnections/`** plus a sibling Validators/Serialization namespace. |
|
||||
| 6 | Validator return type | **`ValidationResult` + `ValidationEntry`** — matches `SemanticValidator` convention. |
|
||||
| 7 | Form structure | **Shared `<OpcUaEndpointEditor>` Blazor component**, used twice (primary + backup). |
|
||||
| 8 | Protocol field in UI | **Hidden**; entity field set to `"OpcUa"` implicitly on save. |
|
||||
| 9 | Validation timing | **On Save click only**. No live per-field validation. |
|
||||
| 10 | Legacy-row handling | **Best-effort parse + warning banner**. Save rewrites to the new shape. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ ScadaLink.Commons │
|
||||
│ Types/DataConnections/ │
|
||||
│ OpcUaEndpointConfig.cs (POCO) │
|
||||
│ OpcUaHeartbeatConfig.cs (POCO) │
|
||||
│ OpcUaSecurityMode.cs (enum) │
|
||||
│ Validators/ │
|
||||
│ OpcUaEndpointConfigValidator.cs │
|
||||
│ Serialization/ │
|
||||
│ OpcUaEndpointConfigSerializer.cs │
|
||||
└──────────────────────────────────────┘
|
||||
▲
|
||||
│ (referenced by both)
|
||||
┌───────┴────────────────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────────────┐ ┌────────────────────────────┐
|
||||
│ ScadaLink.CentralUI │ │ ScadaLink.SiteRuntime │
|
||||
│ Components/Forms/ │ │ Actors/ │
|
||||
│ OpcUaEndpointEditor │ │ DeploymentManagerActor │
|
||||
│ .razor (shared) │ │ (passes raw JSON to │
|
||||
│ │ │ DataConnectionFactory)│
|
||||
│ Pages/Admin/ │ │ │
|
||||
│ DataConnectionForm │ │ DataConnections.OpcUa/ │
|
||||
│ .razor │ │ OpcUaDataConnection.cs │
|
||||
└──────────────────────────┘ │ (consumes typed model) │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
Both sides deserialize from `DataConnection.PrimaryConfiguration` / `BackupConfiguration` strings into the same `OpcUaEndpointConfig` instance. The DB column type does not change.
|
||||
|
||||
## The model
|
||||
|
||||
```csharp
|
||||
// ScadaLink.Commons/Types/DataConnections/OpcUaEndpointConfig.cs
|
||||
namespace ScadaLink.Commons.Types.DataConnections;
|
||||
|
||||
public sealed class OpcUaEndpointConfig
|
||||
{
|
||||
// Connection
|
||||
public string EndpointUrl { get; set; } = "";
|
||||
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
|
||||
public bool AutoAcceptUntrustedCerts { get; set; } = true;
|
||||
|
||||
// Timing
|
||||
public int SessionTimeoutMs { get; set; } = 60000;
|
||||
public int OperationTimeoutMs { get; set; } = 15000;
|
||||
|
||||
// Subscription
|
||||
public int PublishingIntervalMs { get; set; } = 1000;
|
||||
public int SamplingIntervalMs { get; set; } = 1000;
|
||||
public int QueueSize { get; set; } = 10;
|
||||
public int KeepAliveCount { get; set; } = 10;
|
||||
public int LifetimeCount { get; set; } = 30;
|
||||
public int MaxNotificationsPerPublish { get; set; } = 100;
|
||||
|
||||
// Heartbeat (optional)
|
||||
public OpcUaHeartbeatConfig? Heartbeat { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OpcUaHeartbeatConfig
|
||||
{
|
||||
public string TagPath { get; set; } = "";
|
||||
public int MaxSilenceSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
public enum OpcUaSecurityMode { None, Sign, SignAndEncrypt }
|
||||
```
|
||||
|
||||
Defaults match the runtime's current fallbacks so a default-constructed config equals the empty/missing-JSON case. Settable properties (not `init`) so the form can `@bind` directly.
|
||||
|
||||
## The validator
|
||||
|
||||
```csharp
|
||||
// ScadaLink.Commons/Validators/OpcUaEndpointConfigValidator.cs
|
||||
public static class OpcUaEndpointConfigValidator
|
||||
{
|
||||
public static ValidationResult Validate(OpcUaEndpointConfig config, string fieldPrefix = "")
|
||||
{
|
||||
var errors = new List<ValidationEntry>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.EndpointUrl))
|
||||
errors.Add(Err("EndpointUrl", "Endpoint URL is required."));
|
||||
else if (!Uri.TryCreate(config.EndpointUrl, UriKind.Absolute, out var uri)
|
||||
|| uri.Scheme != "opc.tcp")
|
||||
errors.Add(Err("EndpointUrl",
|
||||
"Endpoint URL must be a valid opc.tcp:// URI."));
|
||||
|
||||
if (config.SessionTimeoutMs <= 0)
|
||||
errors.Add(Err("SessionTimeoutMs", "Must be > 0."));
|
||||
if (config.OperationTimeoutMs <= 0)
|
||||
errors.Add(Err("OperationTimeoutMs", "Must be > 0."));
|
||||
if (config.PublishingIntervalMs <= 0)
|
||||
errors.Add(Err("PublishingIntervalMs", "Must be > 0."));
|
||||
if (config.SamplingIntervalMs <= 0)
|
||||
errors.Add(Err("SamplingIntervalMs", "Must be > 0."));
|
||||
if (config.QueueSize < 1)
|
||||
errors.Add(Err("QueueSize", "Must be ≥ 1."));
|
||||
if (config.KeepAliveCount < 1)
|
||||
errors.Add(Err("KeepAliveCount", "Must be ≥ 1."));
|
||||
if (config.LifetimeCount < config.KeepAliveCount * 3)
|
||||
errors.Add(Err("LifetimeCount",
|
||||
"Must be at least 3× KeepAliveCount per OPC UA spec."));
|
||||
if (config.MaxNotificationsPerPublish < 1)
|
||||
errors.Add(Err("MaxNotificationsPerPublish", "Must be ≥ 1."));
|
||||
|
||||
if (config.Heartbeat is { } hb)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hb.TagPath))
|
||||
errors.Add(Err("Heartbeat.TagPath",
|
||||
"Tag path is required when heartbeat is enabled."));
|
||||
if (hb.MaxSilenceSeconds <= 0)
|
||||
errors.Add(Err("Heartbeat.MaxSilenceSeconds", "Must be > 0."));
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ValidationResult.Success()
|
||||
: ValidationResult.FromErrors(errors);
|
||||
|
||||
ValidationEntry Err(string field, string msg) =>
|
||||
new(Field: $"{fieldPrefix}{field}",
|
||||
Message: msg,
|
||||
Category: ValidationCategory.Schema);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `fieldPrefix` parameter — form passes `"Primary."` / `"Backup."` so error messages disambiguate.
|
||||
- `LifetimeCount ≥ 3 × KeepAliveCount` is an actual OPC UA spec constraint and exemplifies the "domain knowledge in the validator" win.
|
||||
- Static, pure, no DI — trivial to unit-test.
|
||||
|
||||
## Serialization & legacy fallback
|
||||
|
||||
```csharp
|
||||
// ScadaLink.Commons/Serialization/OpcUaEndpointConfigSerializer.cs
|
||||
public static class OpcUaEndpointConfigSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public static string Serialize(OpcUaEndpointConfig config)
|
||||
=> JsonSerializer.Serialize(config, JsonOpts);
|
||||
|
||||
public static (OpcUaEndpointConfig Config, bool IsLegacy) Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return (new OpcUaEndpointConfig(), false);
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("endpointUrl", out _))
|
||||
return (JsonSerializer.Deserialize<OpcUaEndpointConfig>(json, JsonOpts)!, false);
|
||||
}
|
||||
catch (JsonException) { /* fall through */ }
|
||||
|
||||
return (LoadLegacy(json), IsLegacy: true);
|
||||
}
|
||||
|
||||
private static OpcUaEndpointConfig LoadLegacy(string json)
|
||||
{
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json)
|
||||
?? new();
|
||||
var c = new OpcUaEndpointConfig
|
||||
{
|
||||
EndpointUrl = dict.GetValueOrDefault("endpoint")
|
||||
?? dict.GetValueOrDefault("EndpointUrl") ?? "",
|
||||
SecurityMode = Enum.TryParse<OpcUaSecurityMode>(
|
||||
dict.GetValueOrDefault("SecurityMode"), out var sm) ? sm : OpcUaSecurityMode.None,
|
||||
AutoAcceptUntrustedCerts = ParseBool(dict, "AutoAcceptUntrustedCerts", true),
|
||||
SessionTimeoutMs = ParseInt(dict, "SessionTimeoutMs", 60000),
|
||||
OperationTimeoutMs = ParseInt(dict, "OperationTimeoutMs", 15000),
|
||||
PublishingIntervalMs = ParseInt(dict, "PublishingIntervalMs", 1000),
|
||||
SamplingIntervalMs = ParseInt(dict, "SamplingIntervalMs", 1000),
|
||||
QueueSize = ParseInt(dict, "QueueSize", 10),
|
||||
KeepAliveCount = ParseInt(dict, "KeepAliveCount", 10),
|
||||
LifetimeCount = ParseInt(dict, "LifetimeCount", 30),
|
||||
MaxNotificationsPerPublish = ParseInt(dict, "MaxNotificationsPerPublish", 100)
|
||||
};
|
||||
var hbPath = dict.GetValueOrDefault("HeartbeatTagPath");
|
||||
if (!string.IsNullOrWhiteSpace(hbPath))
|
||||
c.Heartbeat = new OpcUaHeartbeatConfig
|
||||
{
|
||||
TagPath = hbPath,
|
||||
MaxSilenceSeconds = ParseInt(dict, "HeartbeatMaxSilence", 30)
|
||||
};
|
||||
return c;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Deserialize` returns `(Config, IsLegacy)`. The form raises a Bootstrap warning banner when `IsLegacy=true`. On Save we always `Serialize` — the row gets rewritten to the new shape and the banner disappears on next edit.
|
||||
|
||||
## The shared Blazor component
|
||||
|
||||
`src/ScadaLink.CentralUI/Components/Forms/OpcUaEndpointEditor.razor`
|
||||
|
||||
Parameters:
|
||||
- `Config` (`[EditorRequired]`) — bound by reference; parent owns the instance.
|
||||
- `Title` — header text (e.g. "Primary Endpoint").
|
||||
- `IdPrefix` — disambiguates `for=` attributes when the component appears twice.
|
||||
- `IsLegacy` — toggles the warning banner.
|
||||
- `Errors` (`ValidationResult?`) — drives per-field red text via `EndsWith("." + field)` match against `ValidationEntry.Field`.
|
||||
|
||||
Rendering: four section labels (Connection, Timing, Subscription, Heartbeat) with Bootstrap `row g-2` grids. Heartbeat starts collapsed behind an "Enable Heartbeat" button; once shown it has a "Remove Heartbeat" button. Per-field error text appears immediately below each control.
|
||||
|
||||
## DataConnectionForm changes
|
||||
|
||||
- **Removed**: Protocol `<select>`, the JSON `<textarea>` for primary, the JSON `<textarea>` for backup.
|
||||
- **Added**: Two `<OpcUaEndpointEditor>` instances. The backup one is still gated behind "Add Backup Endpoint" / "Remove Backup" buttons, and `Failover Retry Count` stays in the backup subsection.
|
||||
- **Code-behind**: `_primaryConfig` and `_backupConfig` (`OpcUaEndpointConfig` instances), `_primaryIsLegacy`/`_backupIsLegacy` flags, `_primaryErrors`/`_backupErrors` (`ValidationResult?`). Save runs the validator on both, bails out on failure, serializes via `OpcUaEndpointConfigSerializer.Serialize`.
|
||||
- **Protocol on the entity** is set to the literal `"OpcUa"` on create. The column stays so the runtime's protocol-dispatch (`DataConnectionFactory`) is untouched.
|
||||
|
||||
## Runtime parser swap
|
||||
|
||||
`src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:426-456` — today this code parses both JSON strings into `Dictionary<string, string>` and hands the dict to `DataConnectionFactory`.
|
||||
|
||||
After the change:
|
||||
- `DeploymentManagerActor` no longer parses JSON. It passes the raw `PrimaryConfiguration` / `BackupConfiguration` strings straight to the factory.
|
||||
- `DataConnectionFactory.Create` (OPC UA branch) calls `OpcUaEndpointConfigSerializer.Deserialize(...)`, gets the typed model, and constructs `OpcUaDataConnection` with it.
|
||||
- `OpcUaDataConnection.cs:44-90` is rewritten to take `OpcUaEndpointConfig` directly. The `connectionDetails.TryGetValue(...)` ladder and the `ParseInt` / `ParseBool` helpers go away. Heartbeat becomes `if (cfg.Heartbeat is { } hb) { ... }`.
|
||||
|
||||
Pre-refactor deployment artifacts still load: the serializer's legacy-dict fallback handles them. `IsLegacy` is discarded by the runtime (only the form cares).
|
||||
|
||||
## Tests
|
||||
|
||||
| Project | New / changed tests |
|
||||
|---|---|
|
||||
| `ScadaLink.Commons.Tests` | `OpcUaEndpointConfigSerializerTests`: typed-JSON roundtrip preserves all fields; legacy flat-dict deserializes correctly and sets `IsLegacy=true`; empty/null JSON returns defaults; unknown JSON shape falls back cleanly. |
|
||||
| `ScadaLink.Commons.Tests` | `OpcUaEndpointConfigValidatorTests`: missing URL → error; bad scheme → error; `LifetimeCount < 3×KeepAliveCount` → error; heartbeat-enabled-but-no-tag-path → error; valid config → `IsValid=true`; `fieldPrefix` applied to every error's `Field`. |
|
||||
| `ScadaLink.CentralUI.Tests` | `OpcUaEndpointEditorTests` (bUnit): renders all grouped sections; binding mutates the passed `Config`; Enable/Remove Heartbeat toggles the sub-object; passing `Errors` renders per-field red text; `IsLegacy=true` shows the warning banner. |
|
||||
| `ScadaLink.CentralUI.Tests` | `DataConnectionsFormTests` (bUnit, add if missing): Save with invalid primary URL → no navigation, validator error shown; Save with valid config → repo `AddDataConnectionAsync` called with `Protocol="OpcUa"` and JSON containing `"endpointUrl"` in camelCase. |
|
||||
| Site/DCL test project | Update existing tests to construct `OpcUaDataConnection` from `OpcUaEndpointConfig` instead of `IDictionary<string,string>`. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No EF Core migration; the legacy-parse path handles pre-existing rows.
|
||||
- No new protocols. Custom dropdown option is removed. If/when a second protocol lands, the form re-introduces a protocol dropdown and a `if (Protocol == "OpcUa")` branch around the editor component.
|
||||
- No live (debounced) validation.
|
||||
- No certificate management UI beyond `AutoAcceptUntrustedCerts`.
|
||||
- No "Verify endpoint" button.
|
||||
- No rewrite of `docs/requirements/Component-DataConnectionLayer.md` — a short note pointing at `OpcUaEndpointConfig` as the canonical schema is enough.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `dotnet build` clean.
|
||||
2. `dotnet test` for Commons + CentralUI + SiteRuntime/DCL — all green, including new tests.
|
||||
3. `bash docker/deploy.sh` — rebuild cluster.
|
||||
4. Browser smoke at `http://localhost:9000/admin/connections`:
|
||||
- New connection via site context menu → form shows the OPC UA endpoint editor; no Protocol dropdown.
|
||||
- Bad URL → Save → red error under Endpoint URL.
|
||||
- Valid config, toggle heartbeat, set timing knobs → Save → row created; reload → fields round-trip.
|
||||
- Edit a pre-refactor row → warning banner appears, fields populated from legacy dict; Save rewrites; second edit no banner.
|
||||
- Add backup endpoint, save, deploy a template that uses the connection → site logs show primary online and failover settings honored.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-05-12-opcua-config-model.md",
|
||||
"tasks": [
|
||||
{"id": 45, "subject": "Task 1: Create OPC UA config POCOs + ValidationCategory.ConnectionConfig", "status": "pending"},
|
||||
{"id": 46, "subject": "Task 2: TDD failing tests for OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [45]},
|
||||
{"id": 47, "subject": "Task 3: Implement OpcUaEndpointConfigSerializer", "status": "pending", "blockedBy": [46]},
|
||||
{"id": 48, "subject": "Task 4: TDD failing tests for OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [45]},
|
||||
{"id": 49, "subject": "Task 5: Implement OpcUaEndpointConfigValidator", "status": "pending", "blockedBy": [48]},
|
||||
{"id": 50, "subject": "Task 6: Refactor OpcUaDataConnection.ConnectAsync to use FromFlatDict", "status": "pending", "blockedBy": [47]},
|
||||
{"id": 51, "subject": "Task 7: Refactor DeploymentManagerActor.EnsureDclConnections", "status": "pending", "blockedBy": [47]},
|
||||
{"id": 52, "subject": "Task 8: TDD failing bUnit tests for OpcUaEndpointEditor", "status": "pending", "blockedBy": [45, 49]},
|
||||
{"id": 53, "subject": "Task 9: Implement OpcUaEndpointEditor.razor", "status": "pending", "blockedBy": [52]},
|
||||
{"id": 54, "subject": "Task 10: TDD failing bUnit tests for DataConnectionForm refactor", "status": "pending", "blockedBy": [47, 49]},
|
||||
{"id": 55, "subject": "Task 11: Refactor DataConnectionForm.razor", "status": "pending", "blockedBy": [53, 54]},
|
||||
{"id": 56, "subject": "Task 12: Solution build + all test suites green", "status": "pending", "blockedBy": [50, 51, 55]},
|
||||
{"id": 57, "subject": "Task 13: Docker deploy + browser smoke", "status": "pending", "blockedBy": [56]},
|
||||
{"id": 58, "subject": "Task 14: Push to origin", "status": "pending", "blockedBy": [57]}
|
||||
],
|
||||
"lastUpdated": "2026-05-12T04:33:33Z"
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
# Script parameter / return: JSON Schema + JSONJoy editor
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Superseded — see "Reversal: native Blazor SchemaBuilder" below.
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the custom `ParameterListEditor` / `ReturnTypeEditor` Blazor components
|
||||
with [`jsonjoy-builder`](https://github.com/lovasoa/jsonjoy-builder) (`SchemaVisualEditor`),
|
||||
embedded as a React island. The on-disk format for `TemplateScript.ParameterDefinitions`
|
||||
and `TemplateScript.ReturnDefinition` changes from the project-local flat shape
|
||||
(`[{name,type,required,itemType?}]` / `{type,itemType?}`) to standard JSON Schema.
|
||||
|
||||
## Rationale
|
||||
|
||||
The existing flat shape lacked descriptions, defaults, enums, nested objects,
|
||||
and arrays of structured items. JSON Schema covers all of that, is the
|
||||
industry vocabulary other tooling already speaks (OpenAPI 3.1, function-calling
|
||||
APIs, validators), and `jsonjoy-builder` is a polished pre-built visual editor
|
||||
for it.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **Breaks the no-UI-framework rule for this feature.** `jsonjoy-builder` is
|
||||
React 19 + Radix UI + Tailwind. Accepted: the island is isolated to one
|
||||
modal panel, Tailwind is shipped pre-built (no toolchain shared with the
|
||||
Blazor side), and the visual delta is contained.
|
||||
- **New build pipeline.** A small Vite project under `src/ScadaLink.CentralUI/Schema.Editor/`
|
||||
builds a single IIFE bundle into `wwwroot/lib/schema-editor/`. Output is
|
||||
committed so `dotnet build` doesn't require Node.
|
||||
- **Monaco overlap.** `jsonjoy-builder` depends on `@monaco-editor/react`,
|
||||
which depends on `monaco-editor`. We already load Monaco globally for the
|
||||
script code editor. The island calls `@monaco-editor/react`'s `loader.config({ monaco: window.monaco })`
|
||||
at boot to reuse the same instance — no duplicate Monaco download.
|
||||
|
||||
## Storage format change
|
||||
|
||||
| Field | Before | After |
|
||||
| ---------------------- | --------------------------------- | ----------------------------------------------------------- |
|
||||
| `ParameterDefinitions` | `[{name,type,required,itemType?}]` | `{"type":"object","properties":{...},"required":[...]}` |
|
||||
| `ReturnDefinition` | `{type,itemType?}` | Any JSON Schema (root `type` describes the returned value) |
|
||||
|
||||
Per the chosen rollout: **one-shot migration** rewrites all existing rows on
|
||||
deploy. After the migration, the analysis pipeline reads JSON Schema only —
|
||||
no dual-format support code.
|
||||
|
||||
Type mapping (flat → JSON Schema):
|
||||
|
||||
| Flat type | JSON Schema |
|
||||
| --------- | ----------- |
|
||||
| `Boolean` | `{"type":"boolean"}` |
|
||||
| `Integer` | `{"type":"integer"}` |
|
||||
| `Float` | `{"type":"number"}` |
|
||||
| `String` | `{"type":"string"}` |
|
||||
| `Object` | `{"type":"object"}` |
|
||||
| `List` of X | `{"type":"array","items":{"type":<X>}}` |
|
||||
|
||||
`required: false` ⇒ name omitted from the `required` array.
|
||||
`required: true` (default) ⇒ name added to `required`.
|
||||
|
||||
## Component layout
|
||||
|
||||
```
|
||||
src/ScadaLink.CentralUI/Schema.Editor/ ← new Vite project (committed)
|
||||
package.json
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
src/main.tsx ← exposes window.ScadaSchemaEditor
|
||||
src/SchemaEditorApp.tsx
|
||||
src/index.css
|
||||
.gitignore ← node_modules only
|
||||
dist/ ← (Vite outputs to wwwroot, not here)
|
||||
|
||||
src/ScadaLink.CentralUI/wwwroot/lib/schema-editor/
|
||||
schema-editor.js ← built IIFE, committed
|
||||
schema-editor.css
|
||||
|
||||
src/ScadaLink.CentralUI/Components/Shared/
|
||||
SchemaEditor.razor ← Blazor wrapper; mirrors MonacoEditor.razor
|
||||
|
||||
src/ScadaLink.CentralUI/ScriptAnalysis/
|
||||
ScriptShapeParser.cs ← rewrite to read JSON Schema
|
||||
src/ScadaLink.CentralUI/Components/Shared/
|
||||
ScriptParameterNames.cs ← rewrite to read JSON Schema
|
||||
```
|
||||
|
||||
Removed after rollout: `ParameterListEditor.razor`, `ReturnTypeEditor.razor`.
|
||||
|
||||
## JS interop contract
|
||||
|
||||
```ts
|
||||
window.ScadaSchemaEditor = {
|
||||
mount(id: string, host: HTMLElement, options: {
|
||||
value: string; // current schema JSON (may be empty)
|
||||
mode: 'parameters' | 'return';
|
||||
readOnly?: boolean;
|
||||
}, dotNetRef: { invokeMethodAsync(name: 'OnValueChanged', json: string): Promise<void> }): void;
|
||||
setValue(id: string, value: string): void;
|
||||
dispose(id: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
EF Core migration in `ScadaLink.ConfigurationDatabase` reads
|
||||
`TemplateScripts.ParameterDefinitions` and `ReturnDefinition` from every row,
|
||||
sniffs format (array vs object), translates if legacy, writes back. Idempotent:
|
||||
re-running a row already in JSON Schema is a no-op. Runs once at deploy via
|
||||
the existing auto-apply path.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Schema-driven value-entry forms (e.g. Inbound API tester) — would also use
|
||||
`jsonjoy-builder`'s value-editor mode, but no caller surface needs it today.
|
||||
- Hover/completion enhancements derived from JSON Schema descriptions or
|
||||
defaults. Today's pipeline only needs name + type + required.
|
||||
- Reuse of JSON Schema `$ref` across templates — could be a future template-level
|
||||
schema library.
|
||||
|
||||
---
|
||||
|
||||
## Reversal: native Blazor SchemaBuilder (2026-05-12, same day)
|
||||
|
||||
JSONJoy worked but felt heavy for the actual data we author here. Specifically:
|
||||
|
||||
- The "Add Field" modal flow is two clicks per parameter where the legacy
|
||||
inline-row editor was zero. For the common 1-3 scalar-param case, a visible
|
||||
modal dialog every time is friction.
|
||||
- JSONJoy's value-mode UX is awkward — it always renders an "Add Field" button
|
||||
even when the schema's root type is `string` / `integer` / etc., so the
|
||||
Return-type tab is mismatched to the underlying single-value model.
|
||||
- React 19 + Radix + Tailwind for one form field is a lot of build pipeline
|
||||
surface to maintain.
|
||||
|
||||
**Decision:** replace JSONJoy with a Bootstrap-only Blazor component
|
||||
(`SchemaBuilder.razor`) that recurses through its own render methods.
|
||||
Storage format unchanged — still JSON Schema. The migration, parser, and
|
||||
downstream analysis code are untouched.
|
||||
|
||||
**Scope decisions (from refinement session):**
|
||||
|
||||
- Type set: only the six JSON Schema primitives
|
||||
(`string · integer · number · boolean · object · array`). No `date-time` /
|
||||
`format`, no `enum` / `pattern` / `min/max`, no `$ref` / `oneOf` /
|
||||
`anyOf` / `allOf`, no `additionalProperties`. Power-user expansion can
|
||||
come later behind a per-row "more options" toggle.
|
||||
- No description support per property. The row stays a single horizontal
|
||||
line: name + type + (items: type if array) + required + remove.
|
||||
- Nested objects and arrays-of-objects recurse — same editor renders at any
|
||||
depth.
|
||||
|
||||
**Files added:**
|
||||
|
||||
- `src/ScadaLink.CentralUI/Components/Shared/SchemaBuilderModel.cs` —
|
||||
in-memory `SchemaNode` / `SchemaProperty` tree plus pure-static
|
||||
parse / serialize. Round-trips through the canonical JSON Schema text and
|
||||
tolerates legacy flat-array shape as a parse fallback.
|
||||
- `src/ScadaLink.CentralUI/Components/Shared/SchemaBuilder.razor` —
|
||||
recursive renderer driven by `Mode="object"` (parameter list) or
|
||||
`Mode="value"` (single value, with object/array falling back to the
|
||||
property editor).
|
||||
- `tests/ScadaLink.CentralUI.Tests/Shared/SchemaBuilderModelTests.cs` —
|
||||
parse / serialize / round-trip / legacy-array coverage.
|
||||
|
||||
**Files removed:**
|
||||
|
||||
- `src/ScadaLink.CentralUI/Schema.Editor/` (Vite project, node_modules, etc.)
|
||||
- `src/ScadaLink.CentralUI/wwwroot/lib/schema-editor/` (built bundle)
|
||||
- `src/ScadaLink.CentralUI/Components/Shared/SchemaEditor.razor` (Blazor wrapper)
|
||||
- `<script>` / `<link>` references to schema-editor in `App.razor`
|
||||
- `<DefaultItemExcludes>Schema.Editor/**` from CentralUI csproj
|
||||
|
||||
**Forms updated:** `TemplateEdit.razor`, `SharedScriptForm.razor`,
|
||||
`ApiMethodForm.razor` now use `<SchemaBuilder>` directly.
|
||||
|
||||
The original `jsonjoy-builder` integration sections above are kept for
|
||||
historical context but no longer reflect what's in the codebase.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Script scope access: self / child / parent
|
||||
|
||||
## Goal
|
||||
|
||||
Template scripts get an ergonomic read/write API for:
|
||||
|
||||
- The current template's attributes (`Attributes["X"]`).
|
||||
- Child composition attributes (`Children["TempSensor"].Attributes["Temperature"]`).
|
||||
- Child composition scripts (`Children["TempSensor"].CallScript("Sample")`).
|
||||
- The parent composition (when this template is composed inside another):
|
||||
`Parent.Attributes["SpeedRPM"]`, `Parent.CallScript("Trip")`.
|
||||
|
||||
Editor (Monaco) provides completion, hover, and diagnostics on all the above.
|
||||
|
||||
## What already exists
|
||||
|
||||
- Each `Template` has `Attributes`, `Compositions` (named sub-template references),
|
||||
`Scripts`, `Alarms`.
|
||||
- Flattening produces `ResolvedAttribute.CanonicalName` as the path-qualified name:
|
||||
direct attrs are bare, composed attrs are `"InstanceName.MemberName"`.
|
||||
- `InstanceActor` stores `_attributes[canonicalName]` — flat dict keyed by the
|
||||
fully composed canonical name.
|
||||
- `ScriptRuntimeContext.GetAttribute(name)` does a flat lookup. So
|
||||
`GetAttribute("TempSensor.Temperature")` already works if the canonical name
|
||||
is in the dict. **What's missing is scope-relative access** — a script on
|
||||
`TempSensor` cannot say "my Temperature" without knowing it's composed under
|
||||
some parent path.
|
||||
- `ScriptRuntimeContext.CallScript(name)` Ask-pattern-routes to a Script Actor.
|
||||
Cross-composition / parent routing is **not** implemented.
|
||||
- The actor topology is one Instance Actor per top-level instance — composed
|
||||
sub-templates are **flattened into the parent's actor state**, not separate
|
||||
actors. This is good news: parent/child access is path arithmetic, not
|
||||
ActorRef hopping.
|
||||
|
||||
## Runtime API (new)
|
||||
|
||||
Three accessors layered on `ScriptGlobals` (in addition to the existing
|
||||
`Instance.*`, `Parameters`, `Scripts.CallShared`, etc.):
|
||||
|
||||
```csharp
|
||||
Attributes["X"] // read; throws if missing
|
||||
Attributes["X"] = value // write
|
||||
Attributes.TryGet<T>("X", out v) // typed read with fallback
|
||||
Children["TempSensor"].Attributes["Temperature"]
|
||||
Children["TempSensor"].CallScript("Sample", new { count = 3 })
|
||||
Parent.Attributes["SpeedRPM"] // null check: Parent is null at the root
|
||||
Parent.CallScript("Trip")
|
||||
```
|
||||
|
||||
Internally each is a thin wrapper holding a `ScopePath` (string) plus a
|
||||
reference to `ScriptRuntimeContext`. The indexer / `CallScript` prepend the
|
||||
scope path to the key and delegate to the existing `Instance.GetAttribute` /
|
||||
`Instance.SetAttribute` / `Instance.CallScript`. No new actor messages, no
|
||||
new lookup pathway.
|
||||
|
||||
`Children["X"]` returns a new accessor with prefix `SelfPath + "." + X`.
|
||||
`Parent` returns an accessor with the parent prefix (`null` if no parent).
|
||||
Chained child/parent navigation works naturally because each accessor is the
|
||||
same type returning the same type.
|
||||
|
||||
## Compile-time scope injection
|
||||
|
||||
Every compiled script needs to know its own `ScopePath`. That's captured by
|
||||
the flattening pipeline and passed into `ScriptGlobals` at execution time:
|
||||
|
||||
```csharp
|
||||
public record ScriptScope(
|
||||
string SelfPath, // "" for root, "TempSensor" for composed
|
||||
string? ParentPath, // null if SelfPath == ""
|
||||
IReadOnlyList<string> ChildInstanceNames);
|
||||
```
|
||||
|
||||
`ResolvedScript` gains a `Scope: ScriptScope` field. The flattening service
|
||||
already walks the composition tree to compute canonical names — extending it
|
||||
to emit the scope per script is mechanical.
|
||||
|
||||
`ScriptCompilationService.Compile` reads the scope and seeds `ScriptGlobals.
|
||||
Attributes`, `Children`, `Parent` before the script runs. No code-generation;
|
||||
the accessors close over the scope path at construction time.
|
||||
|
||||
## Editor surface
|
||||
|
||||
The editor side carries the same metadata that the runtime gets:
|
||||
|
||||
- The current template's attribute set (names + types).
|
||||
- Each composition: instance name → resolved child template's attribute set
|
||||
AND script list. The form already loads compositions in `TemplateEdit`.
|
||||
- The parent template's attribute and script lists, ONLY when the open
|
||||
template is composed inside another. We surface this as `null` otherwise.
|
||||
|
||||
New completion contexts:
|
||||
|
||||
| In code | Suggests |
|
||||
|---|---|
|
||||
| `Attributes["X"]` | declared attribute names of current template |
|
||||
| `Children["X"]` | composition instance names |
|
||||
| `Children["X"].Attributes["Y"]` | attribute names of the resolved child template |
|
||||
| `Children["X"].CallScript("Y"` | script names of the resolved child template |
|
||||
| `Parent.Attributes["X"]` | parent template's attribute names |
|
||||
| `Parent.CallScript("X"` | parent template's script names |
|
||||
|
||||
New diagnostics:
|
||||
|
||||
- **SCADA006**: unknown attribute name on the appropriate scope.
|
||||
- **SCADA007**: unknown child composition name in `Children["X"]`.
|
||||
|
||||
Existing `Instance.GetAttribute("X")` / `Instance.CallScript("X")` keep working
|
||||
unchanged. Editor support for those can fall out of the same metadata if we
|
||||
want it.
|
||||
|
||||
## Hover + signature help
|
||||
|
||||
- Hover `Attributes["X"]` → `attribute X: <Type> on <TemplateName>`.
|
||||
- Hover `Children["X"]` → `composition X: <ChildTemplateName>`.
|
||||
- Signature help for `Children["X"].CallScript(...)` reuses the existing
|
||||
shape pipeline once the child template's scripts are reachable as
|
||||
`ScriptShape[]`.
|
||||
|
||||
## What needs to be passed from the form
|
||||
|
||||
`TemplateEdit` already loads the open template's attributes and scripts.
|
||||
Two new pieces:
|
||||
|
||||
1. **Resolved child compositions**: for each `Composition` row, fetch the
|
||||
composed template's `Attributes` and `Scripts`. The repository already
|
||||
has `GetTemplateByIdAsync` — call it for each composition.
|
||||
2. **Parent template (if any)**: query the repository for templates that
|
||||
compose this one. If exactly one, pass its shape. If multiple or none,
|
||||
pass `null` and emit `Parent.X` accesses as diagnostics-by-the-user (since
|
||||
the parent context is ambiguous in design time — the runtime knows because
|
||||
it's running inside one specific deployment, but the editor doesn't).
|
||||
|
||||
Edge case: a template composed into multiple parents has no single parent at
|
||||
edit time. Acceptable behaviour: `Parent` autocompletion is suppressed; using
|
||||
it still compiles but emits a warning at deploy time. Document this clearly.
|
||||
|
||||
## Phased rollout
|
||||
|
||||
1. **Runtime first**. Add `Attributes` / `Children` / `Parent` accessors.
|
||||
Wire scope into `ResolvedScript` and the flattening pipeline. Test with
|
||||
existing flat templates (Scope.SelfPath = ""). Verify a composed script's
|
||||
`Attributes["Temperature"]` reads through correctly. Site-runtime tests.
|
||||
|
||||
2. **Flattening + deployment**. Verify the deployed artifact carries the new
|
||||
`Scope` field through `ResolvedScript` → `FlattenedScript` → site-side.
|
||||
Run a round-trip deploy + execute.
|
||||
|
||||
3. **Editor metadata**. `TemplateEdit` fetches child template shapes for each
|
||||
composition and optionally the parent. New Monaco context fields.
|
||||
|
||||
4. **Editor completion + diagnostics**. New string-literal completion
|
||||
contexts. SCADA006 / SCADA007 diagnostics on the Diagnose path. Hover.
|
||||
|
||||
Each phase is a separate commit and independently shippable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Composing the same template multiple times under different names on the
|
||||
same parent (already supported by the data model; the editor just lists
|
||||
each composition).
|
||||
- Sibling-of-sibling access (`Children["A"].Parent.Children["B"]`). The
|
||||
accessor API supports it naturally but we don't actively suggest it.
|
||||
- Locking-aware writes (`Attributes["X"] = v` when X is locked). The
|
||||
attribute lock is enforced at deployment validation, not at script-write
|
||||
time; runtime writes that hit a locked attribute should reject. Out of
|
||||
scope for this design — covered by the existing lock-enforcement pass.
|
||||
- A formal type for `Children` / `Parent` in the editor's Roslyn analysis
|
||||
(the strict Roslyn route would auto-generate per-template accessor types).
|
||||
We use a dictionary-style indexer for both runtime and editor, with editor
|
||||
awareness coming from the metadata pipeline, not from per-template C#
|
||||
types.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `Attributes["X"]` throw or return `null` on unknown key? The
|
||||
existing `GetAttribute` logs a warning and returns `null`. Same here for
|
||||
consistency.
|
||||
- ~~Async vs sync indexer?~~ **Decided: both.** Sync `Attributes["X"]` /
|
||||
`Attributes["X"] = v` indexer for ergonomics, plus
|
||||
`Attributes.GetAsync("X")` / `Attributes.SetAsync("X", v)` for callers
|
||||
that want to be explicit about the actor Ask. The sync path internally
|
||||
blocks on `.GetAwaiter().GetResult()` — acceptable because all script
|
||||
bodies already run on a dedicated blocking-I/O dispatcher per the project
|
||||
conventions in CLAUDE.md.
|
||||
@@ -0,0 +1,554 @@
|
||||
# ScadaLink Central UI — Design & UX Audit
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Branch at audit time:** `feature/templates-folder-hierarchy` (after `Sites.razor` redesign, commit `0805e18`)
|
||||
**Scope:** All Razor pages, layout, and shared components in `src/ScadaLink.CentralUI`.
|
||||
**Reference pattern:** `src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor` — 2-column responsive card grid, header flex row, kebab menus, search filter, Bootstrap collapse for noisy details, `@key=` on iterated cards, "No X match the filter." and empty-state CTAs.
|
||||
|
||||
## Constraints (recap)
|
||||
|
||||
- Blazor Server + Bootstrap 5 only. **No third-party component frameworks** (no MudBlazor / Radzen / Blazorise / Syncfusion).
|
||||
- Clean, corporate, internal-use aesthetic. Not flashy.
|
||||
- Form pages: vertical stacking; read-only fields first; subsections stacked; buttons at bottom.
|
||||
- Accessibility: aria-labels on icon buttons; labels paired with inputs; semantic headings; never use color as the only state cue.
|
||||
|
||||
---
|
||||
|
||||
## Severity summary
|
||||
|
||||
| Severity | Count | Pages |
|
||||
|---|---|---|
|
||||
| **High** | 7 | LdapMappingForm · DataConnections (header/a11y) · SharedScripts · ExternalSystems · TemplateEdit · DebugView · EventLogs |
|
||||
| **Medium** | 11 | LdapMappings · ApiKeys · DataConnections · DataConnectionForm · ApiKeyForm (partial) · Templates · Topology · Deployments · Dashboard · Health · ParkedMessages · AuditLog · MainLayout / NavMenu · ConfirmDialog · Toast · global CSS |
|
||||
| **Low** | 7+ | Most form pages (TemplateCreate, ExternalSystemForm, SharedScriptForm, DbConnectionForm, ApiMethodForm, NotificationListForm) · Login error feedback · NotAuthorizedView · LoadingSpinner contrast · DataTable clear-button |
|
||||
|
||||
**Suggested implementation order** (high impact / low risk first):
|
||||
|
||||
1. **Shared shell fixes** (ConfirmDialog scroll-lock + Escape + default button color, Toast `aria-live` + custom delay, NavMenu scroll container, login vertical centering) — these unblock everything else and are mostly small.
|
||||
2. **List-page pattern roll-out:** apply the Sites.razor card grid + search + kebab template to LdapMappings, ApiKeys, SharedScripts. These are mechanical.
|
||||
3. **DebugView guardrails:** scroll-lock, max-row cap, `aria-live`, filter — this is high-severity and isolated.
|
||||
4. **EventLogs:** message expand, pagination clarity, filter accessibility.
|
||||
5. **ExternalSystems + TemplateEdit refactors** — biggest scope, leave for last because they need design discussion before implementation.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting findings (apply to many pages)
|
||||
|
||||
These show up everywhere. Fix at the pattern level first, then tour every page once to apply:
|
||||
|
||||
1. **`<h4>` page title in a flex header.** Sites.razor sets the standard at line 16. Currently Templates (`<h6>`), Topology (`<h6>`), Dashboard (`<h3>`), and most form pages mix levels. Adopt `<h4 class="mb-0">` inside `d-flex justify-content-between align-items-center mb-3`.
|
||||
2. **Search input above the list.** `max-width: 320px`, bound to `_search` with `@bind:event="oninput"`, plus the "No X match the filter." inline message. Missing on: LdapMappings, ApiKeys, SharedScripts, EventLogs, ParkedMessages (per-site only), AuditLog.
|
||||
3. **Kebab (⋮) menu for less-frequent actions.** Edit stays as a primary button; Delete/Disable/Deploy move into the dropdown. Missing on: LdapMappings, ApiKeys, SharedScripts, TemplateEdit member rows, ParkedMessages.
|
||||
4. **`@key="entity.Id"` on iterated rows / cards.** Prevents Bootstrap collapse state leaks (the bug caught in smoke on Sites). Apply anywhere `@foreach` renders elements with Bootstrap stateful classes (`show`, `collapsed`, `active`).
|
||||
5. **State badges must not rely on color alone.** Add either icon + text or `aria-label="State: …"`. Affected: Health node Online/Offline, Topology Stale, Deployments row colors, DebugView Quality / Alarm State, AuditLog action badges.
|
||||
6. **`TimestampDisplay` component consistency.** EventLogs / ParkedMessages / AuditLog use it; Health and DebugView format inline. Pick the component, give it a single rendering of "HH:mm:ss UTC" or relative+absolute, retrofit everywhere.
|
||||
7. **Empty-state CTA when count is 0.** Sites.razor lines 53-60 are the template. Missing on: SharedScripts, Templates (tree), ExternalSystems tabs, ParkedMessages, AuditLog.
|
||||
8. **`aria-label` on icon-only buttons** (`⋮`, `📋`, copy, expand/collapse). Almost universally missing today.
|
||||
9. **Truncate-and-expand pattern.** AuditLog has the cleanest pattern (`View` toggle for state JSON). Apply to long message strings (EventLogs, ParkedMessages, Deployments errors) instead of mid-string CSS truncation.
|
||||
|
||||
---
|
||||
|
||||
## Admin section
|
||||
|
||||
### LdapMappings.razor — `/admin/ldap-mappings` — **Medium**
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor`
|
||||
**What it does:** Lists LDAP group → role mappings with inline Edit/Delete and Site Scope hints.
|
||||
|
||||
**Issues**
|
||||
1. *Consistency:* Header (line 12) lacks the Sites flex layout + Bulk actions dropdown next to the primary Add button.
|
||||
2. *Density:* 5-column table; "Site Scope Rules" cell jams multiple badges into a narrow column.
|
||||
3. *Consistency:* No search filter. Sites uses one at lines 67-69.
|
||||
4. *Consistency:* Edit + Delete rendered as twin buttons in the row; Sites uses kebab.
|
||||
5. *Other:* "Site Scope Rules" preview in the row + the "(manage on edit page)" hint creates a confusing duality — the list page promises something it can't deliver.
|
||||
|
||||
**Recommendations**
|
||||
1. Add header flex layout + search input.
|
||||
2. Replace Edit/Delete pair with `Edit` button + `⋮` dropdown containing Delete.
|
||||
3. Either drop the Site Scope column from the list entirely (show a `n rule(s)` badge instead) or expand it into a collapse panel on the row.
|
||||
4. If keeping table layout, add `@key="m.Id"`.
|
||||
|
||||
---
|
||||
|
||||
### LdapMappingForm.razor — `/admin/ldap-mappings/create` and `/{Id}/edit` — **High**
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappingForm.razor`
|
||||
**What it does:** Create/edit a single mapping, plus a secondary panel for Site Scope Rules in edit mode.
|
||||
|
||||
**Issues**
|
||||
1. *Form-layout:* Two distinct sub-forms on one page (mapping basics + scope rules) with no visual separation. Scope rules only become editable after Save, but the UI doesn't communicate that workflow.
|
||||
2. *Hierarchy:* Both sections use `<h6>` inside `card-title`; no primary/secondary hierarchy.
|
||||
3. *Form-layout:* Scope-rule entry uses a nested table inside the card; visually heavy.
|
||||
4. *Accessibility:* Role `<select>` has no `aria-describedby` / help text explaining why "Deployment" surfaces the scope rules section.
|
||||
|
||||
**Recommendations**
|
||||
1. Restructure: top card "Mapping" stacked vertically (Name, LDAP Group, Role, [Save]); below it, a card "Site Scope Rules" that's disabled-with-explanation in create mode and editable in edit mode.
|
||||
2. Replace the nested scope-rule table with a tag-style chip list: each scope rule renders as a removable chip; an inline "Add scope rule" form sits below.
|
||||
3. Add `form-text` under Role: "Deployment role: configure site scope below after saving."
|
||||
|
||||
---
|
||||
|
||||
### DataConnections.razor — `/admin/connections` — **High** for header / a11y, **Medium** overall
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor`
|
||||
**What it does:** Treeview of sites and their data connections with context menu CRUD.
|
||||
|
||||
**Issues**
|
||||
1. *Hierarchy:* Page title is `<h6>` (line 24). Promote to `<h4>` with flex header to match Sites.
|
||||
2. *Consistency:* Inline `btn-group` with Refresh / Expand / Collapse buttons next to search; visually busy. Sites uses Bulk actions dropdown + Add button only.
|
||||
3. *Accessibility:* Tree node kebab toggles lack `aria-label="More actions for {name}"`.
|
||||
4. *Other:* Right-click context menu has no visible hover affordance — easy to miss.
|
||||
5. *Other:* When search returns no matches, the tree silently collapses; no empty-state message.
|
||||
|
||||
**Recommendations**
|
||||
1. Promote heading, adopt flex header. Move Expand/Collapse into a Bulk actions dropdown; drop Refresh (navigation reload covers it).
|
||||
2. Add visible kebab on tree-node hover so the context menu is discoverable.
|
||||
3. Add `aria-label` to every kebab toggle (interpolate the node name).
|
||||
4. Add "No connections match the filter." inline when search clears the tree.
|
||||
|
||||
---
|
||||
|
||||
### DataConnectionForm.razor — `/admin/connections/create` and `/{Id}/edit` — **Medium**
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnectionForm.razor`
|
||||
**What it does:** Create/edit a connection with primary + optional backup endpoint editors (OPC UA only today).
|
||||
|
||||
**Issues**
|
||||
1. *Form-layout:* Site field is disabled in edit mode but rendered as a disabled `<select>` with no read-only styling cue beyond gray.
|
||||
2. *Hierarchy:* "Backup endpoint" `<h6>` uses `border-bottom`; primary endpoint has no parallel heading. Hierarchy is one-sided.
|
||||
3. *Density:* "Add Backup Endpoint" button buried inside the card with no signposting that backup is optional.
|
||||
4. *Accessibility:* No `form-text` on Primary Endpoint / Site / failover knobs.
|
||||
|
||||
**Recommendations**
|
||||
1. Use `<input class="form-control-plaintext" readonly>` for the Site field in edit mode and add a small explanatory line ("Site is locked after creation").
|
||||
2. Mirror the heading pattern: both Primary and Backup get `<h6>` headers; Backup also gets a clear "Optional" badge.
|
||||
3. Add `form-text` help under each tuning knob (PublishingIntervalMs, SamplingIntervalMs, FailoverRetryCount, etc.).
|
||||
|
||||
---
|
||||
|
||||
### ApiKeys.razor — `/admin/api-keys` — **Medium**
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeys.razor`
|
||||
**What it does:** Lists API keys with Edit / Disable-Enable / Delete actions; masked key value.
|
||||
|
||||
**Issues**
|
||||
1. *Consistency:* No search filter.
|
||||
2. *Density:* 5-column table; Status column is redundant with the Disable/Enable button.
|
||||
3. *Consistency:* Three buttons in the Actions cell (Edit / Disable / Delete) — should be Edit + kebab.
|
||||
4. *Other:* No `@key="k.Id"` on rows.
|
||||
|
||||
**Recommendations**
|
||||
1. Add search filter and `@key`.
|
||||
2. Drop the Status column; let the kebab item read "Disable" or "Enable" depending on state.
|
||||
3. Either keep the table and adopt the kebab pattern, or move to the Sites card grid — for ~5 keys per environment the table is fine; for 50+ the card grid would scan better.
|
||||
|
||||
---
|
||||
|
||||
### ApiKeyForm.razor — `/admin/api-keys/create` and `/{Id}/edit` — **Low**
|
||||
**File:** `src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor`
|
||||
**What it does:** Create an API key (showing the secret once) or rename an existing one.
|
||||
|
||||
**Issues**
|
||||
1. *Form-layout:* Header has conditional "Back to API Keys" vs "Back" text.
|
||||
2. *Other:* Copy button on the one-shot secret reveal is wired to a comment / no-op.
|
||||
3. *Density:* Form is one field but wrapped in card-inside-card.
|
||||
|
||||
**Recommendations**
|
||||
1. Fixed header: `← Back · Add / Edit API Key`.
|
||||
2. Implement the copy via `IJSRuntime` + `navigator.clipboard.writeText` (mirror Sites.razor's `CopyAsync`).
|
||||
3. Remove redundant card nesting; render the input + buttons directly in `<div class="container-fluid mt-3">`.
|
||||
|
||||
---
|
||||
|
||||
## Design section
|
||||
|
||||
Files discovered:
|
||||
|
||||
```
|
||||
Components/Pages/Design/Templates.razor @page /design/templates
|
||||
Components/Pages/Design/TemplateCreate.razor @page /design/templates/create
|
||||
Components/Pages/Design/TemplateEdit.razor @page /design/templates/{Id:int}
|
||||
Components/Pages/Design/SharedScripts.razor @page /design/shared-scripts
|
||||
Components/Pages/Design/SharedScriptForm.razor @page /design/shared-scripts/{create|edit}
|
||||
Components/Pages/Design/ExternalSystems.razor @page /design/external-systems
|
||||
Components/Pages/Design/ExternalSystemForm.razor @page /design/external-systems/{create|edit}
|
||||
Components/Pages/Design/DbConnectionForm.razor @page /design/db-connections/{create|edit}
|
||||
Components/Pages/Design/ApiMethodForm.razor @page /design/api-methods/{create|edit}
|
||||
Components/Pages/Design/NotificationListForm.razor @page /design/notification-lists/{create|edit}
|
||||
```
|
||||
|
||||
### Templates.razor — **Medium**
|
||||
**What it does:** Folder-tree view of templates with context-menu CRUD.
|
||||
|
||||
**Issues**
|
||||
1. *Hierarchy:* Page title is `<h6>` (line 53) — should be `<h4>` in flex header.
|
||||
2. *Consistency:* `btn-group-sm` of outline buttons for Expand/Collapse — push these into a Bulk actions dropdown.
|
||||
3. *Accessibility:* Context-menu buttons (lines 271-288) lack `aria-label`.
|
||||
4. *Density:* Treeview height is hardcoded `calc(100vh - 160px)` with no scroll affordance.
|
||||
5. *Other:* No breadcrumb when an edit page navigates away from the tree context.
|
||||
|
||||
**Recommendations**
|
||||
1. Promote heading, adopt flex header pattern.
|
||||
2. Move Expand/Collapse into the Bulk actions dropdown.
|
||||
3. Add aria-labels on every context-menu button (interpolate node name).
|
||||
4. Add a top breadcrumb on TemplateEdit so users know which folder they're editing inside.
|
||||
|
||||
---
|
||||
|
||||
### SharedScripts.razor — **High**
|
||||
**What it does:** Table of shared scripts with name, code preview, parameters, returns.
|
||||
|
||||
**Issues**
|
||||
1. *Consistency:* Table instead of card grid — and code preview is rendered as truncated monospace inline, which is unreadable beyond ~40 chars.
|
||||
2. *Density:* 6 columns (ID, Name, Code preview, Parameters, Returns, Actions). ID is internal-only.
|
||||
3. *Consistency:* No search, no empty-state CTA.
|
||||
4. *Accessibility:* Truncated code preview has no `title=` tooltip.
|
||||
|
||||
**Recommendations**
|
||||
1. Migrate to a card grid (col-lg-6) mirroring Sites: title = Name, body = small code snippet (first 80 chars) + parameter/return counts as chips, footer = Edit + ⋮ Delete.
|
||||
2. Drop ID column entirely.
|
||||
3. Add search by name + code substring.
|
||||
4. Add "No shared scripts configured. Create your first script." CTA.
|
||||
|
||||
---
|
||||
|
||||
### ExternalSystems.razor — **High**
|
||||
**What it does:** Tabbed hub for External Systems, DB Connections, Notification Lists, Inbound API Methods, SMTP Config, API Keys.
|
||||
|
||||
**Issues**
|
||||
1. *Density:* Six subsections on one page with no search per tab; SMTP form crams 6+ inputs in one `row g-2 align-items-end` flex row.
|
||||
2. *Consistency:* Tabs use mixed renderings — External Systems / DB / API Methods use tables; Notification Lists and SMTP use cards. Same-level data, inconsistent shape.
|
||||
3. *Form-layout:* SMTP form violates the vertical-stacking rule.
|
||||
4. *Hierarchy:* Subsection headings are `<h6>` with badge counts — heading level is too small.
|
||||
5. *Accessibility:* Tab buttons lack `role="tab"` / `aria-selected`.
|
||||
6. *Other:* No per-tab empty state.
|
||||
|
||||
**Recommendations**
|
||||
1. Split SMTP off as a standalone `/admin/smtp` (it's a single-row global config, not list data).
|
||||
2. Unify all tabs on the same card-grid pattern.
|
||||
3. Reformat the remaining SMTP page to vertical-stacked fields per `feedback_form_layout`.
|
||||
4. Add `role="tablist"` / `role="tab"` / `aria-selected` and `aria-controls` on the tab nav.
|
||||
5. Add per-tab search + empty-state CTAs.
|
||||
|
||||
---
|
||||
|
||||
### TemplateEdit.razor — **High**
|
||||
**What it does:** Edit a template's properties plus Attributes / Alarms / Scripts / Compositions in tabs.
|
||||
|
||||
**Issues**
|
||||
1. *Density:* Template Properties card uses a 4-column row; Parent Template renders as `form-control-plaintext` next to live inputs, then a Save button at col-md-2. Save ends up mid-row instead of at the bottom.
|
||||
2. *Form-layout:* "Add Attribute / Alarm / Script" inline forms use `row g-2 align-items-end` — the Scripts row stuffs 4 inputs + a textarea horizontally.
|
||||
3. *Consistency:* Card headers inconsistent — some "card-title" h6 inside `card-body`, some bare h6 above a section.
|
||||
4. *Hierarchy:* Validation result alerts mix strong-heading + bare `<li>` items.
|
||||
5. *Accessibility:* Lock-state badges render as cryptic single letters "L"/"U" with no `aria-label`. Tabs lack `role="tab"` / `aria-selected`.
|
||||
6. *Other:* Per-row Delete buttons scattered; many tables.
|
||||
|
||||
**Recommendations**
|
||||
1. Reflow Template Properties to vertical-stack (col-12 each), put Save at the bottom following the form-layout rule.
|
||||
2. Reformat add-forms into a card with stacked col-12 inputs; Scripts gets a full-width Monaco-ish textarea (rows≥10) below the metadata fields.
|
||||
3. Replace L/U badges with full text + `aria-label`: `<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>`.
|
||||
4. Per-row kebab menu replacing Delete (with future Duplicate / Move options).
|
||||
5. Add `role`/`aria-selected` to all tab buttons.
|
||||
|
||||
---
|
||||
|
||||
### TemplateCreate.razor — **Low**
|
||||
1. Use `form-control` not `form-control-sm` for the primary Name field.
|
||||
2. Replace the `←` arrow on the Back button with text `← Back` and add `aria-label="Back to Templates"`.
|
||||
|
||||
---
|
||||
|
||||
### ExternalSystemForm.razor — **Low**
|
||||
1. Auth Config field: add a JSON example placeholder matching the chosen AuthType.
|
||||
|
||||
---
|
||||
|
||||
### SharedScriptForm.razor — **Low**
|
||||
1. Add a small `bi-question-circle` icon next to Parameters / Return Definition linking to a tooltip with schema reference.
|
||||
2. When syntax check fails, surface line/column position in the error message.
|
||||
|
||||
---
|
||||
|
||||
### DbConnectionForm.razor — **Low**
|
||||
1. Add reassurance text under Connection String: "Stored encrypted; not displayed after save." (only if the back end actually does this; otherwise drop the claim.)
|
||||
|
||||
---
|
||||
|
||||
### ApiMethodForm.razor — **Low**
|
||||
1. Script textarea bumped from rows=5 to rows≥10.
|
||||
2. Add JSON example placeholders for Params and Returns.
|
||||
|
||||
---
|
||||
|
||||
### NotificationListForm.razor — **Low**
|
||||
1. Resize the Name input to `form-control` (not `form-control-sm`).
|
||||
2. Recipients `<thead class="table-dark">` → `table-light` for consistency.
|
||||
|
||||
---
|
||||
|
||||
## Deployment section
|
||||
|
||||
Files discovered:
|
||||
|
||||
```
|
||||
Components/Pages/Deployment/Topology.razor @page /deployment/topology (and /deployment/instances)
|
||||
Components/Pages/Deployment/Deployments.razor @page /deployment/deployments
|
||||
Components/Pages/Deployment/DebugView.razor @page /deployment/debug-view
|
||||
(+ InstanceCreate, InstanceConfigure, CreateAreaDialog, MoveAreaDialog, MoveInstanceDialog)
|
||||
```
|
||||
|
||||
### Topology.razor — **Medium**
|
||||
1. *Hierarchy:* `<h6>` page title (line 63) — promote to `<h4>` in flex header.
|
||||
2. *Accessibility:* Expand / Collapse / Refresh / Search / tree-kebab buttons all lack `aria-label`. Inline rename input has no label.
|
||||
3. *Live-data UX:* No "pause live updates" toggle; tree can repaint while user is renaming or moving a node.
|
||||
4. *Density:* Instance counts footer text — could be a summary card above the tree.
|
||||
5. *State cues:* Stale badge is yellow-only; pair with text or icon.
|
||||
6. *Consistency:* Diff modal is hand-rolled Bootstrap modal markup — should be a reusable `<DiffDialog>` mirroring `<ConfirmDialog>`.
|
||||
|
||||
**Recommendations**
|
||||
1. Promote heading, adopt flex header.
|
||||
2. Add aria-labels everywhere (treat the kebab and rename input as the priority).
|
||||
3. Add a "Live updates: on/off" toggle button next to Refresh; pause auto-refresh during edits.
|
||||
4. Move counts to a small summary card above the tree.
|
||||
5. Pair Stale badge with `aria-label="State: Stale"` and a 🟡 dot or "STALE" text.
|
||||
6. Extract `<DiffDialog>` into `Components/Shared/`.
|
||||
|
||||
---
|
||||
|
||||
### Deployments.razor — **Medium**
|
||||
1. *Density:* 8 columns (Deployment ID, Instance, Status, Deployed By, Started, Completed, Revision, Error). Both Deployment ID and Revision are truncated hashes; Error can be a stack trace.
|
||||
2. *Live-data UX:* Auto-refresh runs every 10s with no pause control — if a user is reading an error message, the row can swap underneath them.
|
||||
3. *Consistency:* Summary cards use `col-md-3` only (no `col-sm-6` fallback for tablet); cards are styled differently from Sites.
|
||||
4. *Accessibility:* Spinner inside the status badge has no `role="status"` / `aria-label`. "Auto-refresh: 10s" text is decorative, not a control.
|
||||
5. *State cues:* Row colors (`table-danger`, `table-info`) without an icon or stripe.
|
||||
6. *Other:* Empty state is a single line of text.
|
||||
|
||||
**Recommendations**
|
||||
1. Collapse Error column into a `View error` button that pops a `<DiffDialog>`-style modal (or inline collapse row).
|
||||
2. Add `Live updates: 10s [pause]` toggle.
|
||||
3. Make summary cards `col-lg-3 col-md-6 col-12`.
|
||||
4. Add aria-labels on the spinner and the toggle.
|
||||
5. Add `border-start border-3 border-danger` or icon to failed rows.
|
||||
6. Either fold Deployment ID + Revision into one cell or hide one behind the detail modal.
|
||||
|
||||
---
|
||||
|
||||
### DebugView.razor — **High**
|
||||
1. *Live-data UX:* No scroll-lock on the streaming tables. Auto-scroll behavior is implicit. No max-row cap → tab can balloon in memory.
|
||||
2. *Live-data UX:* Timestamps shown to milliseconds; noisy at sustained update rates.
|
||||
3. *Live-data UX:* No stream filter (e.g., "only alarms with state=Active") — once subscribed, you watch everything.
|
||||
4. *Accessibility:* Quality / Alarm State badges are color-only. No `aria-live="polite"` on the streaming table bodies.
|
||||
5. *Consistency:* "Snapshot received at …" is a tiny muted footer; should be a header-level status strip.
|
||||
6. *UX risk:* Page persists session in `localStorage` and auto-reconnects on refresh, with no user-visible notice.
|
||||
|
||||
**Recommendations**
|
||||
1. Add per-table `🔒 Lock scroll` toggle.
|
||||
2. Cap rows at e.g. 200; add a `Clear` button.
|
||||
3. Add per-table filter input.
|
||||
4. Display timestamps as `HH:mm:ss` by default; `.fff` only inside an "Expanded row" view.
|
||||
5. Add `aria-live="polite" aria-atomic="false"` on the streaming table bodies.
|
||||
6. Pair every Quality and Alarm State badge with `aria-label`.
|
||||
7. Replace the snapshot footer with a status strip: instance · connection state · last snapshot time.
|
||||
8. On auto-reconnect, toast "Auto-reconnected to {instance}" with a `Start fresh` button.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring section + Dashboard
|
||||
|
||||
Files discovered:
|
||||
|
||||
```
|
||||
Components/Pages/Dashboard.razor @page /
|
||||
Components/Pages/Monitoring/Health.razor @page /monitoring/health
|
||||
Components/Pages/Monitoring/EventLogs.razor @page /monitoring/event-logs
|
||||
Components/Pages/Monitoring/ParkedMessages.razor @page /monitoring/parked-messages
|
||||
Components/Pages/Monitoring/AuditLog.razor @page /monitoring/audit-log
|
||||
```
|
||||
|
||||
### Dashboard.razor — **Medium**
|
||||
1. *Dashboard UX:* It is currently just a user-info card. For a central SCADA console the landing page should show system KPIs first (sites online/offline, errors, queue depths, parked-message count) — the things you'd want to see in <5 seconds.
|
||||
2. *Hierarchy:* `<h3>` heading; rest of the site is `<h4>`.
|
||||
3. *Consistency:* Inline `style="max-width:500px"` instead of Bootstrap utilities.
|
||||
|
||||
**Recommendations**
|
||||
1. Repurpose as a "Glance" page: KPI cards across the top (Sites, Errors, Parked Messages, Latest deployments status), a sites-by-health small list, recent audit events.
|
||||
2. Move the user-info card to a secondary panel or drop it (it's already in the top-right of the layout).
|
||||
3. `<h3>` → `<h4>` for site-wide consistency, replace inline styles with utility classes.
|
||||
|
||||
---
|
||||
|
||||
### Health.razor — **Medium**
|
||||
1. *KPI choices:* Sites Online + Sites Offline + Total Sites is redundant; Total Script Errors is global and not actionable. Promote "Sites with active errors" / "Cluster degraded" instead.
|
||||
2. *Hierarchy:* Header is `<h4>` left-aligned with no flex header; doesn't match Sites.
|
||||
3. *Density:* Per-site cards use a 4-column inner grid that breaks on narrow viewports.
|
||||
4. *Time format:* `HH:mm:ss` only, no timezone, no relative.
|
||||
5. *State cues:* Online/Offline / Primary/Standby badges are color-only.
|
||||
|
||||
**Recommendations**
|
||||
1. Replace "Total Sites" KPI with "Sites with active errors" or "Cluster health %".
|
||||
2. Adopt flex header layout.
|
||||
3. Reduce per-site card to 2 columns (col-md-6) or wrap each subsection in a collapse à la Sites.razor "Cluster nodes".
|
||||
4. Use `TimestampDisplay` with UTC suffix; consider adding a relative time hint ("3 minutes ago").
|
||||
5. Add `aria-label` and an icon to every Online/Offline/Primary/Standby badge.
|
||||
|
||||
---
|
||||
|
||||
### EventLogs.razor — **High**
|
||||
1. *Density:* "Message" column truncates long error strings mid-string with no expand.
|
||||
2. *Pagination:* "Load more" + continuation token, no total count shown.
|
||||
3. *Filter affordance:* 7 filter inputs in one row; "Keyword" label is vague.
|
||||
4. *Accessibility:* Labels are not linked to inputs via `for`/`id`; row colors are the primary severity cue.
|
||||
5. *Time:* Uses `<TimestampDisplay>` — confirm it standardises with the other log pages.
|
||||
|
||||
**Recommendations**
|
||||
1. Apply AuditLog's `View` / `Hide` toggle pattern for the Message cell.
|
||||
2. Switch to numeric pagination ("Page X of Y, N total") or surface a total count next to the Load More button.
|
||||
3. Move the filter row into a Bootstrap collapse with label `Filter options (n active)`.
|
||||
4. Add `id`/`for` pairings, `aria-label`s, and pair the row color with an icon stripe.
|
||||
5. Standardise on `TimestampDisplay` across all log pages.
|
||||
|
||||
---
|
||||
|
||||
### ParkedMessages.razor — **Medium**
|
||||
1. *Density:* Message ID is truncated to 12 chars with no copy or expand affordance.
|
||||
2. *Density:* Error message field can be long; no expand.
|
||||
3. *Accessibility:* Retry / Discard buttons have `title=` only, no `aria-label`.
|
||||
4. *State:* No spinner / disabled affordance while a Retry is in flight.
|
||||
|
||||
**Recommendations**
|
||||
1. Render Message ID as a `<code>` with a `📋 Copy` button or expand row showing the full ID + error.
|
||||
2. Apply AuditLog's expand toggle for error messages.
|
||||
3. Add `aria-label="Retry message {id}"` and `aria-label="Discard message {id}"`.
|
||||
4. Replace each action button's normal/disabled state with a small spinner during the action.
|
||||
|
||||
---
|
||||
|
||||
### AuditLog.razor — **Medium**
|
||||
1. *Pagination bug:* `Next` is disabled when `_entries.Count < _pageSize`; this misfires when the last page has exactly `_pageSize` rows (will show enabled Next that returns empty).
|
||||
2. *Filter affordance:* 5 filter inputs in one row; no `Clear filters` button.
|
||||
3. *Density:* Entity ID is a full GUID with no copy / expand.
|
||||
4. *State expansion:* JSON detail has `max-height: 200px` with no "expand to full size" affordance.
|
||||
5. *Accessibility:* `View`/`Hide` button has no `aria-label`.
|
||||
|
||||
**Recommendations**
|
||||
1. Fix the pagination logic: rely on a "has more" flag from the API, not a length compare.
|
||||
2. Add a `Clear filters` button next to the filter row.
|
||||
3. Add a copy button or expand-on-click for Entity ID.
|
||||
4. Make the JSON detail panel resizable, or open in a `<DiffDialog>`-style modal when content exceeds 1 KB.
|
||||
5. Add `aria-label` to the toggle (interpolate entry id).
|
||||
|
||||
---
|
||||
|
||||
## Layout, shared components, global CSS
|
||||
|
||||
### MainLayout.razor / NavMenu.razor / App.razor
|
||||
|
||||
**Issues**
|
||||
1. *Responsive:* Sidebar is fixed `min-width: 220px / max-width: 220px` in `App.razor` lines 13-14. No `d-none d-lg-flex` or hamburger toggle for narrow viewports. **High.**
|
||||
2. *Scrolling:* `<ul class="nav flex-column flex-grow-1">` has no overflow boundary. If role-driven nav becomes long enough, the footer (username + Sign Out) will scroll off-screen. **Medium.**
|
||||
3. *Semantics:* Section headers (Admin, Design, …) render as bare `<li class="nav-section-header">` — not focusable / not semantic. **Medium.**
|
||||
4. *Active state:* Active blue (#0d6efd) and hover gray (#343a40) are similar enough to confuse — pair active with a left border or underline. **Low.**
|
||||
|
||||
**Recommendations**
|
||||
1. Wrap the sidebar in `d-none d-lg-flex` + add a hamburger button in the top bar for `<lg` viewports. Replace fixed widths with `flex-basis: 220px` and let it collapse off-canvas on mobile.
|
||||
2. Wrap `<ul>` in `<div style="overflow-y:auto; flex:1 1 auto;">` so the footer is always anchored.
|
||||
3. Convert section headers to `<li role="presentation"><span class="nav-section-header">Admin</span></li>` or just `<div role="separator" aria-label="Admin section">`.
|
||||
4. Add `border-left: 3px solid var(--bs-primary)` to `.nav-link.active`.
|
||||
|
||||
---
|
||||
|
||||
### Login.razor — **Medium** / **Low**
|
||||
|
||||
**Issues**
|
||||
1. *Centering:* `margin-top: 10vh;` on the container — on short viewports the card pushes below the fold. **Medium.**
|
||||
2. *Validation:* No client-side validation feedback for empty fields; only server-side via `?error=` query param. **Low.**
|
||||
|
||||
**Recommendations**
|
||||
1. Wrap in `<div class="d-flex align-items-center justify-content-center min-vh-100">` for true vertical centering.
|
||||
2. Add HTML5 `required` and `:invalid` styling; keep the server-side error banner for actual auth failures.
|
||||
|
||||
---
|
||||
|
||||
### NotAuthorizedView.razor — **Low**
|
||||
1. Wrap in the same centered layout as Login, with the "ScadaLink" brand heading on top — currently feels orphaned.
|
||||
|
||||
---
|
||||
|
||||
### ToastNotification.razor — **Medium**
|
||||
|
||||
**Issues**
|
||||
1. *z-index:* Toasts are at `z-index: 1090`; Bootstrap modal backdrop defaults to 1040 and the modal element itself to 1055. Currently OK, but ConfirmDialog markup doesn't set explicit z-index on the modal element — document the hierarchy or set explicit values.
|
||||
2. *Auto-dismiss:* Hardcoded 5 s. No way to extend for important messages.
|
||||
3. *Accessibility:* `role="alert"` is set but `aria-live="polite"` / `aria-atomic="true"` are missing.
|
||||
|
||||
**Recommendations**
|
||||
1. Document the z-index ladder in a comment at the top of the component; set explicit z-index in `ConfirmDialog` too.
|
||||
2. Add `[Parameter] public int AutoDismissMs { get; set; } = 6000;`.
|
||||
3. Add `aria-live="polite" aria-atomic="true"` to the container.
|
||||
|
||||
---
|
||||
|
||||
### ConfirmDialog.razor — **High** / **Medium**
|
||||
|
||||
**Issues**
|
||||
1. *Scroll:* Backdrop doesn't add `overflow: hidden` to `<body>` — the page behind scrolls under the dialog. **High.**
|
||||
2. *Keyboard:* No `Escape`-to-close handler. No focus trap. **Medium.**
|
||||
3. *Defaults:* `ConfirmButtonClass` defaults to `btn-danger` — wrong for non-destructive confirms. **Medium.**
|
||||
|
||||
**Recommendations**
|
||||
1. On `ShowAsync`, JS-interop add `overflow:hidden` to `body`; remove on close.
|
||||
2. Add `@onkeydown="..."` for Escape → Cancel; on show, focus the cancel button (or the safer button) and on close return focus to the trigger.
|
||||
3. Default `ConfirmButtonClass` to `btn-primary`; explicit `btn-danger` on destructive call sites only.
|
||||
|
||||
---
|
||||
|
||||
### LoadingSpinner.razor — **Low**
|
||||
1. `text-muted` on a light background may not meet 4.5:1. Switch to `text-secondary`.
|
||||
|
||||
---
|
||||
|
||||
### DataTable.razor — **Low**
|
||||
1. Search input has no clear (✕) button.
|
||||
2. Pagination disabled state is on the parent `<li>` not the button — apply `disabled` directly + `aria-disabled="true"`.
|
||||
|
||||
---
|
||||
|
||||
### NewFolderDialog.razor — **Low**
|
||||
1. Uses combined modal + inline background style instead of a separate `<div class="modal-backdrop fade show">` like ConfirmDialog. Refactor to match.
|
||||
|
||||
---
|
||||
|
||||
### TreeView.razor / TreeView.razor.css
|
||||
1. Reliance on `var(--bs-*)` is good; no change.
|
||||
2. Same a11y caveats as Topology — hover/focus visuals must reach kebab toggles.
|
||||
|
||||
---
|
||||
|
||||
### Global CSS — **Medium**
|
||||
|
||||
**Issues**
|
||||
1. *Inline:* ~60 lines of `<style>` are inline in `App.razor` instead of in a `wwwroot/css/site.css` file.
|
||||
2. *Theming:* Sidebar uses hardcoded hex colors (#212529, #343a40, #adb5bd, #fff); blocks any future light-mode / brand variation work.
|
||||
3. *Reconnect modal:* Uses ad-hoc flex centering; could just be `.modal-dialog-centered`.
|
||||
|
||||
**Recommendations**
|
||||
1. Move inline styles to `wwwroot/css/site.css` and link in `App.razor`.
|
||||
2. Replace hex with `var(--bs-dark)` / `var(--bs-light)` etc.
|
||||
3. Use Bootstrap's `.modal-dialog-centered` for the reconnect overlay.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting strategic recommendations
|
||||
|
||||
These are bigger investments that pay back across many pages:
|
||||
|
||||
1. **Dialog/Modal service.** A single `IDialogService` that owns z-index stacking, body scroll lock, focus trap, Escape-to-close. Replace per-component ad-hoc backdrops. Fixes ConfirmDialog scroll-lock, focus-trap, and z-index collisions in one stroke; also unblocks the planned `<DiffDialog>` for Topology and the error-detail modal for Deployments.
|
||||
2. **Accessibility pass.** Adopt a single rule: every icon-only button has `aria-label`; every state badge is colour + text + icon; every form input has linked label and optional `aria-describedby`. Most pages need ~5 minutes of edits to comply.
|
||||
3. **Design tokens via CSS variables.** Pull the sidebar palette + the few custom colors into `:root` custom properties. Adopt Bootstrap's CSS variables (`--bs-*`) for everything else. Unblocks light/dark mode and any future rebrand.
|
||||
4. **Pagination + filter component.** EventLogs / ParkedMessages / AuditLog / Deployments all roll their own. Extract one `<PagedTable TItem>` or at least a `<Paginator>` that takes (page, pageSize, total) and emits standard events.
|
||||
5. **`TimestampDisplay` audit.** Make sure every consumer goes through it; standardise on UTC display + tooltip with relative time. Eliminate inline `.ToString("HH:mm:ss")` calls.
|
||||
6. **One reference page for list patterns.** Use `Sites.razor` as the reference; add a comment at the top of it pointing future implementers at it (or extract its skeleton into a snippet under `docs/`).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope / decisions to defer
|
||||
|
||||
- Whether to migrate any list page from table-only to card grid (most should, but each is a separate ticket).
|
||||
- Dark-mode / theming work.
|
||||
- A real dashboard (KPI page) replacement.
|
||||
- Replacing the SignalR debug-view streaming model.
|
||||
@@ -19,7 +19,7 @@ Debug streaming events currently flow through Akka.NET ClusterClient (`InstanceA
|
||||
| **Reconnection** | ClusterClient auto-reconnect (coarse, cluster-level) | gRPC channel-level reconnect per subscription |
|
||||
| **Serialization** | Akka.NET Hyperion (runtime IL, fragile across versions) | Protocol Buffers (schema-driven, cross-platform) |
|
||||
|
||||
The DCL already uses this exact pattern — `RealLmxProxyClient` opens gRPC server-streaming subscriptions to LmxProxy servers for real-time tag value updates. This plan applies the same pattern to site→central communication.
|
||||
gRPC server-streaming is an established pattern for real-time tag value updates; this plan applies the same pattern to site→central communication.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -216,7 +216,7 @@ message AttributeValueUpdate {
|
||||
string instance_unique_name = 1;
|
||||
string attribute_path = 2;
|
||||
string attribute_name = 3;
|
||||
string value = 4; // string-encoded (same as LmxProxy VtqMessage pattern)
|
||||
string value = 4; // string-encoded
|
||||
string quality = 5; // "Good", "Uncertain", "Bad"
|
||||
int64 timestamp_utc_ticks = 6;
|
||||
}
|
||||
@@ -230,7 +230,7 @@ message AlarmStateUpdate {
|
||||
}
|
||||
```
|
||||
|
||||
Pre-generate C# stubs and check into `src/ScadaLink.Communication/SiteStreamGrpc/` (same pattern as LmxProxy — no `protoc` in Docker for ARM64 compatibility).
|
||||
Pre-generate C# stubs and check into `src/ScadaLink.Communication/SiteStreamGrpc/` — no `protoc` in Docker for ARM64 compatibility.
|
||||
|
||||
## Server-Streaming Pattern (Site Side)
|
||||
|
||||
@@ -304,8 +304,6 @@ builder.Services.AddGrpc();
|
||||
app.MapGrpcService<SiteStreamGrpcServer>();
|
||||
```
|
||||
|
||||
Reference: `infra/lmxfakeproxy/Program.cs` uses the identical Kestrel setup.
|
||||
|
||||
## Client-Streaming Pattern (Central Side)
|
||||
|
||||
### gRPC Client Implementation
|
||||
@@ -346,8 +344,6 @@ public async Task<StreamSubscription> SubscribeAsync(
|
||||
}
|
||||
```
|
||||
|
||||
Reference: `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` uses the identical background-task-reading-stream pattern for LmxProxy subscriptions.
|
||||
|
||||
### Port Resolution
|
||||
|
||||
### Client Factory
|
||||
@@ -744,11 +740,6 @@ case ScriptErrorEvent error:
|
||||
|
||||
| Pattern | File | Relevance |
|
||||
|---------|------|-----------|
|
||||
| Proto file definition | `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` | Same proto3 syntax, server streaming (`stream VtqMessage`) |
|
||||
| Pre-generated C# stubs | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyGrpc/` | Same approach — checked-in stubs, no `protoc` at build time |
|
||||
| gRPC client + stream reader | `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` | Background task reads `ResponseStream`, invokes callback |
|
||||
| gRPC server implementation | `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` | Service base class override pattern |
|
||||
| Kestrel HTTP/2 setup | `infra/lmxfakeproxy/Program.cs` | `HttpProtocols.Http2`, `AddGrpc()`, `MapGrpcService<T>()` |
|
||||
| SiteStreamManager | `src/ScadaLink.SiteRuntime/Streaming/SiteStreamManager.cs` | Subscribe/filter by instance, per-subscriber buffer, DropHead overflow |
|
||||
| Per-site client caching | `CentralCommunicationActor._siteClients` dictionary | One client per site, refresh on address change |
|
||||
| Bridge actor pattern | `src/ScadaLink.Communication/Actors/DebugStreamBridgeActor.cs` | Per-session actor with callbacks, adapted to use gRPC instead of Akka messages |
|
||||
|
||||
@@ -614,7 +614,7 @@ Phase 0 covers REQ-COM and REQ-HOST requirements. The following are split with o
|
||||
|
||||
| REQ ID | Phase 0 Scope | Other Phase(s) Scope |
|
||||
|--------|---------------|---------------------|
|
||||
| REQ-COM-2 | Interface definition only | Phase 3B: OPC UA and LmxProxy implementations |
|
||||
| REQ-COM-2 | Interface definition only | Phase 3B: OPC UA implementation |
|
||||
| REQ-COM-4a | Interface definition only | Phase 1: `IAuditService` implementation in Configuration Database |
|
||||
| REQ-COM-5a-4 | Noted in plan; versioning rules documented | Phase 1/3A: Akka serialization binding configuration |
|
||||
| REQ-HOST-2 | Skeleton role branching with stub `AddXxx()` calls | Phase 1: Full service registration with real implementations |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Phase 3B brings the site cluster to life as a fully operational data collection, scripting, alarm evaluation, and health reporting platform. Upon completion, a site can:
|
||||
|
||||
- Communicate bidirectionally with the central cluster using all 8 message patterns.
|
||||
- Connect to OPC UA servers and LmxProxy endpoints, subscribe to tags, and deliver values to Instance Actors.
|
||||
- Connect to OPC UA servers, subscribe to tags, and deliver values to Instance Actors.
|
||||
- Execute scripts in response to triggers (interval, value change, conditional).
|
||||
- Evaluate alarm conditions, manage alarm state, and execute on-trigger scripts.
|
||||
- Compile and execute shared scripts inline.
|
||||
@@ -25,7 +25,7 @@ Phase 3B brings the site cluster to life as a fully operational data collection,
|
||||
| Component | Scope |
|
||||
|-----------|-------|
|
||||
| Central-Site Communication | Full — all 8 message patterns, correlation IDs, per-pattern timeouts, transport heartbeat |
|
||||
| Data Connection Layer | Full — IDataConnection, OPC UA adapter, LmxProxy adapter, connection actor, auto-reconnect, write-back, tag path resolution, health reporting |
|
||||
| Data Connection Layer | Full — IDataConnection, OPC UA adapter, connection actor, auto-reconnect, write-back, tag path resolution, health reporting |
|
||||
| Site Runtime | Full runtime — Script Actor, Alarm Actor, shared scripts, Script Runtime API (core operations), script trust model, site-wide Akka stream |
|
||||
| Health Monitoring | Site-side collection + central-side aggregation and offline detection |
|
||||
| Site Event Logging | Event recording, retention/purge, remote query with pagination |
|
||||
@@ -66,8 +66,8 @@ Each bullet extracted from docs/requirements/HighLevelReqs.md at the individual
|
||||
|
||||
### Section 2.4 — Data Connection Protocols
|
||||
|
||||
- [ ] `[2.4-1]` System supports OPC UA and LmxProxy (gRPC-based custom protocol with existing client SDK).
|
||||
- [ ] `[2.4-2]` Both protocols implement a common interface supporting: connect, subscribe to tag paths, receive value updates, and write values.
|
||||
- [ ] `[2.4-1]` System supports OPC UA.
|
||||
- [ ] `[2.4-2]` Protocol adapters implement a common interface supporting: connect, subscribe to tag paths, receive value updates, and write values.
|
||||
- [ ] `[2.4-3]` Additional protocols can be added by implementing the common interface.
|
||||
- [ ] `[2.4-4]` Data Connection Layer is a clean data pipe — publishes tag value updates to Instance Actors but performs no evaluation of triggers or alarm conditions.
|
||||
|
||||
@@ -221,15 +221,6 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
- [ ] `[KDD-ui-4]` Dead letter monitoring as a health metric.
|
||||
- [ ] `[KDD-ui-5]` Site Event Logging: 30-day retention, 1GB storage cap, daily purge, paginated queries with keyword search.
|
||||
|
||||
### LmxProxy Protocol Details
|
||||
|
||||
- [ ] `[CD-DCL-1]` LmxProxy: gRPC/HTTP/2 transport, protobuf-net code-first, port 5050.
|
||||
- [ ] `[CD-DCL-2]` LmxProxy: API key auth, session-based (SessionId), 30s keep-alive heartbeat via `GetConnectionStateAsync`.
|
||||
- [ ] `[CD-DCL-3]` LmxProxy: Server-streaming gRPC for subscriptions (`IAsyncEnumerable<VtqMessage>`), 1000ms default sampling, on-change with 0.
|
||||
- [ ] `[CD-DCL-4]` LmxProxy: SDK retry policy (exponential backoff via Polly) complements DCL's fixed-interval reconnect. SDK handles operation-level transient failures; DCL handles connection-level recovery.
|
||||
- [ ] `[CD-DCL-5]` LmxProxy: Batch read/write capabilities (ReadBatchAsync, WriteBatchAsync, WriteBatchAndWaitAsync).
|
||||
- [ ] `[CD-DCL-6]` LmxProxy: TLS 1.2/1.3, mutual TLS (client cert + key PEM), custom CA trust, self-signed for dev.
|
||||
|
||||
### Communication Component Design
|
||||
|
||||
- [ ] `[CD-Comm-1]` 8 distinct message patterns: Deployment, Instance Lifecycle, System-Wide Artifact, Integration Routing, Recipe/Command Delivery, Debug Streaming, Health Reporting, Remote Queries.
|
||||
@@ -282,7 +273,6 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
- [ ] `[CD-DCL-12]` Value update message format: tag path, value, quality (good/bad/uncertain), timestamp.
|
||||
- [ ] `[CD-DCL-13]` When Instance Actor stopped, DCL cleans up associated subscriptions.
|
||||
- [ ] `[CD-DCL-14]` On redeployment, subscriptions established fresh based on new configuration.
|
||||
- [ ] `[CD-DCL-15]` LmxProxy connection actor holds SessionId, starts 30s keep-alive timer on Connected state. On keep-alive failure, transitions to Reconnecting, client disposes subscriptions.
|
||||
|
||||
---
|
||||
|
||||
@@ -411,30 +401,6 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
|
||||
---
|
||||
|
||||
### WP-8: Data Connection Layer — LmxProxy Adapter
|
||||
|
||||
**Description**: Implement the LmxProxy adapter wrapping the existing `LmxProxyClient` SDK behind IDataConnection.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Implements all IDataConnection methods mapped per docs/requirements/Component-DCL concrete type mappings.
|
||||
- Connect: calls `ConnectAsync`, stores SessionId.
|
||||
- Subscribe: calls `SubscribeAsync`, processes `IAsyncEnumerable<VtqMessage>` stream, forwards updates.
|
||||
- Write: calls `WriteAsync`.
|
||||
- Read: calls `ReadAsync`.
|
||||
- Configurable sampling interval (default 1000ms, 0 = on-change).
|
||||
- gRPC/HTTP/2 transport on configured port (default 5050).
|
||||
- API key authentication passed in ConnectRequest.
|
||||
- TLS support: TLS 1.2/1.3, mutual TLS, custom CA trust, self-signed for dev.
|
||||
- 30s keep-alive heartbeat via `GetConnectionStateAsync`. On failure, marks disconnected, disposes subscriptions.
|
||||
- SDK retry policy (Polly exponential backoff) retained for operation-level transient failures.
|
||||
- Batch operations exposed (ReadBatchAsync, WriteBatchAsync) for future use.
|
||||
|
||||
**Estimated Complexity**: L
|
||||
|
||||
**Requirements Traced**: `[2.4-1]`, `[2.4-2]`, `[CD-DCL-1]`, `[CD-DCL-2]`, `[CD-DCL-3]`, `[CD-DCL-4]`, `[CD-DCL-5]`, `[CD-DCL-6]`, `[CD-DCL-15]`
|
||||
|
||||
---
|
||||
|
||||
### WP-9: Data Connection Layer — Auto-Reconnect & Bad Quality Propagation
|
||||
|
||||
**Description**: Implement auto-reconnection at fixed interval with immediate bad quality propagation on disconnect.
|
||||
@@ -460,7 +426,6 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
**Acceptance Criteria**:
|
||||
- After reconnection, all subscriptions that were active before disconnect are re-subscribed.
|
||||
- Instance Actors require no action — they see quality return to good as fresh values arrive.
|
||||
- LmxProxy adapter: new session established, new subscriptions created (old session/subscriptions were disposed on disconnect).
|
||||
- OPC UA adapter: new session established, monitored items re-created.
|
||||
- Test: disconnect OPC UA server, reconnect, verify values resume without Instance Actor intervention.
|
||||
|
||||
@@ -476,7 +441,7 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- Instance Actor sends write request to DCL when script calls SetAttribute for data-connected attribute.
|
||||
- DCL writes value via appropriate protocol (OPC UA Write / LmxProxy WriteAsync).
|
||||
- DCL writes value via the appropriate protocol (e.g., OPC UA Write).
|
||||
- Write failure (connection down, device rejection, timeout) returned synchronously to calling script.
|
||||
- Successful write: in-memory value NOT optimistically updated. Value updates only when device confirms via existing subscription.
|
||||
- Write failures also logged to Site Event Logging.
|
||||
@@ -531,7 +496,7 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
- Tag value updates delivered directly to requesting Instance Actor.
|
||||
- When Instance Actor stopped (disable, delete, redeployment): DCL cleans up associated subscriptions.
|
||||
- On redeployment: subscriptions established fresh based on new configuration.
|
||||
- Protocol-agnostic — works for both OPC UA and LmxProxy.
|
||||
- Protocol-agnostic — works for OPC UA and any future protocol adapter.
|
||||
|
||||
**Estimated Complexity**: M
|
||||
|
||||
@@ -896,7 +861,7 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- IDataConnection interface defined in Commons (Phase 0 — REQ-COM-2).
|
||||
- OPC UA adapter and LmxProxy adapter both implement IDataConnection.
|
||||
- The OPC UA adapter implements IDataConnection.
|
||||
- Connection actor instantiates the correct adapter based on data connection protocol type from configuration.
|
||||
- Adding a new protocol requires only implementing IDataConnection and registering the adapter — no changes to connection actor or Instance Actor.
|
||||
|
||||
@@ -933,7 +898,6 @@ Constraints from CLAUDE.md Key Design Decisions (KDD) and Component-*.md (CD) th
|
||||
|------|---------------|
|
||||
| Connection Actor | State machine transitions (Connecting -> Connected -> Reconnecting), stash/unstash behavior, bad quality propagation on disconnect |
|
||||
| OPC UA Adapter | IDataConnection contract compliance, subscribe/unsubscribe, write |
|
||||
| LmxProxy Adapter | IDataConnection contract compliance, SessionId management, keep-alive, subscription stream processing |
|
||||
| Script Actor | Trigger evaluation (interval, value change, conditional), minimum time between runs, concurrent execution |
|
||||
| Alarm Actor | Condition evaluation (Value Match, Range Violation, Rate of Change), state transitions (normal->active, active->normal), no script on clear |
|
||||
| Script Runtime API | GetAttribute, SetAttribute (data-connected + static), CallScript, CallShared |
|
||||
@@ -1003,7 +967,6 @@ Phase 3B is complete when ALL of the following pass:
|
||||
| # | Question | Context | Impact | Status |
|
||||
|---|----------|---------|--------|--------|
|
||||
| Q-P3B-1 | What is the exact dedicated blocking I/O dispatcher configuration for Script Execution Actors? | KDD-runtime-3 says "dedicated blocking I/O dispatcher" — need Akka.NET HOCON config (thread pool size, throughput settings). | WP-15. Sensible defaults can be set; tuned in Phase 8. | Deferred — use Akka.NET default blocking-io-dispatcher config; tune during Phase 8 performance testing. |
|
||||
| Q-P3B-2 | Should LmxProxy adapter expose WriteBatchAndWaitAsync (write-and-poll handshake) through IDataConnection or as a protocol-specific extension? | CD-DCL-5 lists WriteBatchAndWaitAsync but IDataConnection only defines simple Write. | WP-8. Does not block core functionality. | Deferred — expose as protocol-specific extension method; not part of IDataConnection core contract. |
|
||||
| Q-P3B-3 | What is the Rate of Change alarm evaluation time window? | Section 3.4 says "changes faster than a defined threshold" but does not specify the time window (per-second? per-minute? configurable?). | WP-16. Needs a design decision for the evaluation algorithm. | Deferred — implement as configurable window (default: per-second rate). Document in alarm definition schema. |
|
||||
| Q-P3B-4 | How does the health report sequence number behave across failover? | Sequence number is monotonic within a singleton lifecycle. After failover, the new singleton starts at 1. Central must handle this. | WP-27, WP-28. Central should accept any report from a site marked offline regardless of sequence number. | Resolved in design — central accepts report when site is offline; for online sites, requires seq > last. On failover, site goes offline first (missed reports), so the reset is naturally handled. |
|
||||
|
||||
@@ -1123,7 +1086,6 @@ Codex received work package titles (not full acceptance criteria due to prompt s
|
||||
| 9 | UTC timestamps not covered | **False positive** — UTC is a Phase 0 convention (KDD-data-6). Message contracts in WP-1 specify "All timestamps in message contracts are UTC." Health report in WP-27 specifies "UTC from site clock." |
|
||||
| 10 | Event log schema and active-node behavior uncovered | **False positive** — WP-29 acceptance criteria list full schema and "Only active node generates and stores events. Event logs not replicated to standby." |
|
||||
| 11 | Remote query filters/pagination details uncovered | **False positive** — WP-31 acceptance criteria list all filter types, "default 500 events," and "continuation token." |
|
||||
| 12 | LmxProxy details uncovered in WP-8 | **False positive** — WP-8 acceptance criteria explicitly cover port, API key, SessionId, keep-alive, TLS, batch ops, Polly retry. |
|
||||
|
||||
### Step 2 — Negative Requirement Review
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
- Admin can assign/unassign data connections to/from sites.
|
||||
- Admin can edit data connection details.
|
||||
- Admin can delete a data connection (blocked if bound to any instance attribute).
|
||||
- Protocol type selection (OPC UA, LmxProxy).
|
||||
- Protocol type selection (OPC UA).
|
||||
- Connection details form varies by protocol type.
|
||||
- Non-Admin users cannot access data connection management.
|
||||
- Data connection changes are audit logged.
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
| Q7 | JWT signing key storage? | `appsettings.json` (per environment). | 2026-03-16 |
|
||||
| Q8 | OPC UA server for dev/test? | Azure IoT OPC PLC simulator in Docker. See `infra/opcua/nodes.json` and `docs/test_infra/test_infra_opcua.md`. | 2026-03-16 |
|
||||
| Q10 | Target site hardware? | Windows Server 2022, 24 GB RAM, 1 TB drive, 16-core Xeon. | 2026-03-16 |
|
||||
| Q9 | What is the custom protocol? Is there an existing specification or SDK? | LmxProxy — gRPC-based protocol (protobuf-net code-first, port 5050, API key auth). Client SDK: `LmxProxyClient` NuGet package. See docs/requirements/Component-DataConnectionLayer.md for full API mapping and protocol details. | 2026-03-16 |
|
||||
| Q11 | Are there specific external systems (MES, recipe manager) to integrate with for initial testing? | REST API test server (`infra/restapi/`) provides simulated external endpoints for External System Gateway and Inbound API testing. No real MES/recipe system needed for initial phases. | 2026-03-16 |
|
||||
| Q15 | Should the Machine Data Database schema be designed in this project, or is it out of scope? | Out of scope — Machine Data Database is a pre-existing database at customer sites. Test infra seeds sample tables/data in `infra/mssql/machinedata_seed.sql`. | 2026-03-16 |
|
||||
| Q13 | Who is the development team? | Solo developer with extensive Akka.NET experience and full availability. No parallelization constraints — phases are sequential. | 2026-03-16 |
|
||||
@@ -45,7 +44,6 @@
|
||||
| Q-P3A-2 | Single SQLite file or separate files per concern? | Single file with separate tables. Simpler transaction management. | 2026-03-16 |
|
||||
| Q-P3A-3 | Akka.Persistence or direct SQLite for Deployment Manager singleton? | Direct SQLite. Recovery is full read-all-configs-and-rebuild, not event replay. | 2026-03-16 |
|
||||
| Q-P3B-1 | Blocking I/O dispatcher config for Script Execution Actors? | Use Akka.NET default blocking-io-dispatcher config. Tune during Phase 8 performance testing. | 2026-03-16 |
|
||||
| Q-P3B-2 | Should WriteBatchAndWaitAsync be on IDataConnection or protocol-specific? | Add to `IDataConnection` — both OPC UA and LmxProxy can implement it. | 2026-03-16 |
|
||||
| Q-P3B-3 | Rate of Change alarm evaluation time window? | Configurable window, default per-second rate. Document in alarm definition schema. | 2026-03-16 |
|
||||
| Q-P3B-4 | Health report sequence number across failover? | Resolved in design — offline detection handles the reset naturally. Central accepts lower seq after site goes offline/online. | 2026-03-16 |
|
||||
| Q-P3C-1 | S&F retry timers on failover — reset or continue? | Continue from `last_attempt_at` to avoid burst retries. | 2026-03-16 |
|
||||
|
||||
@@ -216,17 +216,6 @@ Design decisions from CLAUDE.md Key Design Decisions and docs/requirements/Compo
|
||||
| KDD-code-8 | EF Core migrations: auto-apply in dev, manual SQL scripts for production | CLAUDE.md | 1 | Pending |
|
||||
| KDD-code-9 | Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network) | CLAUDE.md | 3B | Plan generated |
|
||||
|
||||
### LmxProxy Protocol (Component Design)
|
||||
|
||||
| ID | Constraint | Source | Phase(s) | Status |
|
||||
|----|-----------|--------|----------|--------|
|
||||
| CD-DCL-1 | LmxProxy: gRPC/HTTP/2 transport, protobuf-net code-first, port 5050 | Component-DCL | 3B | Plan generated |
|
||||
| CD-DCL-2 | LmxProxy: API key auth, session-based (SessionId), 30s keep-alive heartbeat | Component-DCL | 3B | Plan generated |
|
||||
| CD-DCL-3 | LmxProxy: Server-streaming gRPC for subscriptions, 1000ms default sampling | Component-DCL | 3B | Plan generated |
|
||||
| CD-DCL-4 | LmxProxy: SDK retry policy (exponential backoff) complements DCL.s fixed-interval reconnect | Component-DCL | 3B | Plan generated |
|
||||
| CD-DCL-5 | LmxProxy: Batch read/write capabilities (ReadBatchAsync, WriteBatchAsync) | Component-DCL | 3B | Plan generated |
|
||||
| CD-DCL-6 | LmxProxy: TLS 1.2/1.3, mutual TLS, self-signed for dev | Component-DCL | 3B | Plan generated |
|
||||
|
||||
---
|
||||
|
||||
## Split-Section Tracking
|
||||
|
||||
@@ -88,7 +88,7 @@ scadalink instance delete <code>
|
||||
```
|
||||
scadalink site list [--format json|table]
|
||||
scadalink site get <site-id> [--format json|table]
|
||||
scadalink site create --name <name> --id <site-id>
|
||||
scadalink site create --name <name> --id <site-id> [--node-a-address <addr>] [--node-b-address <addr>] [--grpc-node-a-address <addr>] [--grpc-node-b-address <addr>]
|
||||
scadalink site update <site-id> --file <path>
|
||||
scadalink site delete <site-id>
|
||||
scadalink site area list <site-id>
|
||||
|
||||
@@ -24,7 +24,7 @@ Central cluster only. Sites have no user interface.
|
||||
|
||||
## Real-Time Updates
|
||||
|
||||
- **Debug view**: Real-time display of attribute values and alarm states via **streaming**. When the user opens a debug view, a `DebugStreamBridgeActor` on the central side subscribes to the site's Akka stream for the selected instance. The bridge actor delivers an initial `DebugViewSnapshot` followed by ongoing `AttributeValueChanged` and `AlarmStateChanged` events to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- **Debug view**: Real-time display of attribute values and alarm states via **gRPC streaming**. When the user opens a debug view, a `DebugStreamBridgeActor` on the central side opens a gRPC server-streaming subscription to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` via ClusterClient. Ongoing `AttributeValueChanged` and `AlarmStateChanged` events flow via the gRPC stream (not through ClusterClient) to the bridge actor, which delivers them to the Blazor component via callbacks that call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- **Health dashboard**: Site status, connection health, error rates, and buffer depths update via a **10-second auto-refresh timer**. Since health reports arrive from sites every 30 seconds, a 10s poll interval catches updates within one reporting cycle without unnecessary overhead.
|
||||
- **Deployment status**: Pending/in-progress/success/failed transitions **push to the UI immediately** via SignalR (built into Blazor Server). No polling required for deployment tracking.
|
||||
|
||||
@@ -37,6 +37,9 @@ Central cluster only. Sites have no user interface.
|
||||
## Workflows / Pages
|
||||
|
||||
### Template Authoring (Design Role)
|
||||
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
|
||||
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline as leaf nodes beneath their owning template (right-click "Open composed template" reveals and selects the target).
|
||||
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Native HTML5 **drag-drop** reorganizes templates between folders and reparents folders, with cycle detection rejected via toast on drop. Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
|
||||
- Create, edit, and delete templates.
|
||||
- **Template deletion** is blocked if any instances or child templates reference the template. The UI displays the references preventing deletion.
|
||||
- Manage template hierarchy (inheritance) — visual tree of parent/child relationships.
|
||||
@@ -66,8 +69,10 @@ Central cluster only. Sites have no user interface.
|
||||
- Configure SMTP settings.
|
||||
|
||||
### Site & Data Connection Management (Admin Role)
|
||||
- Create, edit, and delete site definitions.
|
||||
- Create, edit, and delete site definitions, including Akka node addresses (NodeA/NodeB) and gRPC node addresses (GrpcNodeA/GrpcNodeB).
|
||||
- Define data connections and assign them to sites (name, protocol type, connection details).
|
||||
- **Data connection form**: "Primary Endpoint Configuration" (required JSON text area) and optional "Backup Endpoint Configuration" (collapsible section, hidden by default, revealed via "Add Backup Endpoint" button; "Remove Backup" button when editing an existing backup). "Failover Retry Count" numeric input (default 3, min 1, max 20) is visible only when a backup endpoint is configured.
|
||||
- **Data connection list page**: Shows Primary Config and Backup Config columns. Active Endpoint column populated from health reports.
|
||||
|
||||
### Area Management (Admin Role)
|
||||
- Define hierarchical area structures per site.
|
||||
@@ -101,8 +106,8 @@ Central cluster only. Sites have no user interface.
|
||||
### Debug View (Deployment Role)
|
||||
- Select a deployed instance and open a live debug view.
|
||||
- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side that subscribes to the site's Akka stream for the selected instance.
|
||||
- The bridge actor receives an initial `DebugViewSnapshot` followed by ongoing `AttributeValueChanged` and `AlarmStateChanged` events from the site.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` via ClusterClient.
|
||||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC stream directly to the bridge actor — they do not pass through ClusterClient.
|
||||
- Events are delivered to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
|
||||
- Stream includes attribute values formatted as `[InstanceUniqueName].[AttributePath].[AttributeName]` and alarm states formatted as `[InstanceUniqueName].[AlarmName]`.
|
||||
|
||||
@@ -32,7 +32,7 @@ Both central and site clusters.
|
||||
- The Site Runtime Deployment Manager runs as an **Akka.NET cluster singleton** on the active node, owning the full Instance Actor hierarchy.
|
||||
- One standby node receives replicated store-and-forward data and is ready to take over.
|
||||
- Connected to local SQLite databases (store-and-forward buffer, event logs, deployed configurations).
|
||||
- Connected to machines via data connections (OPC UA, LmxProxy).
|
||||
- Connected to machines via data connections (OPC UA).
|
||||
|
||||
## Failover Behavior
|
||||
|
||||
@@ -106,7 +106,8 @@ The Host component wires CoordinatedShutdown into the Windows Service lifecycle
|
||||
Each node is configured with:
|
||||
- **Cluster seed nodes**: **Both nodes** are seed nodes — each node lists both itself and its partner. Either node can start first and form the cluster; the other joins when it starts. No startup ordering dependency.
|
||||
- **Cluster role**: Central or Site (plus site identifier for site clusters).
|
||||
- **Akka.NET remoting**: Hostname/port for inter-node and inter-cluster communication.
|
||||
- **Akka.NET remoting**: Hostname/port for inter-node and inter-cluster communication (default 8081 central, 8082 site).
|
||||
- **gRPC port** (site nodes only): Dedicated HTTP/2 port for the SiteStreamGrpcServer (default 8083). Separate from the Akka remoting port — gRPC uses Kestrel, Akka uses its own TCP transport.
|
||||
- **Local storage paths**: SQLite database locations (site nodes only).
|
||||
|
||||
## Windows Service
|
||||
|
||||
@@ -32,7 +32,8 @@ Commons must define shared primitive and utility types used across multiple comp
|
||||
- **`InstanceState` enum**: Enabled, Disabled.
|
||||
- **`DeploymentStatus` enum**: Pending, InProgress, Success, Failed.
|
||||
- **`AlarmState` enum**: Active, Normal.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange.
|
||||
- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo.
|
||||
- **`ConnectionHealth` enum**: Connected, Disconnected, Connecting, Error.
|
||||
|
||||
Types defined here must be immutable and thread-safe.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
The Communication component manages all messaging between the central cluster and site clusters using Akka.NET. It provides the transport layer for deployments, instance lifecycle commands, integration routing, debug streaming, health reporting, and remote queries (parked messages, event logs).
|
||||
The Communication component manages all messaging between the central cluster and site clusters. It provides the transport layer for deployments, instance lifecycle commands, integration routing, debug streaming, health reporting, and remote queries (parked messages, event logs). Two transports are used: **Akka.NET ClusterClient** for command/control messaging and **gRPC server-streaming** for real-time data (attribute values, alarm states).
|
||||
|
||||
## Location
|
||||
|
||||
@@ -10,12 +10,15 @@ Both central and site clusters. Each side has communication actors that handle m
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Resolve site addresses from the configuration database and maintain a cached address map.
|
||||
- Establish and maintain cross-cluster connections using Akka.NET ClusterClient/ClusterClientReceptionist.
|
||||
- Resolve site addresses (Akka remoting and gRPC) from the configuration database and maintain a cached address map.
|
||||
- Establish and maintain cross-cluster connections using Akka.NET ClusterClient/ClusterClientReceptionist for command/control.
|
||||
- Establish and maintain per-site gRPC streaming connections for real-time data delivery (site→central).
|
||||
- Route messages between central and site clusters in a hub-and-spoke topology.
|
||||
- Broker requests from external systems (via central) to sites and return responses.
|
||||
- Support multiple concurrent message patterns (request/response, fire-and-forget, streaming).
|
||||
- Detect site connectivity status for health monitoring.
|
||||
- Host the **SiteStreamGrpcServer** on site nodes (Kestrel HTTP/2) to serve real-time event streams.
|
||||
- Manage per-site **SiteStreamGrpcClient** instances on central nodes via **SiteStreamGrpcClientFactory**.
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
@@ -35,6 +38,7 @@ Both central and site clusters. Each side has communication actors that handle m
|
||||
- **Pattern**: Broadcast with per-site acknowledgment (deploy to all sites), or targeted to a single site (per-site deployment).
|
||||
- When shared scripts, external system definitions, database connections, data connections, notification lists, or SMTP configuration are explicitly deployed, central sends them to the target site(s).
|
||||
- Each site acknowledges receipt and reports success/failure independently.
|
||||
- **Shared script deployment triggers immediate recompilation on the site** — the site's `SharedScriptLibrary` replaces its in-memory compiled code, making updated shared scripts available to all running instances without redeployment. Other artifact types (external systems, database connections, etc.) are stored but do not require recompilation.
|
||||
|
||||
### 4. Integration Routing (External System → Central → Site → Central → External System)
|
||||
- **Pattern**: Request/Response (brokered).
|
||||
@@ -50,22 +54,55 @@ Both central and site clusters. Each side has communication actors that handle m
|
||||
- Site applies and acknowledges.
|
||||
|
||||
### 6. Debug Streaming (Site → Central)
|
||||
- **Pattern**: Subscribe/push with initial snapshot (no polling).
|
||||
- A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor sends a `SubscribeDebugViewRequest` to the site via `CentralCommunicationActor`. The site's `InstanceActor` stores the subscription's correlation ID and replies with an initial snapshot via the ClusterClient reply path.
|
||||
- Site requests a **snapshot** of all current attribute values and alarm states from the Instance Actor and sends it back to the bridge actor (via the ClusterClient reply path, which works for immediate responses).
|
||||
- For ongoing events, the InstanceActor wraps `AttributeValueChanged` and `AlarmStateChanged` in a `DebugStreamEvent(correlationId, event)` message and sends it to the local `SiteCommunicationActor`. The SiteCommunicationActor forwards it to central via its own ClusterClient (`ClusterClient.Send("/user/central-communication", event)`). The `CentralCommunicationActor` looks up the bridge actor by correlation ID and delivers the event. This follows the same site→central pattern as health reports.
|
||||
- **Pattern**: Subscribe/push with initial snapshot. Two transports: **ClusterClient** for the subscribe/unsubscribe handshake and initial snapshot, **gRPC server-streaming** for ongoing real-time events.
|
||||
- A **DebugStreamBridgeActor** (one per active debug session) is created on the central cluster by the **DebugStreamService**. The bridge actor first opens a **gRPC server-streaming subscription** to the site via `SiteStreamGrpcClient`, then sends a `SubscribeDebugViewRequest` to the site via `CentralCommunicationActor` (ClusterClient). The site's `InstanceActor` replies with an initial snapshot via the ClusterClient reply path.
|
||||
- **gRPC stream (real-time events)**: The site's **SiteStreamGrpcServer** receives the gRPC `SubscribeInstance` call and creates a **StreamRelayActor** that subscribes to **SiteStreamManager** for the requested instance. Events (`AttributeValueChanged`, `AlarmStateChanged`) flow from `SiteStreamManager` → `StreamRelayActor` → `Channel<SiteStreamEvent>` (bounded, 1000, DropOldest) → gRPC response stream → `SiteStreamGrpcClient` on central → `DebugStreamBridgeActor`.
|
||||
- The `DebugStreamEvent` message type no longer exists — events are not routed through ClusterClient. `SiteCommunicationActor` and `CentralCommunicationActor` have no role in streaming event delivery.
|
||||
- The bridge actor forwards received events to the consumer via callbacks (Blazor component or SignalR hub).
|
||||
- **Snapshot-to-stream handoff**: The gRPC stream is opened **before** the snapshot request to avoid missing events. The consumer applies the snapshot as baseline, then replays buffered gRPC events with timestamps newer than the snapshot (timestamp-based dedup).
|
||||
- Attribute value stream messages: `[InstanceUniqueName].[AttributePath].[AttributeName]`, value, quality, timestamp.
|
||||
- Alarm state stream messages: `[InstanceUniqueName].[AlarmName]`, state (active/normal), priority, timestamp.
|
||||
- Central sends an unsubscribe request when the debug session ends. The site removes its stream subscription and the bridge actor is stopped.
|
||||
- Central sends an unsubscribe request via ClusterClient when the debug session ends. The gRPC stream is cancelled. The site's `StreamRelayActor` is stopped and the SiteStreamManager subscription is removed.
|
||||
- The stream is session-based and temporary.
|
||||
|
||||
#### Site-Side gRPC Streaming Components
|
||||
|
||||
- **SiteStreamGrpcServer**: gRPC service (`SiteStreamService.SiteStreamServiceBase`) hosted on each site node via Kestrel HTTP/2 on a dedicated port (default 8083). Implements the `SubscribeInstance` RPC. For each subscription, creates a `StreamRelayActor` that subscribes to `SiteStreamManager`, bridges events through a `Channel<SiteStreamEvent>` to the gRPC response stream. Tracks active subscriptions by `correlation_id` — duplicate IDs cancel the old stream. Enforces a max concurrent stream limit (default 100). Rejects streams with `StatusCode.Unavailable` before the actor system is ready.
|
||||
- **StreamRelayActor**: Short-lived actor created per gRPC subscription. Receives domain events (`AttributeValueChanged`, `AlarmStateChanged`) from `SiteStreamManager`, converts them to protobuf `SiteStreamEvent` messages, and writes to the `Channel<SiteStreamEvent>` writer. Stopped when the gRPC stream is cancelled or the client disconnects.
|
||||
|
||||
#### Central-Side Debug Stream Components
|
||||
|
||||
- **DebugStreamService**: Singleton service that manages debug stream sessions. Resolves instance ID to unique name and site, creates and tears down `DebugStreamBridgeActor` instances, and provides a clean API for both Blazor components and the SignalR hub.
|
||||
- **DebugStreamBridgeActor**: One per active debug session. Acts as the Akka-level subscriber registered with the site's `InstanceActor`. Receives real-time `AttributeValueChanged` and `AlarmStateChanged` events from the site and forwards them to the consumer via callbacks.
|
||||
- **DebugStreamService**: Singleton service that manages debug stream sessions. Resolves instance ID to unique name and site, creates and tears down `DebugStreamBridgeActor` instances, and provides a clean API for both Blazor components and the SignalR hub. Injects `SiteStreamGrpcClientFactory` for gRPC stream creation.
|
||||
- **DebugStreamBridgeActor**: One per active debug session. Opens a gRPC streaming subscription via `SiteStreamGrpcClient` and receives real-time events via callback. Also receives the initial `DebugViewSnapshot` via ClusterClient. Forwards all events to the consumer via callbacks. Handles gRPC stream errors with reconnection logic: tries the other site node endpoint, retries with backoff (max 3 retries), terminates the session if all retries fail.
|
||||
- **SiteStreamGrpcClient**: Per-site gRPC client that manages `GrpcChannel` instances and streaming subscriptions. Reads from the gRPC response stream in a background task, converts protobuf messages to domain events, and invokes the `onEvent` callback.
|
||||
- **SiteStreamGrpcClientFactory**: Caches per-site `SiteStreamGrpcClient` instances. Reads `GrpcNodeAAddress` / `GrpcNodeBAddress` from the `Site` entity (loaded by `CentralCommunicationActor`). Falls back to NodeB if NodeA connection fails. Disposes clients on site removal or address change.
|
||||
- **DebugStreamHub**: SignalR hub at `/hubs/debug-stream` for external consumers (e.g., CLI). Authenticates via Basic Auth + LDAP and requires the **Deployment** role. Server-to-client methods: `OnSnapshot`, `OnAttributeChanged`, `OnAlarmChanged`, `OnStreamTerminated`.
|
||||
|
||||
#### gRPC Proto Definition
|
||||
|
||||
The streaming protocol is defined in `sitestream.proto` (`src/ScadaLink.Communication/Protos/sitestream.proto`):
|
||||
|
||||
- **Service**: `SiteStreamService` with a single RPC `SubscribeInstance(InstanceStreamRequest) returns (stream SiteStreamEvent)`.
|
||||
- **Messages**: `InstanceStreamRequest` (correlation_id, instance_unique_name), `SiteStreamEvent` (correlation_id, oneof event: `AttributeValueUpdate`, `AlarmStateUpdate`).
|
||||
- The `oneof event` pattern is extensible — future event types (health metrics, connection state changes) are added as new fields without breaking existing consumers.
|
||||
- Proto field numbers are never reused. Old clients ignore unknown `oneof` variants.
|
||||
|
||||
#### gRPC Connection Keepalive
|
||||
|
||||
Three layers of dead-client detection prevent orphan streams on site nodes:
|
||||
|
||||
| Layer | Detects | Timeline | Mechanism |
|
||||
|-------|---------|----------|-----------|
|
||||
| TCP RST | Clean process death, connection close | 1–5s | OS-level TCP, `WriteAsync` throws |
|
||||
| gRPC keepalive PING | Network partition, silent crash, firewall drop | ~25s | HTTP/2 PING frames, `CancellationToken` fires |
|
||||
| Session timeout | Misconfigured keepalive, long-lived zombie streams | 4 hours | `CancellationTokenSource.CancelAfter` |
|
||||
|
||||
Keepalive settings are configurable via `CommunicationOptions`:
|
||||
- `GrpcKeepAlivePingDelay`: 15 seconds (default)
|
||||
- `GrpcKeepAlivePingTimeout`: 10 seconds (default)
|
||||
- `GrpcMaxStreamLifetime`: 4 hours (default)
|
||||
- `GrpcMaxConcurrentStreams`: 100 (default)
|
||||
|
||||
### 6a. Debug Snapshot (Central → Site)
|
||||
- **Pattern**: Request/Response (one-shot, no subscription).
|
||||
- Central sends a `DebugSnapshotRequest` (identified by instance unique name) to the site.
|
||||
@@ -91,12 +128,17 @@ Both central and site clusters. Each side has communication actors that handle m
|
||||
|
||||
```
|
||||
Central Cluster
|
||||
├── ClusterClient → Site A Cluster (SiteCommunicationActor via Receptionist)
|
||||
├── ClusterClient → Site B Cluster (SiteCommunicationActor via Receptionist)
|
||||
└── ClusterClient → Site N Cluster (SiteCommunicationActor via Receptionist)
|
||||
├── ClusterClient → Site A Cluster (SiteCommunicationActor via Receptionist) [command/control]
|
||||
├── ClusterClient → Site B Cluster (SiteCommunicationActor via Receptionist) [command/control]
|
||||
└── ClusterClient → Site N Cluster (SiteCommunicationActor via Receptionist) [command/control]
|
||||
│
|
||||
├── SiteStreamGrpcClient ◄── gRPC stream ── Site A (SiteStreamGrpcServer) [real-time data]
|
||||
├── SiteStreamGrpcClient ◄── gRPC stream ── Site B (SiteStreamGrpcServer) [real-time data]
|
||||
└── SiteStreamGrpcClient ◄── gRPC stream ── Site N (SiteStreamGrpcServer) [real-time data]
|
||||
|
||||
Site Clusters
|
||||
└── ClusterClient → Central Cluster (CentralCommunicationActor via Receptionist)
|
||||
└── ClusterClient → Central Cluster (CentralCommunicationActor via Receptionist) [command/control]
|
||||
└── SiteStreamGrpcServer (Kestrel HTTP/2, port 8083) → serves gRPC streams [real-time data]
|
||||
```
|
||||
|
||||
- Sites do **not** communicate with each other.
|
||||
@@ -107,8 +149,8 @@ Site Clusters
|
||||
|
||||
Central discovers site addresses through the **configuration database**, not runtime registration:
|
||||
|
||||
- Each site record in the Sites table includes optional **NodeAAddress** and **NodeBAddress** fields containing base Akka addresses of the site's cluster nodes (e.g., `akka.tcp://scadalink@host:port`).
|
||||
- The **CentralCommunicationActor** loads all site addresses from the database at startup and creates one **ClusterClient per site**, configured with both NodeA and NodeB as contact points.
|
||||
- Each site record in the Sites table includes optional **NodeAAddress** and **NodeBAddress** fields containing base Akka addresses of the site's cluster nodes (e.g., `akka.tcp://scadalink@host:port`), and optional **GrpcNodeAAddress** and **GrpcNodeBAddress** fields containing gRPC endpoints (e.g., `http://host:8083`).
|
||||
- The **CentralCommunicationActor** loads all site addresses from the database at startup and creates one **ClusterClient per site**, configured with both NodeA and NodeB as contact points. The **SiteStreamGrpcClientFactory** uses `GrpcNodeAAddress` / `GrpcNodeBAddress` to create per-site gRPC channels for streaming.
|
||||
- The address cache is **refreshed every 60 seconds** and **on-demand** when site records are added, edited, or deleted via the Central UI or CLI. ClusterClient instances are recreated when contact points change.
|
||||
- When routing a message to a site, central sends via `ClusterClient.Send("/user/site-communication", msg)`. **ClusterClient handles failover between NodeA and NodeB internally** — there is no application-level NodeA preference/NodeB fallback logic.
|
||||
- **Heartbeats** from sites serve **health monitoring only** — they do not serve as a registration or address discovery mechanism.
|
||||
@@ -166,7 +208,7 @@ The ManagementActor is registered at the well-known path `/user/management` on c
|
||||
## Connection Failure Behavior
|
||||
|
||||
- **In-flight messages**: When a connection drops while a request is in flight (e.g., deployment sent but no response received), the Akka ask pattern times out and the caller receives a failure. There is **no automatic retry or buffering at central** — the engineer sees the failure in the UI and re-initiates the action. This is consistent with the design principle that central does not buffer messages.
|
||||
- **Debug streams**: Any connection interruption (failover or network blip) kills the debug stream. The `DebugStreamBridgeActor` is stopped and the consumer is notified via `OnStreamTerminated`. The engineer must reopen the debug view to re-establish the subscription with a fresh snapshot. There is no auto-resume.
|
||||
- **Debug streams**: Any gRPC stream interruption triggers reconnection logic in the `DebugStreamBridgeActor`. The bridge actor attempts to reconnect to the other site node endpoint (NodeB if NodeA failed, or vice versa), with up to 3 retries and 5-second backoff. If all retries fail, the consumer is notified via `OnStreamTerminated` and the bridge actor is stopped. Events during the reconnection gap are lost (acceptable for real-time debug view). On successful reconnection, the consumer can request a fresh snapshot to re-sync state.
|
||||
|
||||
## Failover Behavior
|
||||
|
||||
@@ -175,9 +217,11 @@ The ManagementActor is registered at the well-known path `/user/management` on c
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Akka.NET Remoting + ClusterClient**: Provides the transport layer. ClusterClient/ClusterClientReceptionist used for all cross-cluster messaging.
|
||||
- **Akka.NET Remoting + ClusterClient**: Provides the command/control transport layer. ClusterClient/ClusterClientReceptionist used for cross-cluster command/control messaging (deployments, lifecycle, subscribe/unsubscribe handshake, snapshots).
|
||||
- **gRPC (Grpc.AspNetCore + Grpc.Net.Client)**: Provides the real-time data streaming transport. Site nodes host a gRPC server (SiteStreamGrpcServer); central nodes create per-site gRPC clients (SiteStreamGrpcClient).
|
||||
- **Cluster Infrastructure**: Manages node roles and failover detection.
|
||||
- **Configuration Database**: Provides site node addresses (NodeAAddress, NodeBAddress) for address resolution.
|
||||
- **Configuration Database**: Provides site node addresses (NodeAAddress, NodeBAddress for Akka remoting; GrpcNodeAAddress, GrpcNodeBAddress for gRPC streaming) for address resolution.
|
||||
- **Site Runtime (SiteStreamManager)**: The SiteStreamGrpcServer subscribes to SiteStreamManager to receive real-time events for gRPC delivery.
|
||||
|
||||
## Interactions
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ Central cluster only. Site clusters do not access the configuration database —
|
||||
The configuration database stores all central system data, organized by domain area:
|
||||
|
||||
### Template & Modeling
|
||||
- **Templates**: Template definitions (name, parent template reference, description).
|
||||
- **Templates**: Template definitions (name, parent template reference, description, nullable `FolderId` FK to `TemplateFolders` — null means the template lives at the tree root).
|
||||
- **TemplateFolders**: Hierarchical organizational folders for templates (`Id`, `Name`, nullable `ParentFolderId` self-reference, `SortOrder`). Unique index on `(ParentFolderId, Name)` enforces case-insensitive sibling uniqueness. Folders are UI-only — they have no effect on template resolution or flattening.
|
||||
- **Template Attributes**: Attribute definitions per template (name, value, data type, lock flag, description, data source reference).
|
||||
- **Template Alarms**: Alarm definitions per template (name, description, priority, lock flag, trigger type, trigger configuration, on-trigger script reference).
|
||||
- **Template Scripts**: Script definitions per template (name, lock flag, C# source code, trigger type, trigger configuration, minimum time between runs, parameter definitions, return value definitions).
|
||||
@@ -42,7 +43,7 @@ The configuration database stores all central system data, organized by domain a
|
||||
- **Shared Scripts**: System-wide reusable script definitions (name, C# source code, parameter definitions, return value definitions).
|
||||
|
||||
### Sites & Data Connections
|
||||
- **Sites**: Site definitions (name, identifier, description).
|
||||
- **Sites**: Site definitions (name, identifier, description, NodeAAddress, NodeBAddress, GrpcNodeAAddress, GrpcNodeBAddress).
|
||||
- **Data Connections**: Data connection definitions (name, protocol type, connection details) with site assignments.
|
||||
|
||||
### External Systems & Database Connections
|
||||
|
||||
@@ -10,7 +10,7 @@ Site clusters only. Central does not interact with machines directly.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Manage data connections defined centrally and deployed to sites as part of artifact deployment (OPC UA servers, LmxProxy endpoints). Data connection definitions are stored in local SQLite after deployment.
|
||||
- Manage data connections defined centrally and deployed to sites as part of artifact deployment (OPC UA servers). Data connection definitions are stored in local SQLite after deployment.
|
||||
- Establish and maintain connections to data sources based on deployed instance configurations.
|
||||
- Subscribe to tag paths as requested by Instance Actors (based on attribute data source references in the flattened configuration).
|
||||
- Deliver tag value updates to the requesting Instance Actors.
|
||||
@@ -19,7 +19,7 @@ Site clusters only. Central does not interact with machines directly.
|
||||
|
||||
## Common Interface
|
||||
|
||||
Both OPC UA and LmxProxy implement the same interface:
|
||||
All protocol adapters implement the same interface:
|
||||
|
||||
```
|
||||
IDataConnection : IAsyncDisposable
|
||||
@@ -38,32 +38,16 @@ IDataConnection : IAsyncDisposable
|
||||
|
||||
The `Disconnected` event is raised by an adapter when it detects an unexpected connection loss (server offline, network failure, keep-alive timeout). The `DataConnectionActor` subscribes to this event to trigger the reconnection state machine. Additional protocols can be added by implementing this interface.
|
||||
|
||||
### Concrete Type Mappings
|
||||
|
||||
| IDataConnection | OPC UA SDK | LmxProxy (`RealLmxProxyClient`) |
|
||||
|---|---|---|
|
||||
| `Connect()` | OPC UA session establishment | gRPC `Connect` RPC with `x-api-key` metadata header, server returns `SessionId` |
|
||||
| `Disconnect()` | Close OPC UA session | gRPC `Disconnect` RPC |
|
||||
| `Subscribe(tagPath, callback)` | OPC UA Monitored Items | gRPC `Subscribe` server-streaming RPC (`stream VtqMessage`), cancelled via `CancellationTokenSource` |
|
||||
| `Unsubscribe(id)` | Remove Monitored Item | Cancel the `CancellationTokenSource` for that subscription (stops streaming RPC) |
|
||||
| `Read(tagPath)` | OPC UA Read | gRPC `Read` RPC → `VtqMessage` → `LmxVtq` |
|
||||
| `ReadBatch(tagPaths)` | OPC UA Read (multiple nodes) | gRPC `ReadBatch` RPC → `repeated VtqMessage` → `IDictionary<string, LmxVtq>` |
|
||||
| `Write(tagPath, value)` | OPC UA Write | gRPC `Write` RPC (throws on failure) |
|
||||
| `WriteBatch(values)` | OPC UA Write (multiple nodes) | gRPC `WriteBatch` RPC (throws on failure) |
|
||||
| `WriteBatchAndWait(...)` | OPC UA Write + poll for confirmation | `WriteBatch` + poll `Read` at 100ms intervals until response value matches or timeout |
|
||||
| `Status` | OPC UA session state | `IsConnected` — true when `SessionId` is non-empty |
|
||||
| `Disconnected` | `Session.KeepAlive` event fires with bad `ServiceResult` | gRPC subscription stream ends or throws non-cancellation `RpcException` |
|
||||
|
||||
### Common Value Type
|
||||
|
||||
Both protocols produce the same value tuple consumed by Instance Actors. Before the first value update arrives from the DCL, data-sourced attributes are held at **uncertain** quality by the Instance Actor (see Site Runtime — Initialization):
|
||||
All protocols produce the same value tuple consumed by Instance Actors. Before the first value update arrives from the DCL, data-sourced attributes are held at **uncertain** quality by the Instance Actor (see Site Runtime — Initialization):
|
||||
|
||||
| Concept | ScadaLink Design | LmxProxy Wire Format | Local Type |
|
||||
|---|---|---|---|
|
||||
| Value container | `TagValue(Value, Quality, Timestamp)` | `VtqMessage { Tag, Value, TimestampUtcTicks, Quality }` | `LmxVtq(Value, TimestampUtc, Quality)` — readonly record struct |
|
||||
| Quality | `QualityCode` enum: Good / Bad / Uncertain | String: `"Good"` / `"Uncertain"` / `"Bad"` | `LmxQuality` enum: Good / Uncertain / Bad |
|
||||
| Timestamp | `DateTimeOffset` (UTC) | `int64` (DateTime.Ticks, UTC) | `DateTime` (UTC) |
|
||||
| Value type | `object?` | `string` (parsed by client to double, bool, or string) | `object?` |
|
||||
| Concept | ScadaLink Design |
|
||||
|---|---|
|
||||
| Value container | `TagValue(Value, Quality, Timestamp)` |
|
||||
| Quality | `QualityCode` enum: Good / Bad / Uncertain |
|
||||
| Timestamp | `DateTimeOffset` (UTC) |
|
||||
| Value type | `object?` |
|
||||
|
||||
## Supported Protocols
|
||||
|
||||
@@ -74,39 +58,46 @@ Both protocols produce the same value tuple consumed by Instance Actors. Before
|
||||
- Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping.
|
||||
- Disconnect detection via `Session.KeepAlive` event (see Disconnect Detection Pattern below).
|
||||
|
||||
### LmxProxy (Custom Protocol)
|
||||
## Endpoint Redundancy
|
||||
|
||||
LmxProxy is a gRPC-based protocol for communicating with LMX data servers. The DCL includes its own proto-generated gRPC client (`RealLmxProxyClient`) — no external SDK dependency.
|
||||
Data connections support an optional backup endpoint for automatic failover when the active endpoint becomes unreachable. Both endpoints use the same protocol.
|
||||
|
||||
**Transport & Connection**:
|
||||
- gRPC over HTTP/2, using proto-generated client stubs from `scada.proto` (service: `scada.ScadaService`). Pre-generated C# files are checked into `Adapters/LmxProxyGrpc/` to avoid running `protoc` in Docker (ARM64 compatibility).
|
||||
- Default port: **50051**.
|
||||
- Session-based: `Connect` RPC returns a `SessionId` used for all subsequent operations.
|
||||
- Keep-alive: Managed by the LmxProxy server's session timeout. The DCL reconnect cycle handles session loss.
|
||||
**Entity fields:**
|
||||
|
||||
**Authentication & TLS**:
|
||||
- API key-based authentication sent as `x-api-key` gRPC metadata header on every call. The server's `ApiKeyInterceptor` validates the header before the request reaches the service method. The API key is also included in the `ConnectRequest` body for session-level validation.
|
||||
- Plain HTTP/2 (no TLS) for current deployments. The server supports TLS when configured.
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `PrimaryConfiguration` | string? (max 4000) | Required. Renamed from `Configuration` |
|
||||
| `BackupConfiguration` | string? (max 4000) | Optional. Null = no backup |
|
||||
| `FailoverRetryCount` | int (default 3) | Retries on active endpoint before switching |
|
||||
|
||||
**Subscriptions**:
|
||||
- Server-streaming gRPC (`Subscribe` RPC returns `stream VtqMessage`).
|
||||
- Configurable sampling interval (default: 0 = on-change).
|
||||
- Wire format: `VtqMessage { tag, value (string), timestamp_utc_ticks (int64), quality (string: "Good"/"Uncertain"/"Bad") }`.
|
||||
- Subscription lifetime managed by `CancellationTokenSource` — cancellation stops the streaming RPC.
|
||||
**Failover state machine:**
|
||||
|
||||
**Client Implementation** (`RealLmxProxyClient`):
|
||||
- Uses `Google.Protobuf` + `Grpc.Net.Client` (standard proto-generated stubs, no protobuf-net runtime IL emit).
|
||||
- `ILmxProxyClientFactory` creates instances configured with host, port, and API key.
|
||||
- Value conversion: string values from `VtqMessage` are parsed to `double`, `bool`, or left as `string`.
|
||||
- Quality mapping: `"Good"` → `LmxQuality.Good`, `"Uncertain"` → `LmxQuality.Uncertain`, else `LmxQuality.Bad`.
|
||||
```
|
||||
Connected → disconnect → push bad quality → retry active endpoint (5s)
|
||||
→ N failures (≥ FailoverRetryCount) → switch to other endpoint
|
||||
→ dispose adapter, create fresh adapter with other config
|
||||
→ reconnect → ReSubscribeAll → Connected
|
||||
```
|
||||
|
||||
**Proto Source**: The `.proto` file originates from the LmxProxy server repository (`lmx/Proxy/Grpc/Protos/scada.proto` in ScadaBridge). The C# stubs are pre-generated and stored at `Adapters/LmxProxyGrpc/`.
|
||||
- **Round-robin**: primary → backup → primary → backup. No preferred endpoint after first failover — the connection stays on whichever endpoint is working.
|
||||
- **No auto-failback**: The connection remains on the active endpoint until it fails.
|
||||
- **Single-endpoint connections** (no backup): Retry indefinitely on the same endpoint, preserving existing behavior.
|
||||
- **Adapter lifecycle on failover**: The actor disposes the current `IDataConnection` adapter and creates a fresh one via `DataConnectionFactory.Create()` with the other endpoint's configuration. Clean slate — no stale state.
|
||||
|
||||
**Test Infrastructure**: The `infra/lmxfakeproxy/` project provides a fake LmxProxy server that bridges to the OPC UA test server. It implements the full `scada.ScadaService` proto, enabling end-to-end testing of `RealLmxProxyClient` without a Windows LmxProxy deployment. See [test_infra_lmxfakeproxy.md](../test_infra/test_infra_lmxfakeproxy.md) for setup.
|
||||
**Health reporting:**
|
||||
|
||||
- `DataConnectionHealthReport` includes `ActiveEndpoint`: `"Primary"`, `"Backup"`, or `"Primary (no backup)"`.
|
||||
|
||||
**Site event log entries:**
|
||||
|
||||
- `DataConnectionFailover` (Warning) — connection name, from-endpoint, to-endpoint, failure count.
|
||||
- `DataConnectionRestored` (Info) — connection name, active endpoint.
|
||||
|
||||
See [`2026-03-22-primary-backup-data-connections-design.md`](../plans/2026-03-22-primary-backup-data-connections-design.md) for the full design.
|
||||
|
||||
## Connection Configuration Reference
|
||||
|
||||
All settings are parsed from the data connection's `Configuration` JSON dictionary (stored as `IDictionary<string, string>` connection details). Invalid numeric values fall back to defaults silently.
|
||||
All settings are parsed from the data connection's configuration JSON dictionaries (`PrimaryConfiguration` and optional `BackupConfiguration`, stored as `IDictionary<string, string>` connection details). Both endpoints use the same protocol-specific keys. Invalid numeric values fall back to defaults silently.
|
||||
|
||||
### OPC UA Settings
|
||||
|
||||
@@ -124,16 +115,6 @@ All settings are parsed from the data connection's `Configuration` JSON dictiona
|
||||
| `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` |
|
||||
| `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates |
|
||||
|
||||
### LmxProxy Settings
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `Host` | string | `localhost` | LmxProxy server hostname |
|
||||
| `Port` | int | `50051` | LmxProxy gRPC port |
|
||||
| `ApiKey` | string | *(none)* | API key for `x-api-key` header authentication |
|
||||
| `SamplingIntervalMs` | int | `0` | Subscription sampling interval: 0 = on-change, >0 = time-based (ms) |
|
||||
| `UseTls` | bool | `false` | Use HTTPS instead of plain HTTP/2 for gRPC channel |
|
||||
|
||||
### Shared Settings (appsettings.json)
|
||||
|
||||
These are configured via `DataConnectionOptions` in `appsettings.json`, not per-connection:
|
||||
@@ -143,7 +124,6 @@ These are configured via `DataConnectionOptions` in `appsettings.json`, not per-
|
||||
| `ReconnectInterval` | 5s | Fixed interval between reconnection attempts |
|
||||
| `TagResolutionRetryInterval` | 10s | Retry interval for unresolved tag paths |
|
||||
| `WriteTimeout` | 30s | Timeout for write operations |
|
||||
| `LmxProxyKeepAliveInterval` | 30s | Keep-alive ping interval for LmxProxy sessions |
|
||||
|
||||
## Subscription Management
|
||||
|
||||
@@ -178,8 +158,6 @@ Each data connection is managed by a dedicated connection actor that uses the Ak
|
||||
|
||||
This pattern ensures no messages are lost during connection transitions and is the standard Akka.NET approach for actors with I/O lifecycle dependencies.
|
||||
|
||||
**LmxProxy-specific notes**: The `RealLmxProxyClient` holds the `SessionId` returned by the `Connect` RPC and includes it in all subsequent operations. Subscriptions use server-streaming gRPC — a background task reads from the `ResponseStream` and invokes the callback for each `VtqMessage`. When the stream breaks (server offline, network failure), the background task detects the `RpcException` or stream end and invokes the `onStreamError` callback, which triggers the adapter's `Disconnected` event. The DCL actor transitions to **Reconnecting**, pushes bad quality, disposes the client, and retries at the fixed interval.
|
||||
|
||||
**OPC UA-specific notes**: The `RealOpcUaClient` uses the OPC Foundation SDK's `Session.KeepAlive` event for proactive disconnect detection. The SDK sends keep-alive requests at the subscription's `KeepAliveCount × PublishingInterval` (default: 10s). When keep-alive fails, the `ConnectionLost` event fires, triggering the same reconnection flow. On reconnection, the DCL re-creates the OPC UA session and subscription, then re-adds all monitored items.
|
||||
|
||||
## Connection Lifecycle & Reconnection
|
||||
@@ -197,14 +175,13 @@ Each adapter implements the `IDataConnection.Disconnected` event to proactively
|
||||
|
||||
**Proactive detection** (server goes offline between operations):
|
||||
- **OPC UA**: The OPC Foundation SDK fires `Session.KeepAlive` events at regular intervals. `RealOpcUaClient` hooks this event; when `ServiceResult.IsBad(e.Status)` (server unreachable, keep-alive timeout), it fires `ConnectionLost`. The `OpcUaDataConnection` adapter translates this into `IDataConnection.Disconnected`.
|
||||
- **LmxProxy**: gRPC server-streaming subscriptions run in background tasks reading from `ResponseStream`. When the server goes offline, the stream either ends normally (server closed) or throws a non-cancellation `RpcException`. `RealLmxProxyClient` invokes the `onStreamError` callback, which `LmxProxyDataConnection` translates into `IDataConnection.Disconnected`.
|
||||
|
||||
**Reactive detection** (failure discovered during an operation):
|
||||
- Both adapters wrap `ReadAsync` (and by extension `ReadBatchAsync`) with exception handling. If a read throws a non-cancellation exception, the adapter calls `RaiseDisconnected()` and re-throws. The `DataConnectionActor`'s existing error handling catches the exception while the disconnect event triggers the reconnection state machine.
|
||||
|
||||
**Event marshalling**: The `DataConnectionActor` subscribes to `_adapter.Disconnected` in `PreStart()`. Since `Disconnected` may fire from a background thread (gRPC stream task, OPC UA keep-alive timer), the handler sends an `AdapterDisconnected` message to `Self`, marshalling the notification onto the actor's message loop. This triggers `BecomeReconnecting()` → bad quality push → retry timer.
|
||||
|
||||
**Once-only guard**: Both `LmxProxyDataConnection` and `OpcUaDataConnection` use a `volatile bool _disconnectFired` flag to ensure `RaiseDisconnected()` fires exactly once per connection session. The flag resets on successful reconnection (`ConnectAsync`).
|
||||
**Once-only guard**: `OpcUaDataConnection` uses a `volatile bool _disconnectFired` flag to ensure `RaiseDisconnected()` fires exactly once per connection session. The flag resets on successful reconnection (`ConnectAsync`).
|
||||
|
||||
## Write Failure Handling
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ The Host must bind configuration sections from `appsettings.json` to strongly-ty
|
||||
|
||||
| Section | Options Class | Owner | Contents |
|
||||
|---------|--------------|-------|----------|
|
||||
| `ScadaLink:Node` | `NodeOptions` | Host | Role, NodeHostname, SiteId, RemotingPort |
|
||||
| `ScadaLink:Node` | `NodeOptions` | Host | Role, NodeHostname, SiteId, RemotingPort, GrpcPort (site only, default 8083) |
|
||||
| `ScadaLink:Cluster` | `ClusterOptions` | ClusterInfrastructure | SeedNodes, SplitBrainResolverStrategy, StableAfter, HeartbeatInterval, FailureDetectionThreshold, MinNrOfMembers |
|
||||
| `ScadaLink:Database` | `DatabaseOptions` | Host | Central: ConfigurationDb, MachineDataDb connection strings; Site: SQLite paths |
|
||||
|
||||
@@ -79,6 +79,7 @@ Before the Akka.NET actor system is created, the Host must validate all required
|
||||
- `NodeConfiguration.Role` must be a valid `NodeRole` value.
|
||||
- `NodeConfiguration.NodeHostname` must not be null or empty.
|
||||
- `NodeConfiguration.RemotingPort` must be in valid port range (1–65535).
|
||||
- Site nodes must have `GrpcPort` in valid port range (1–65535) and different from `RemotingPort`.
|
||||
- Site nodes must have a non-empty `SiteId`.
|
||||
- Central nodes must have non-empty `ConfigurationDb` and `MachineDataDb` connection strings.
|
||||
- Site nodes must have non-empty SQLite path values. Site nodes do **not** require a `ConfigurationDb` connection string — all configuration is received via artifact deployment and read from local SQLite.
|
||||
@@ -112,14 +113,24 @@ The Host must configure the Akka.NET actor system using Akka.Hosting with:
|
||||
|
||||
On central nodes, the Host must configure the Akka.NET **ClusterClientReceptionist** and register the ManagementActor with it. This allows external processes (e.g., the CLI) to discover and communicate with the ManagementActor via ClusterClient without joining the cluster as full members. The receptionist is started as part of the Akka.NET bootstrap (REQ-HOST-6) on central nodes only.
|
||||
|
||||
### REQ-HOST-7: ASP.NET Web Endpoints (Central Only)
|
||||
### REQ-HOST-7: ASP.NET Web Endpoints
|
||||
|
||||
On central nodes, the Host must use `WebApplication.CreateBuilder` to produce a full ASP.NET Core host with Kestrel, and must map web endpoints for:
|
||||
|
||||
- Central UI (via `MapCentralUI()` extension method).
|
||||
- Inbound API (via `MapInboundAPI()` extension method).
|
||||
|
||||
On site nodes, the Host must use `Host.CreateDefaultBuilder` to produce a generic `IHost` — **not** a `WebApplication`. This ensures no Kestrel server is started, no HTTP port is opened, and no web endpoint or middleware pipeline is configured. Site nodes are headless and must never accept inbound HTTP connections.
|
||||
On site nodes, the Host must also use `WebApplication.CreateBuilder` (not `Host.CreateDefaultBuilder`) to host the **SiteStreamGrpcServer** via Kestrel HTTP/2 on the configured `GrpcPort` (default 8083). Kestrel is configured with `HttpProtocols.Http2` on the gRPC port only — no HTTP/1.1 web endpoints are exposed. The gRPC service is mapped via `MapGrpcService<SiteStreamGrpcServer>()`.
|
||||
|
||||
**Startup ordering (site nodes)**:
|
||||
1. Actor system and SiteStreamManager must be initialized before gRPC begins accepting connections.
|
||||
2. The gRPC server rejects streams with `StatusCode.Unavailable` until the actor system is ready.
|
||||
|
||||
**Shutdown ordering (site nodes)**:
|
||||
1. On `CoordinatedShutdown`, stop accepting new gRPC streams first.
|
||||
2. Cancel all active gRPC streams (triggering client-side reconnect).
|
||||
3. Tear down actors.
|
||||
4. Use `IHostApplicationLifetime.ApplicationStopping` to signal the gRPC server.
|
||||
|
||||
### REQ-HOST-8: Structured Logging
|
||||
|
||||
|
||||
@@ -99,6 +99,21 @@ Each API method definition includes:
|
||||
- This allows complex request/response structures (e.g., an object containing properties and a list of nested objects).
|
||||
- Template attributes retain the simpler four-type system. The extended types apply only to Inbound API method definitions and External System Gateway method definitions.
|
||||
|
||||
## Script Compilation & Hot-Reload
|
||||
|
||||
API method scripts are compiled at central startup — all method definitions are loaded from the configuration database and compiled into in-memory delegates.
|
||||
|
||||
### Update Workflow
|
||||
|
||||
- Updating a method via the CLI (`api-method update --id <N> --code '...'`) or Management API triggers immediate recompilation (`CompileAndRegister`). The updated script takes effect on the next API call — no node restart is required.
|
||||
- Creating a new method after startup: if the method is created but not yet compiled, the first invocation triggers lazy (on-demand) compilation.
|
||||
|
||||
### Direct SQL Warning
|
||||
|
||||
> **Do not edit API method scripts via direct SQL.** The in-memory compiled script will not be updated until the next node restart. Always use the CLI, Management API, or Central UI to modify API method scripts.
|
||||
|
||||
---
|
||||
|
||||
## API Call Logging
|
||||
|
||||
- **Only failures are logged.** Script execution errors (500 responses) are logged centrally.
|
||||
@@ -141,6 +156,10 @@ Inbound API scripts **cannot** call shared scripts directly — shared scripts a
|
||||
- **Input parameters** are available as defined in the method definition.
|
||||
- **Return value** construction matching the defined return structure.
|
||||
|
||||
#### Parameter Access
|
||||
- `Parameters["key"]` — Raw dictionary access.
|
||||
- `Parameters.Get<T>("key")` — Typed access (same API as site runtime scripts). See Site Runtime component for full type support.
|
||||
|
||||
#### Database Access
|
||||
- `Database.Connection("connectionName")` — Obtain a raw MS SQL client connection for querying the configuration or machine data databases directly from central.
|
||||
|
||||
|
||||
@@ -83,6 +83,15 @@ The endpoint performs LDAP authentication and role resolution server-side, colla
|
||||
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
|
||||
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
|
||||
|
||||
### Template Folders
|
||||
|
||||
- **ListTemplateFolders**: List all template folders (read-only; any authenticated user).
|
||||
- **CreateTemplateFolder** (`Name`, `ParentFolderId?`): Create a folder, optionally nested under a parent (Design role).
|
||||
- **RenameTemplateFolder** (`FolderId`, `NewName`): Rename a folder; enforces sibling uniqueness (Design role).
|
||||
- **MoveTemplateFolder** (`FolderId`, `NewParentFolderId?`): Move a folder to a new parent (or root); rejects cycles (Design role).
|
||||
- **DeleteTemplateFolder** (`FolderId`): Delete a folder; blocked if the folder contains any subfolders or templates (Design role).
|
||||
- **MoveTemplateToFolder** (`TemplateId`, `NewFolderId?`): Move a template into a folder, or to the root when null (Design role).
|
||||
|
||||
### Template Members
|
||||
|
||||
- **AddTemplateAttribute** / **UpdateTemplateAttribute** / **DeleteTemplateAttribute**: Manage attributes on a template.
|
||||
|
||||
@@ -113,7 +113,7 @@ Deployment Manager Singleton (Cluster Singleton)
|
||||
|
||||
### Debug View Support
|
||||
- On request from central (via Communication Layer), the Instance Actor provides a **snapshot** of all current attribute values and alarm states.
|
||||
- Subsequent changes are delivered via the site-wide Akka stream, filtered by instance unique name.
|
||||
- Subsequent changes are delivered via the **SiteStreamManager** → **SiteStreamGrpcServer** → gRPC stream to central. The Instance Actor publishes attribute value and alarm state changes to the SiteStreamManager; it does not forward events directly to the Communication Layer.
|
||||
- The Instance Actor also handles one-shot `DebugSnapshotRequest` messages: it builds the same snapshot (attribute values and alarm states) and replies directly to the sender. Unlike `SubscribeDebugViewRequest`, no subscriber is registered and no stream is established.
|
||||
|
||||
### Supervision Strategy
|
||||
@@ -176,19 +176,20 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
### Alarm Evaluation
|
||||
- Subscribes to attribute change notifications from its parent Instance Actor for the attribute(s) referenced by its trigger definition.
|
||||
- On each value update, evaluates the trigger condition:
|
||||
- **Value Match**: Incoming value equals the predefined target.
|
||||
- **Value Match**: Incoming value equals the predefined target. Supports `"!=X"` prefix for not-equals semantics.
|
||||
- **Range Violation**: Value is outside the allowed min/max range.
|
||||
- **Rate of Change**: Value change rate exceeds the defined threshold over time.
|
||||
- When the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
|
||||
- **Rate of Change**: Value change rate exceeds the defined threshold over a configurable time window. Direction filter (rising / falling / either) restricts which side of the rate triggers.
|
||||
- **HiLo**: Multi-setpoint level alarm with up to four configurable setpoints (LoLo, Lo, Hi, HiHi). Any subset may be configured. Each setpoint may carry its own priority that overrides the alarm-level priority for that band.
|
||||
- For binary trigger types (ValueMatch / RangeViolation / RateOfChange), when the condition is met and the alarm is currently in **normal** state, the alarm transitions to **active**:
|
||||
- Updates the alarm state on the parent Instance Actor (which publishes to the Akka stream).
|
||||
- If an on-trigger script is defined, spawns an Alarm Execution Actor to execute it.
|
||||
- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**:
|
||||
- Updates the alarm state on the parent Instance Actor.
|
||||
- No script execution on clear.
|
||||
- When the condition clears and the alarm is in **active** state, the alarm transitions to **normal**.
|
||||
- For HiLo triggers, the actor tracks the current `AlarmLevel` (None / Low / LowLow / High / HighHigh). Each level transition emits a fresh `AlarmStateChanged` with the new level and its priority; level escalations (e.g., High → HighHigh) and de-escalations (HighHigh → High) both produce events. The on-trigger script fires only on the Normal → non-None edge, not on escalations between alarm bands.
|
||||
- No script execution on clear in any trigger type.
|
||||
|
||||
### Alarm State
|
||||
- Held **in memory** only — not persisted to SQLite.
|
||||
- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state and transition to active when conditions are detected.
|
||||
- Held **in memory** only — not persisted to SQLite. State comprises `AlarmState` (Active / Normal) and `AlarmLevel` (None for binary triggers; the active band for HiLo).
|
||||
- On restart (or failover), alarm states are re-evaluated from incoming values. All alarms start in normal state with level None and transition based on incoming values.
|
||||
|
||||
### Alarm Execution Actor
|
||||
- **Short-lived** child actor created when an on-trigger script needs to execute.
|
||||
@@ -209,6 +210,32 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
||||
|
||||
---
|
||||
|
||||
## Script Lifecycle
|
||||
|
||||
All script types can be updated without restarting the cluster, but the mechanism differs per type.
|
||||
|
||||
### Instance Scripts and Alarm On-Trigger Scripts
|
||||
|
||||
- Compiled at deployment time when the Deployment Manager receives a flattened configuration and creates the Instance Actor hierarchy.
|
||||
- **To update**: modify the script in the template, then redeploy the instance (`instance deploy --id <N>`).
|
||||
- Redeployment stops the existing Instance Actor and all its children, creates a new Instance Actor, and recompiles all scripts and alarms from the updated configuration.
|
||||
- There is no way to hot-reload a single instance script without redeploying the entire instance.
|
||||
|
||||
### Shared Scripts
|
||||
|
||||
- Compiled at the site when received from central via artifact deployment (`deploy artifacts`).
|
||||
- The `SharedScriptLibrary` replaces its in-memory compiled code dictionary under a lock, making updated code immediately available to all Script Actors.
|
||||
- **To update**: modify the shared script via the CLI or UI, then run `deploy artifacts` to push the change to sites.
|
||||
- No instance redeployment is required — running instances pick up the new shared script code on the next `Scripts.CallShared()` invocation.
|
||||
|
||||
### Inbound API Method Scripts
|
||||
|
||||
- See Component-InboundAPI.md for the compilation and hot-reload lifecycle of API method scripts.
|
||||
|
||||
> **Warning**: Editing scripts via direct SQL does not trigger recompilation. The in-memory compiled script will remain stale until the next node restart or redeployment. Always use the CLI, Management API, or Central UI to modify scripts.
|
||||
|
||||
---
|
||||
|
||||
## Script Runtime API
|
||||
|
||||
Available to all Script Execution Actors and Alarm Execution Actors:
|
||||
@@ -232,6 +259,14 @@ Available to all Script Execution Actors and Alarm Execution Actors:
|
||||
- `Database.Connection("connectionName")` — Obtain a raw MS SQL client connection (ADO.NET) for synchronous read/write.
|
||||
- `Database.CachedWrite("connectionName", "sql", parameters)` — Submit a write operation for store-and-forward delivery.
|
||||
|
||||
### Parameter Access
|
||||
- `Parameters["key"]` — Raw dictionary access (returns `object?`, requires manual casting).
|
||||
- `Parameters.Get<T>("key")` — Typed access with descriptive error messages. Throws `ScriptParameterException` if parameter is missing, null, or cannot be converted to `T`.
|
||||
- `Parameters.Get<T?>("key")` — Nullable typed access. Returns `null` if parameter is missing, null, or cannot be converted.
|
||||
- `Parameters.Get<T[]>("key")` — Array access. Converts a list parameter to a typed array. Throws on first unconvertible element.
|
||||
- `Parameters.Get<List<T>>("key")` — List access. Converts a list parameter to a typed `List<T>`.
|
||||
- Supported types: `bool`, `int`, `long`, `float`, `double`, `string`, `DateTime`.
|
||||
|
||||
### Recursion Limit
|
||||
- Every script call (`Instance.CallScript` and `Scripts.CallShared`) increments a call depth counter.
|
||||
- If the counter exceeds the maximum recursion depth (default: 10), the call fails with an error.
|
||||
@@ -280,10 +315,16 @@ Per Akka.NET best practices, internal actor communication uses **Tell** (fire-an
|
||||
- Script Execution Actors may run concurrently, but all state mutations (attribute reads/writes, alarm state updates) are mediated through the parent Instance Actor's message queue.
|
||||
- External side effects (external system calls, notifications, database writes) are not serialized — concurrent scripts may produce interleaved side effects. This is acceptable because each side effect is independent.
|
||||
|
||||
## SiteStreamManager and gRPC Integration
|
||||
|
||||
- The `SiteStreamManager` implements the `ISiteStreamSubscriber` interface, allowing the Communication Layer's `SiteStreamGrpcServer` to subscribe to the stream for cross-cluster delivery via gRPC.
|
||||
- When a gRPC `SubscribeInstance` call arrives, the `SiteStreamGrpcServer` creates a `StreamRelayActor` and subscribes it to `SiteStreamManager` for the requested instance. Events flow from `SiteStreamManager` → `StreamRelayActor` → `Channel<SiteStreamEvent>` → gRPC response stream to central.
|
||||
- The `SiteStreamManager` filters events by instance unique name and forwards matching events to all registered subscribers (both local debug consumers and gRPC relay actors).
|
||||
|
||||
## Site-Wide Stream Backpressure
|
||||
|
||||
- The site-wide Akka stream uses **per-subscriber buffering** with bounded buffers. Each subscriber (debug view, future consumers) gets an independent buffer.
|
||||
- If a subscriber falls behind (e.g., slow network on debug view), its buffer fills and oldest events are dropped. This does not affect other subscribers or the publishing Instance Actors.
|
||||
- The site-wide Akka stream uses **per-subscriber buffering** with bounded buffers. Each subscriber (gRPC relay actors, future consumers) gets an independent buffer.
|
||||
- If a subscriber falls behind (e.g., slow network on gRPC stream), its buffer fills and oldest events are dropped. This does not affect other subscribers or the publishing Instance Actors.
|
||||
- Instance Actors publish to the stream with **fire-and-forget** semantics — publishing never blocks the actor.
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -23,6 +23,7 @@ Central cluster only. Sites receive flattened output and have no awareness of te
|
||||
- Perform comprehensive pre-deployment validation (see Validation section).
|
||||
- Provide on-demand validation for Design users during template authoring.
|
||||
- Enforce template deletion constraints — templates cannot be deleted if any instances or child templates reference them.
|
||||
- Organize templates into nested folders (`TemplateFolder` entity) and validate folder hierarchy invariants (acyclicity, sibling uniqueness, non-empty-on-delete).
|
||||
|
||||
## Key Entities
|
||||
|
||||
@@ -33,6 +34,14 @@ Central cluster only. Sites receive flattened output and have no awareness of te
|
||||
- Defines attributes, alarms, and scripts as first-class members.
|
||||
- Cannot be deleted if referenced by instances or child templates.
|
||||
- Concurrent editing uses **last-write-wins** — no pessimistic locking or conflict detection.
|
||||
- May belong to a `TemplateFolder` via nullable `FolderId`, or live at the tree root when null.
|
||||
|
||||
### TemplateFolder
|
||||
- Hierarchical organizational entity with a self-referencing `ParentFolderId` (null at the root).
|
||||
- Sibling folder names are unique (case-insensitive) within the same parent.
|
||||
- Folders carry **no semantic meaning** for template resolution, flattening, validation, or inheritance — they exist purely for UI organization.
|
||||
- Folder deletion is blocked if the folder contains any subfolders or templates.
|
||||
- The folder graph is enforced acyclic on move (a folder cannot become its own descendant).
|
||||
|
||||
### Attribute
|
||||
- Name, Value, Data Type (Boolean, Integer, Float, String), Lock Flag, Description.
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
# TreeView Component
|
||||
|
||||
## Purpose
|
||||
|
||||
A reusable, generic Blazor Server component that renders hierarchical data as an expandable/collapsible tree. The component is data-agnostic — it accepts any tree-shaped data via type parameters and render fragments, following the same pattern as the existing `DataTable<TItem>` shared component.
|
||||
|
||||
## Location
|
||||
|
||||
`src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
|
||||
|
||||
## Primary Use Case: Instance Hierarchy
|
||||
|
||||
The motivating use case is displaying instances organized by site and area:
|
||||
|
||||
```
|
||||
- Site A
|
||||
+ Area 1
|
||||
- Sub Area 1
|
||||
Instance 1
|
||||
Instance 2
|
||||
+ Area 2
|
||||
+ Site B
|
||||
+ Site C
|
||||
```
|
||||
|
||||
**Hierarchy**: Site → Area → Sub Area (recursive) → Instance (leaf)
|
||||
|
||||
Nodes at each level may be expandable (branches) or plain items (leaves). Leaf nodes have no expand/collapse toggle.
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 — Generic Type Parameter
|
||||
|
||||
The component accepts a single type parameter `TItem` representing any node in the tree. The consumer provides:
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `Items` | `IReadOnlyList<TItem>` | Yes | Root-level items |
|
||||
| `ChildrenSelector` | `Func<TItem, IReadOnlyList<TItem>>` | Yes | Returns children for a given node |
|
||||
| `HasChildrenSelector` | `Func<TItem, bool>` | Yes | Whether the node can be expanded (branch vs. leaf) |
|
||||
| `KeySelector` | `Func<TItem, object>` | Yes | Unique key per node (for state tracking) |
|
||||
|
||||
### R2 — Render Fragments
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `NodeContent` | `RenderFragment<TItem>` | Yes | Renders the label/content for each node |
|
||||
| `EmptyContent` | `RenderFragment?` | No | Shown when `Items` is empty |
|
||||
|
||||
The `NodeContent` fragment receives the `TItem` and is responsible for rendering the node's display (text, icons, badges, action buttons, etc.). The tree component only renders the structural chrome (indentation, expand/collapse toggle, vertical guide lines).
|
||||
|
||||
### R3 — Expand/Collapse Behavior
|
||||
|
||||
- Each branch node displays a toggle indicator: `+` when collapsed, `−` when expanded.
|
||||
- Clicking the **toggle icon** expands/collapses the node. Clicking the **content area** does **not** toggle expansion (it is reserved for selection — see R5).
|
||||
- Leaf nodes (where `HasChildrenSelector` returns `false`) display no toggle — they are indented inline with sibling branch nodes.
|
||||
- Expand/collapse state is tracked internally by the component using `KeySelector` for identity.
|
||||
- All nodes start collapsed by default unless `InitiallyExpanded` is set.
|
||||
- **Session persistence**: When the user navigates away and returns, previously expanded nodes are restored (see R11).
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `InitiallyExpanded` | `Func<TItem, bool>?` | No | Predicate — nodes matching this start expanded (first load only, before any persisted state exists) |
|
||||
|
||||
### R4 — Indentation and Visual Structure
|
||||
|
||||
The component renders the structural chrome: indent gutters per depth, the toggle slot, and ancestor guide lines. Leaf nodes render an empty toggle placeholder so labels align across siblings.
|
||||
|
||||
The exact tokens (indent unit, toggle glyph, guide-line treatment) are specified in **V2** of the Visual Design Guide.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `IndentPx` | `int` | No | Pixels per indent level. Default: 24 |
|
||||
| `ShowGuideLines` | `bool` | No | Show vertical connector lines. Default: true |
|
||||
|
||||
### R5 — Selection
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `Selectable` | `bool` | No | Enable click-to-select. Default: false |
|
||||
| `SelectedKey` | `object?` | No | Currently selected node key (two-way binding) |
|
||||
| `SelectedKeyChanged` | `EventCallback<object?>` | No | Fires when selection changes |
|
||||
| `SelectedCssClass` | `string` | No | CSS class for selected node. Default: `"bg-primary bg-opacity-10"` |
|
||||
|
||||
When `Selectable` is true, clicking a node row selects it (highlighted). Clicking the expand/collapse toggle does **not** change selection — only clicking the content area does.
|
||||
|
||||
### R6 — Lazy Loading (Deferred)
|
||||
|
||||
Future enhancement. For now, all children are provided synchronously via `ChildrenSelector`. A future version may support `Func<TItem, Task<IReadOnlyList<TItem>>>` for on-demand loading with a spinner placeholder.
|
||||
|
||||
### R7 — Keyboard Navigation (Deferred)
|
||||
|
||||
Future enhancement. Arrow keys for navigation, Enter/Space for expand/collapse, Home/End for first/last.
|
||||
|
||||
### R8 — External Filtering
|
||||
|
||||
The tree component itself does **not** implement filter UI. Filtering is driven externally by the consuming page — for example, a site dropdown that filters the tree to show only the selected site's hierarchy.
|
||||
|
||||
**How it works:**
|
||||
- The consumer filters `Items` (and/or adjusts `ChildrenSelector` results) and passes the filtered list to the component.
|
||||
- When `Items` changes (Blazor re-render), the component re-renders the tree with the new data.
|
||||
- **Expansion state is preserved across filter changes.** Nodes that were expanded before filtering remain expanded if they reappear after the filter changes. The component tracks expanded keys independently of the current `Items` — keys are never purged when items disappear, so re-adding a previously expanded node restores its expanded state.
|
||||
- Selection is cleared if the selected node is no longer present after filtering.
|
||||
|
||||
**Example — site filter on the instances page:**
|
||||
```razor
|
||||
<select class="form-select form-select-sm" @bind="_selectedSiteId">
|
||||
<option value="">All Sites</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<TreeView TItem="TreeNode" Items="GetFilteredRoots()" ...>
|
||||
...
|
||||
</TreeView>
|
||||
|
||||
@code {
|
||||
private int? _selectedSiteId;
|
||||
|
||||
private List<TreeNode> GetFilteredRoots()
|
||||
{
|
||||
if (_selectedSiteId == null) return _allRoots;
|
||||
return _allRoots.Where(r => r.SiteId == _selectedSiteId).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This keeps filter logic in the page (domain-specific) while the component handles rendering whatever it receives.
|
||||
|
||||
### R9 — Styling
|
||||
|
||||
- Uses Bootstrap 5 utility classes and CSS variables. No third-party Blazor component frameworks.
|
||||
- Adds one icon-library dependency: **Bootstrap Icons** (static files at `wwwroot/lib/bootstrap-icons/`). Distribution rules in **V4** of the Visual Design Guide.
|
||||
- Hardcoded colors are forbidden; use Bootstrap utility classes (`bg-primary bg-opacity-10`, `text-muted`) or CSS variables (`var(--bs-tertiary-bg)`, `var(--bs-border-color)`).
|
||||
- Component-local CSS lives in `TreeView.razor.css` (Blazor CSS isolation).
|
||||
- All visual tokens (row density, indent, state visuals, glyphs, labels, badges) are specified in the **Visual Design Guide** (V1–V7). This requirement is non-normative summary; the Guide is authoritative.
|
||||
|
||||
### R10 — No Internal Scrolling
|
||||
|
||||
The tree renders inline in the page flow. The consuming page is responsible for placing it in a scrollable container if needed (e.g., `overflow-auto` with `max-height`).
|
||||
|
||||
### R11 — Session-Persistent Expansion State
|
||||
|
||||
When a user expands nodes, navigates away (e.g., clicks an instance link to the configure page), and returns to the page, the tree must restore the same expansion state.
|
||||
|
||||
**Mechanism:**
|
||||
- The component requires a `StorageKey` parameter — a unique string identifying this tree instance (e.g., `"instances-tree"`, `"data-connections-tree"`).
|
||||
- Expanded node keys are stored in browser `sessionStorage` under the key `treeview:{StorageKey}`.
|
||||
- On mount (`OnAfterRenderAsync` first render), the component reads `sessionStorage` and expands any nodes whose keys are present. This takes precedence over `InitiallyExpanded`.
|
||||
- On every expand/collapse toggle, the component writes the updated set of expanded keys to `sessionStorage`.
|
||||
- `sessionStorage` is scoped to the browser tab — each tab has independent state. State is cleared when the tab is closed.
|
||||
|
||||
**Implementation note:** Blazor Server requires `IJSRuntime` to access `sessionStorage`. The component injects `IJSRuntime` and uses a small JS interop helper (inline or in a shared `.js` file) for `getItem`/`setItem`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `StorageKey` | `string?` | No | Key for sessionStorage persistence. If null, expansion state is not persisted (in-memory only). |
|
||||
|
||||
### R12 — Expand All / Collapse All
|
||||
|
||||
The component exposes methods that the consumer can call via `@ref`:
|
||||
|
||||
```csharp
|
||||
/// Expands all branch nodes in the tree (recursive).
|
||||
public void ExpandAll();
|
||||
|
||||
/// Collapses all branch nodes in the tree.
|
||||
public void CollapseAll();
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```razor
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.ExpandAll()">Expand All</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _tree.CollapseAll()">Collapse All</button>
|
||||
|
||||
<TreeView @ref="_tree" TItem="TreeNode" ... />
|
||||
|
||||
@code {
|
||||
private TreeView<TreeNode> _tree = default!;
|
||||
}
|
||||
```
|
||||
|
||||
Both methods update sessionStorage if `StorageKey` is set. `ExpandAll` requires walking the full tree via `ChildrenSelector` to collect all branch node keys.
|
||||
|
||||
### R13 — Programmatic Expand-to-Node
|
||||
|
||||
The component exposes a method to reveal a specific node by expanding all of its ancestors:
|
||||
|
||||
```csharp
|
||||
/// Expands all ancestor nodes so that the node with the given key becomes visible.
|
||||
/// Optionally selects the node and scrolls it into view.
|
||||
public void RevealNode(object key, bool select = false);
|
||||
```
|
||||
|
||||
This requires the component to build a parent lookup (key → parent key) from the tree data. When called:
|
||||
|
||||
1. Walk from the target node's key up to the root, collecting ancestor keys.
|
||||
2. Expand all ancestors.
|
||||
3. If `select` is true, set the node as selected and fire `SelectedKeyChanged`.
|
||||
4. After rendering, scroll the node element into view via JS interop (`element.scrollIntoView({ block: 'nearest' })`).
|
||||
|
||||
**Use case:** Search box on the instances page — user types "Motor-1", results list shows matching instances. Clicking a result calls `_tree.RevealNode(instanceKey, select: true)` to expand the Site → Area path and highlight the instance.
|
||||
|
||||
### R14 — Accessibility (ARIA)
|
||||
|
||||
The component renders semantic ARIA attributes for screen reader support:
|
||||
|
||||
- The root `<ul>` has `role="tree"`.
|
||||
- Each node `<li>` has `role="treeitem"`.
|
||||
- Branch nodes have `aria-expanded="true"` or `aria-expanded="false"`.
|
||||
- Child `<ul>` containers have `role="group"`.
|
||||
- When `Selectable` is true, the selected node has `aria-selected="true"`.
|
||||
- Each node row has a unique `id` derived from `KeySelector` for anchor targeting.
|
||||
|
||||
This is baseline accessibility — no keyboard navigation yet (deferred in R7), but screen readers can understand the tree structure.
|
||||
|
||||
### R15 — Context Menu
|
||||
|
||||
The component supports an optional right-click context menu on nodes, defined by the consumer via a render fragment.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `ContextMenu` | `RenderFragment<TItem>?` | No | Menu content rendered when a node is right-clicked. Receives the right-clicked `TItem`. |
|
||||
|
||||
**Behavior:**
|
||||
- Right-clicking a node renders the `ContextMenu` fragment for that node. The component checks whether the fragment produces any content — **if the fragment renders nothing (empty markup), no menu is shown and the browser default context menu is used.** This is how per-node-type menus work: the consumer uses `@if` blocks in the fragment, and nodes that don't match any condition simply produce no output.
|
||||
- When content is produced, the browser's default context menu is suppressed (`@oncontextmenu:preventDefault`) and a floating menu is shown at the cursor.
|
||||
- The menu is rendered as a Bootstrap dropdown: `<div class="dropdown-menu show">` containing `<button class="dropdown-item">` elements.
|
||||
- Clicking a menu item or clicking anywhere outside the menu dismisses it.
|
||||
- Pressing Escape dismisses the menu.
|
||||
- Only one context menu is visible at a time — right-clicking another node replaces the current menu.
|
||||
- If the `ContextMenu` parameter itself is null (not provided), right-click always uses the browser default for all nodes.
|
||||
|
||||
**The consumer controls which items appear and what they do:**
|
||||
```razor
|
||||
<TreeView TItem="TreeNode" Items="_roots" ... >
|
||||
<NodeContent Context="node">
|
||||
<span>@node.Label</span>
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@if (node.Kind == NodeKind.Instance)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => DeployInstance(node)">
|
||||
Deploy
|
||||
</button>
|
||||
@if (node.State == InstanceState.Enabled)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => DisableInstance(node)">
|
||||
Disable
|
||||
</button>
|
||||
}
|
||||
else if (node.State == InstanceState.Disabled)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => EnableInstance(node)">
|
||||
Enable
|
||||
</button>
|
||||
}
|
||||
<button class="dropdown-item" @onclick="() => NavigateToConfigure(node)">
|
||||
Configure
|
||||
</button>
|
||||
<button class="dropdown-item" @onclick="() => ShowDiff(node)">
|
||||
Diff
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(node)">
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
else if (node.Kind == NodeKind.Site)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => DeployAllInSite(node)">
|
||||
Deploy All
|
||||
</button>
|
||||
}
|
||||
</ContextMenu>
|
||||
</TreeView>
|
||||
```
|
||||
|
||||
This keeps the tree clean — no inline action buttons cluttering leaf nodes. Different node types can show different menu items (instances get full CRUD actions, sites might get bulk operations, areas might have no menu at all).
|
||||
|
||||
**Positioning:**
|
||||
- The menu is absolutely positioned relative to the viewport using the mouse event's `clientX`/`clientY`.
|
||||
- If the menu would overflow the viewport bottom or right edge, it flips direction (opens upward or leftward).
|
||||
- The component handles positioning internally — no JS interop needed (CSS `position: fixed` with `top`/`left` from the mouse event).
|
||||
|
||||
### R16 — Multi-Selection (Deferred)
|
||||
|
||||
Future enhancement. Single selection (R5) covers current needs. A future version may add:
|
||||
|
||||
- `MultiSelect` bool parameter
|
||||
- `SelectedKeys` / `SelectedKeysChanged` for set-based selection
|
||||
- Shift+click for range select, Ctrl+click for toggle
|
||||
- Use case: bulk operations (select multiple instances → deploy/disable all)
|
||||
|
||||
## Visual Design Guide
|
||||
|
||||
This section is the canonical visual specification for the TreeView. It is normative: any change to the chrome (row layout, indentation, glyphs, state visuals, badge styling) must update this section. Consumers' `NodeContent` fragments follow the label and badge recipes in V5–V6; `/design/templates` is the worked example in V7.
|
||||
|
||||
R4 and R9 above describe *that* the component renders structural chrome and uses Bootstrap utilities. This section says *exactly how*.
|
||||
|
||||
### V1 — Density & Row Anatomy
|
||||
|
||||
Each `<li role="treeitem">` renders one row. The row is a flexbox so trailing meta can right-align cleanly and the entire row width is a hover/selected/drop-target surface.
|
||||
|
||||
**Row container** (replaces today's `.tv-row` styling):
|
||||
|
||||
```html
|
||||
<div class="tv-row d-flex align-items-center"
|
||||
style="gap:.25rem; padding:.25rem .5rem; padding-left: calc(.5rem + var(--tv-indent, 0px));">
|
||||
<span class="tv-toggle">…chevron or placeholder…</span>
|
||||
<span class="tv-glyph">…Bootstrap Icon or placeholder…</span>
|
||||
<span class="tv-label">…primary + secondary…</span>
|
||||
<span class="tv-meta ms-auto">…badges…</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
| Token | Value | Notes |
|
||||
|---|---|---|
|
||||
| Row vertical padding | `py-1` (0.25rem top/bottom) | Yields ~32px row height at base font-size + line-height 1.5. |
|
||||
| Row horizontal padding | `px-2` (0.5rem left/right) | Selected/hover background spans full row including this padding. |
|
||||
| Inter-slot gap | `gap: .25rem` | Between toggle, glyph, label. The meta slot is offset by `margin-left: auto`. |
|
||||
| Font size | inherits (1rem base) | Compact pages may opt into `small` per-page, not at the component level. |
|
||||
| Line height | inherits (1.5) | Aligns the chevron, glyph, and label baselines correctly. |
|
||||
| Toggle slot width | 20px (`width: 1.25rem`) | Always present, even on leaves (which render an empty placeholder). |
|
||||
| Glyph slot width | 20px (`width: 1.25rem`) | Always present; consumer may render an empty span to preserve alignment. |
|
||||
| Label slot | `flex: 1 1 auto; min-width: 0;` | `min-width: 0` is required for ellipsis truncation to work in a flex child. |
|
||||
| Meta slot | `margin-left: auto;` | Pushes badges to the right edge of the row. |
|
||||
|
||||
**Hit semantics**:
|
||||
- The full row (`tv-row`) is the surface for hover, selected, focus-visible, and drop-target backgrounds.
|
||||
- Click-to-select fires only on the **label slot** (preserves R5: toggle clicks do not select).
|
||||
- The toggle slot's invisible tap target is enlarged by negative margins inside the 20px slot so it remains a comfortable 24×24px target.
|
||||
|
||||
### V2 — Depth, Indent & Guide Lines
|
||||
|
||||
| Token | Value |
|
||||
|---|---|
|
||||
| Indent per depth | 24px (`IndentPx` default, unchanged) |
|
||||
| Toggle glyph (collapsed) | `<i class="bi bi-chevron-right">` |
|
||||
| Toggle glyph (expanded) | `<i class="bi bi-chevron-down">` (or `bi-chevron-right` rotated 90° via CSS) |
|
||||
| Guide line color | `var(--bs-border-color)` |
|
||||
| Guide line width | 1px |
|
||||
| Guide line style | solid, vertical-only (no horizontal stubs) |
|
||||
| Guide line position | one line per ancestor depth, drawn down the indent column (left edge of each 24px indent slot) |
|
||||
| Guide lines enabled | `ShowGuideLines` parameter (default true) |
|
||||
| Leaf alignment | identical depth gutter as siblings; the toggle slot renders an empty placeholder so glyphs and labels align across leaves and branches |
|
||||
|
||||
Implementation note: guide lines are drawn by repeating a `linear-gradient` background or by stacking `border-left` on indent spacers — both are pure CSS, no extra DOM. The current `tv-guides` class is the hook.
|
||||
|
||||
### V3 — State Visuals
|
||||
|
||||
States compose: focus rings layer on top of hover/selected; drop-target overrides hover and selected. All states paint the full row width (V1).
|
||||
|
||||
| State | Visual | Implementation |
|
||||
|---|---|---|
|
||||
| Default | none | — |
|
||||
| Hover | full-row tint | `background: var(--bs-tertiary-bg);` on `:hover` of `.tv-row` |
|
||||
| Focus-visible | inset 2px primary ring | `box-shadow: inset 0 0 0 2px var(--bs-primary);` on `:focus-visible` |
|
||||
| Selected | full-row primary tint | `class="bg-primary bg-opacity-10"` (existing `SelectedCssClass` default, unchanged) |
|
||||
| Selected + hover | selected tint persists; hover does not deepen | hover background applies only when not selected (`:hover:not(.bg-primary)`) |
|
||||
| Selected + focus | tint + ring both visible | focus ring layers via box-shadow |
|
||||
| Drop-target (valid) | `bg-info bg-opacity-25` | overrides hover/selected backgrounds; opt-in per consumer |
|
||||
| Drop-target (invalid) | cursor `not-allowed`, no tint change | absence of valid-tint is the cue |
|
||||
| Dragging source | `opacity: 0.5` | applied to the row currently being dragged |
|
||||
| Dimmed (non-droppable while a drag is in progress) | `opacity: 0.5` | applied to nodes the consumer marks as unsuitable drop targets |
|
||||
|
||||
Drag-drop is **not** part of the TreeView component's intrinsic behavior — it is opt-in per consuming page. The drag-related state visuals (drop-target, dragging, dimmed) are documented here so consumers that *do* implement DnD share the same visual language. The `/design/templates` page (V7) explicitly does **not** use drag-drop; reorganization happens via the right-click context menu.
|
||||
|
||||
### V4 — Glyph & Icon System
|
||||
|
||||
**Distribution**: Bootstrap Icons ships as static files under `src/ScadaLink.CentralUI/wwwroot/lib/bootstrap-icons/` (`bootstrap-icons.css` + `fonts/*.woff2`). Referenced once from `MainLayout.razor`:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="~/lib/bootstrap-icons/bootstrap-icons.css" />
|
||||
```
|
||||
|
||||
No CDN dependency — works on air-gapped industrial deployments. Version pinned in the file path or filename.
|
||||
|
||||
**Rules**:
|
||||
- Glyphs are inline `<i class="bi bi-…"></i>` elements inside the 20px glyph slot.
|
||||
- Branches render an **open/closed pair**: a `closed` glyph when collapsed, an `open` glyph when expanded (consumer chooses both via `NodeContent`). The chevron toggle reinforces the same state.
|
||||
- Leaves render a single static glyph or no glyph (empty span preserves alignment).
|
||||
- **Color**: glyphs inherit `color` from their row. Default is body text; consumers may apply `text-muted` for de-emphasis. Kind is communicated by *shape*, not by color, to keep the palette available for status badges.
|
||||
- **Size**: glyphs render at `1em` (inherits row font-size). No fixed pixel size.
|
||||
|
||||
### V5 — Label Recipe & Typography
|
||||
|
||||
The label slot contains, in order: **[primary] [secondary modifiers]**. Trailing meta lives in the separate `.tv-meta` slot (V1).
|
||||
|
||||
| Element | Style |
|
||||
|---|---|
|
||||
| Primary label (branches) | `class="fw-semibold"` |
|
||||
| Primary label (leaves) | normal weight |
|
||||
| Secondary modifiers | `class="text-muted small ms-1"` |
|
||||
| Overflow handling | `.tv-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }` |
|
||||
| Tooltip | `title` attribute on the primary label span, set to the full name on every row (cheap, helps when the row is narrower than the name) |
|
||||
|
||||
**Rule of thumb**: font-weight tracks *has children*, not *kind*. A folder with no children renders regular weight; a leaf-template promoted to a branch by adding compositions becomes semibold automatically.
|
||||
|
||||
### V6 — Badge Taxonomy
|
||||
|
||||
Three semantic badge roles. The meta slot holds **at most two** badges per row. All badges live in `.tv-meta`, right-aligned (V1).
|
||||
|
||||
| Role | Purpose | Markup | Examples |
|
||||
|---|---|---|---|
|
||||
| Count | numeric child aggregation | `<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@N</span>` | folder child count; area instance count |
|
||||
| Status | semantic state | `<span class="badge bg-{success\|warning\|danger\|info}">@Label</span>` | Enabled / Disabled / Stale / Error |
|
||||
| Kind | category / type tag | same filled semantic style, used sparingly | Protocol (OPC UA), Source (Inherited) |
|
||||
|
||||
**Rules**:
|
||||
- Counts represent **direct children only**. Never transitive descendants.
|
||||
- A count of 0 **renders nothing** — no badge at all.
|
||||
- Status uses Bootstrap semantic colors; do not introduce custom palettes.
|
||||
- The component does not enforce the 2-badge cap; it is a documented convention. PR review should catch violations.
|
||||
|
||||
### V7 — Worked Example: `/design/templates`
|
||||
|
||||
**Page model**: the templates page is a **tree browser only**. Selecting a template in the tree navigates to a dedicated edit page (`/design/templates/{id}`); creating a template navigates to `/design/templates/create`. No split-pane editor. Reorganization (move folder, move template) happens exclusively through the **right-click context menu** with modal dialog pickers — there is no drag-and-drop on this page.
|
||||
|
||||
Three node kinds; concrete recipes following V1–V6.
|
||||
|
||||
| Kind | Glyph (collapsed) | Glyph (expanded) | Primary | Secondary | Badges |
|
||||
|---|---|---|---|---|---|
|
||||
| Folder | `bi-folder` | `bi-folder2-open` | folder name (semibold when has children, regular otherwise) | — | count of direct children (subtle pill), only if ≥ 1 |
|
||||
| Template | `bi-file-earmark-text` | same (templates with compositions still use the same glyph — chevron carries state) | `$Name` (semibold when has compositions, regular otherwise) | — | none |
|
||||
| Composition | `bi-arrow-return-right` | n/a (leaf, no expanded state) | composition instance name (regular weight) | — | none |
|
||||
|
||||
**`NodeContent` fragment** for the templates page (replaces the current `RenderNodeLabel` in `Templates.razor`):
|
||||
|
||||
```razor
|
||||
@switch (node.Kind)
|
||||
{
|
||||
case TmplNodeKind.Folder:
|
||||
var folderOpen = _tree.IsExpanded(node.Key);
|
||||
<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" : "")"
|
||||
title="@node.Label">@node.Label</span>
|
||||
@if (node.Children.Count > 0)
|
||||
{
|
||||
<span class="tv-meta ms-auto">
|
||||
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
|
||||
</span>
|
||||
}
|
||||
break;
|
||||
|
||||
case TmplNodeKind.Template:
|
||||
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
|
||||
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
|
||||
title="@node.Label">@node.Label</span>
|
||||
break;
|
||||
|
||||
case TmplNodeKind.Composition:
|
||||
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
|
||||
<span class="tv-label" title="@node.Label">@node.Label</span>
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
**Locked subtractions from the previous design**:
|
||||
- Template node "inherits $Parent" muted text — **removed**. Inheritance is shown in the right pane only.
|
||||
- Template node "X attr, Y alm, Z scr" compound badge — **removed**.
|
||||
- Template node "N comp" accent badge — **removed**.
|
||||
|
||||
These subtractions are deliberate: templates are leaves-from-the-tree's-perspective (their inner attributes/alarms/scripts are not tree-navigable), so the tree row should carry only what's needed to identify and pick the template. All counts and inheritance information live in the right detail pane.
|
||||
|
||||
|
||||
|
||||
```csharp
|
||||
@typeparam TItem
|
||||
|
||||
// Data
|
||||
[Parameter] public IReadOnlyList<TItem> Items { get; set; }
|
||||
[Parameter] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; }
|
||||
[Parameter] public Func<TItem, bool> HasChildrenSelector { get; set; }
|
||||
[Parameter] public Func<TItem, object> KeySelector { get; set; }
|
||||
|
||||
// Rendering
|
||||
[Parameter] public RenderFragment<TItem> NodeContent { get; set; }
|
||||
[Parameter] public RenderFragment? EmptyContent { get; set; }
|
||||
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
|
||||
|
||||
// Layout
|
||||
[Parameter] public int IndentPx { get; set; } = 24;
|
||||
[Parameter] public bool ShowGuideLines { get; set; } = true;
|
||||
|
||||
// Expand/Collapse
|
||||
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
|
||||
[Parameter] public string? StorageKey { get; set; } // sessionStorage persistence key
|
||||
|
||||
// Selection
|
||||
[Parameter] public bool Selectable { get; set; }
|
||||
[Parameter] public object? SelectedKey { get; set; }
|
||||
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
|
||||
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
||||
|
||||
// Public methods (called via @ref)
|
||||
public void ExpandAll();
|
||||
public void CollapseAll();
|
||||
public void RevealNode(object key, bool select = false);
|
||||
```
|
||||
|
||||
## Usage Example: Instance Hierarchy
|
||||
|
||||
```razor
|
||||
@* Build a unified tree model from sites, areas, and instances *@
|
||||
|
||||
<TreeView TItem="TreeNode" Items="_roots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key"
|
||||
Selectable="true"
|
||||
SelectedKey="_selectedKey"
|
||||
SelectedKeyChanged="key => { _selectedKey = key; StateHasChanged(); }">
|
||||
<NodeContent Context="node">
|
||||
@switch (node.Kind)
|
||||
{
|
||||
case NodeKind.Site:
|
||||
<span class="fw-semibold">@node.Label</span>
|
||||
break;
|
||||
case NodeKind.Area:
|
||||
<span class="text-secondary">@node.Label</span>
|
||||
break;
|
||||
case NodeKind.Instance:
|
||||
<span>@node.Label</span>
|
||||
<span class="badge bg-success ms-2">Enabled</span>
|
||||
break;
|
||||
}
|
||||
</NodeContent>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No items to display.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
|
||||
@code {
|
||||
private object? _selectedKey;
|
||||
private List<TreeNode> _roots = new();
|
||||
|
||||
record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children);
|
||||
enum NodeKind { Site, Area, Instance }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Example: Data Connections by Site
|
||||
|
||||
A simpler two-level tree — Site → Data Connections (leaves):
|
||||
|
||||
```
|
||||
- Site A
|
||||
Data Connection 1
|
||||
Data Connection 2
|
||||
+ Site B
|
||||
+ Site C
|
||||
```
|
||||
|
||||
```razor
|
||||
<TreeView TItem="TreeNode" Items="_roots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => n.Key">
|
||||
<NodeContent Context="node">
|
||||
@if (node.Kind == NodeKind.Site)
|
||||
{
|
||||
<span class="fw-semibold">@node.Label</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@node.Label</span>
|
||||
<span class="badge bg-info ms-2">@node.Protocol</span>
|
||||
}
|
||||
</NodeContent>
|
||||
</TreeView>
|
||||
|
||||
@code {
|
||||
private List<TreeNode> _roots = new();
|
||||
|
||||
record TreeNode(string Key, string Label, NodeKind Kind, List<TreeNode> Children, string? Protocol = null);
|
||||
enum NodeKind { Site, DataConnection }
|
||||
|
||||
// Build: group data connections by SiteId, wrap each site as a branch
|
||||
// with its connections as leaf children
|
||||
}
|
||||
```
|
||||
|
||||
This demonstrates the component working with a flat two-level grouping — no recursive hierarchy needed. The consumer simply groups data connections by site and builds one level of children per site node.
|
||||
|
||||
## Tree Model Construction Pattern
|
||||
|
||||
The consuming page is responsible for building the tree model. The component only knows about `TItem`.
|
||||
|
||||
**Instance hierarchy** (deep, recursive):
|
||||
1. Load sites, areas (with `ParentAreaId` hierarchy), and instances.
|
||||
2. Build `Area` subtree per site using recursive `ParentAreaId` traversal.
|
||||
3. Attach instances as leaf children of their assigned area (or directly under the site if `AreaId` is null).
|
||||
4. Wrap each entity in a uniform `TreeNode`.
|
||||
|
||||
**Data connections by site** (flat, two-level):
|
||||
1. Load sites and data connections.
|
||||
2. Group connections by `SiteId`.
|
||||
3. Each site becomes a branch node with its connections as leaf children.
|
||||
|
||||
## Other Potential Uses
|
||||
|
||||
The component is generic enough for:
|
||||
|
||||
- **Template inheritance tree**: Template → child templates (via `ParentTemplateId`)
|
||||
- **Area management**: Site → Area hierarchy (replace current flat indentation in Areas.razor)
|
||||
- **Data connections**: Site → connections (flat grouping, as shown above)
|
||||
- **Navigation sidebar**: Hierarchical menu structure
|
||||
- **File/folder browser**: Any nested structure
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests use the existing bUnit + xUnit + NSubstitute setup in `tests/ScadaLink.CentralUI.Tests/`. Tests live in a dedicated file: `TreeViewTests.cs`.
|
||||
|
||||
All tests use a simple test model:
|
||||
|
||||
```csharp
|
||||
record TestNode(string Key, string Label, List<TestNode> Children);
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
**Rendering:**
|
||||
- Renders root-level items with correct labels
|
||||
- Renders `EmptyContent` when `Items` is empty
|
||||
- Does not render `EmptyContent` when items exist
|
||||
- Leaf nodes have no expand/collapse toggle
|
||||
- Branch nodes show `+` toggle when collapsed
|
||||
|
||||
**Expand/Collapse:**
|
||||
- Clicking toggle expands node and shows children
|
||||
- Clicking expanded toggle collapses node and hides children
|
||||
- Children of collapsed nodes are not in the DOM
|
||||
- Deep nesting: expand parent, then expand child — grandchildren visible
|
||||
- `InitiallyExpanded` predicate expands matching nodes on first render
|
||||
|
||||
**Indentation:**
|
||||
- Root nodes have zero indentation
|
||||
- Child nodes are indented by `IndentPx` pixels per depth level
|
||||
- Custom `IndentPx` value is applied correctly
|
||||
|
||||
**Selection:**
|
||||
- When `Selectable` is false (default), clicking a node does not fire `SelectedKeyChanged`
|
||||
- When `Selectable` is true, clicking node content fires `SelectedKeyChanged` with correct key
|
||||
- Clicking expand toggle does **not** change selection
|
||||
- Selected node has `SelectedCssClass` applied
|
||||
- Custom `SelectedCssClass` is used when provided
|
||||
|
||||
**External Filtering:**
|
||||
- Re-rendering with a filtered `Items` list removes hidden root nodes
|
||||
- Expansion state is preserved after filter changes — expanding Site A, filtering to Site A only, then removing filter still shows Site A expanded
|
||||
- Selection is cleared when the selected node disappears from filtered results
|
||||
|
||||
**Session Persistence (R11):**
|
||||
- When `StorageKey` is null, no JS interop calls are made
|
||||
- When `StorageKey` is set, expanding a node writes to sessionStorage via JS interop
|
||||
- On mount with a `StorageKey`, reads sessionStorage and restores expanded nodes
|
||||
- Persisted state takes precedence over `InitiallyExpanded`
|
||||
|
||||
*Note: sessionStorage tests mock `IJSRuntime` (already available via bUnit's `JSInterop`).*
|
||||
|
||||
**Expand All / Collapse All (R12):**
|
||||
- `ExpandAll()` expands all branch nodes — all descendants visible
|
||||
- `CollapseAll()` collapses all branch nodes — only roots visible
|
||||
- `ExpandAll()` updates sessionStorage when `StorageKey` is set
|
||||
- `CollapseAll()` clears sessionStorage expanded set when `StorageKey` is set
|
||||
|
||||
**RevealNode (R13):**
|
||||
- `RevealNode(key)` expands all ancestors of the target node
|
||||
- Target node's content is present in the DOM after reveal
|
||||
- `RevealNode(key, select: true)` selects the node and fires `SelectedKeyChanged`
|
||||
- `RevealNode` with unknown key is a no-op (does not throw)
|
||||
- Deeply nested node (3+ levels) — all intermediate ancestors expanded
|
||||
|
||||
**Accessibility (R14):**
|
||||
- Root `<ul>` has `role="tree"`
|
||||
- Node `<li>` elements have `role="treeitem"`
|
||||
- Expanded branch has `aria-expanded="true"`
|
||||
- Collapsed branch has `aria-expanded="false"`
|
||||
- Child container `<ul>` has `role="group"`
|
||||
- Selected node has `aria-selected="true"` when `Selectable` is true
|
||||
|
||||
**Context Menu (R15):**
|
||||
- Right-clicking a node shows the context menu with consumer-defined content
|
||||
- Context menu is positioned at cursor coordinates
|
||||
- When `ContextMenu` parameter is null, right-click does not render a menu
|
||||
- When `ContextMenu` fragment renders empty content for a node type, no menu appears and browser default is used
|
||||
- Right-clicking a node type with menu items shows the menu; right-clicking a node type without menu items does not
|
||||
- Clicking a menu item dismisses the menu
|
||||
- Clicking outside the menu dismisses it
|
||||
- Right-clicking a different node replaces the current menu
|
||||
|
||||
### Test File Location
|
||||
|
||||
`tests/ScadaLink.CentralUI.Tests/TreeViewTests.cs`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Bootstrap 5 (already included in CentralUI)
|
||||
- No additional packages
|
||||
- bUnit 2.0.33-preview (already in test project)
|
||||
|
||||
## Page Integration Notes
|
||||
|
||||
### 1. Topology Page (`/deployment/topology` — Topology.razor)
|
||||
|
||||
The Topology page is the single home for Site → Area → Instance hierarchy management. It replaces the former `/deployment/instances` page (the legacy URL is retained as a secondary `@page` directive on `Topology.razor` so existing bookmarks resolve) and the former `/admin/areas*` admin pages.
|
||||
|
||||
**Scope:**
|
||||
- Structural management of areas (create, rename inline, move, delete) and instance placement (move to area).
|
||||
- Instance lifecycle: Deploy/Redeploy, Enable/Disable, Configure, Diff, Delete via per-node context menu.
|
||||
- Search-only filter row (single text input) — dims non-matching rows, preserves tree shape, no collapse.
|
||||
|
||||
**TreeView wiring:**
|
||||
- `Items` = list of Site root nodes built from `_sites`, `_allAreas`, and `_allInstances`.
|
||||
- `KeySelector` returns prefixed keys (`s:{id}`, `a:{id}`, `i:{id}`).
|
||||
- `StorageKey` = `"topology-tree"` for expansion state.
|
||||
- A separate `topology-tree-selected` sessionStorage key persists the selected node across navigation.
|
||||
- `Selectable` = true; selection does not navigate (instance configure goes through the context menu).
|
||||
- Empty containers always rendered (so they can be drop/move targets).
|
||||
|
||||
**Glyphs (V1–V7 visual guide):**
|
||||
- Site: `bi-building`
|
||||
- Area: `bi-diagram-3`
|
||||
- Instance: `bi-box` + state badge + Stale/Current badge when deployed.
|
||||
|
||||
**Context menus:**
|
||||
- **Site:** Add Area, Create Instance here.
|
||||
- **Area:** Add Sub-area, Create Instance here, Move to Area…, Rename… (also F2 / double-click inline), Delete.
|
||||
- **Instance:** Deploy/Redeploy, Enable/Disable (state-dependent), Configure, Diff, Move to Area…, Delete. Instance rename is intentionally absent (see "Instance rename" below).
|
||||
|
||||
**Inline rename:** Area rows only. F2 or double-click swaps the label for an input bound to a local buffer. Enter commits via `AreaService.UpdateAreaAsync`; Escape cancels; server validation errors stay surfaced inline.
|
||||
|
||||
**Search behavior:** Single text input above the tree. While text is present, any row whose label does not match (case-insensitive substring) and whose subtree contains no match is rendered at `opacity: 0.4`. The tree shape stays intact.
|
||||
|
||||
**Top-of-page buttons:** `+ Area` (opens `CreateAreaDialog` with site picker), `+ Instance` (navigates to `/deployment/instances/create` with no preselection), `Refresh`, `Expand`, `Collapse`.
|
||||
|
||||
**Files added:**
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Topology.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveAreaDialog.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/MoveInstanceDialog.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/CreateAreaDialog.razor`
|
||||
|
||||
**Files removed:**
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor`
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor` (and AreaAdd / AreaEdit / AreaDelete)
|
||||
|
||||
**Backend addition:** `AreaService.MoveAreaAsync(int areaId, int? newParentAreaId, string user)` adds area re-parenting (cycle prevention, same-site, name collision at new parent). Pairs with the existing `InstanceService.AssignToAreaAsync`.
|
||||
|
||||
**Instance rename:** Out of scope for this page. `InstanceService` does not currently support renaming an instance (`UniqueName` is also the site-side `InstanceActor` identity and appears in deployment records). A separate design pass is required if rename is wanted.
|
||||
|
||||
---
|
||||
|
||||
### 2. Data Connections Page (`/admin/data-connections` — DataConnections.razor)
|
||||
|
||||
**Current state:** Flat table listing all data connections across all sites. Columns: ID, Name, Protocol, Site, Primary Config, Backup Config, Actions (Edit, Delete). No filters. ~119 lines.
|
||||
|
||||
**Change to:**
|
||||
- Replace the `<table>` with a `<TreeView>` showing Site → Data Connection hierarchy (two levels, no recursion).
|
||||
- **No filter bar needed initially** — the tree naturally groups by site. If the number of sites grows, a site filter dropdown can be added later using the external filtering pattern.
|
||||
- **Move Edit and Delete into the `ContextMenu` fragment**, shown only for data connection nodes:
|
||||
- Edit → navigates to `/admin/data-connections/{id}/edit`
|
||||
- Delete → shows confirm dialog, then deletes
|
||||
- Site nodes get no context menu.
|
||||
- **Node content per type:**
|
||||
- Site nodes: `<span class="fw-semibold">SiteName</span>` + child count badge (e.g., `<span class="badge bg-secondary ms-1">3</span>`)
|
||||
- Data Connection nodes: `<span>Name</span>` + protocol badge (e.g., `<span class="badge bg-info ms-2">OPC UA</span>`)
|
||||
- **Tree model:** Group data connections by `SiteId`. Each site becomes a branch, its connections become leaves. Sites with no connections still appear as empty branches (expandable but no children).
|
||||
- **StorageKey:** `"data-connections-tree"`
|
||||
|
||||
**Files to modify:**
|
||||
- `src/ScadaLink.CentralUI/Components/Pages/Admin/DataConnections.razor` — replace table with TreeView, add tree model building, move actions to context menu.
|
||||
|
||||
**Removed code:**
|
||||
- `<table>` / `<thead>` / `<tbody>` structure
|
||||
- Inline Edit/Delete buttons
|
||||
|
||||
---
|
||||
|
||||
## Interactions
|
||||
|
||||
- **DataTable**: The tree replaces flat tables on the Topology and Data Connections pages. Other pages that don't need hierarchy continue using DataTable.
|
||||
- **InstanceConfigure.razor**: Right-click → Configure on an instance node navigates to `/deployment/instances/{Id}/configure`. Back-nav returns to `/deployment/topology`.
|
||||
@@ -45,11 +45,13 @@
|
||||
- **Machine Data Database**: A separate database for collected machine data (e.g., telemetry, measurements, events).
|
||||
|
||||
### 2.2 Communication: Central ↔ Site
|
||||
- Central-to-site and site-to-central communication uses **Akka.NET ClusterClient/ClusterClientReceptionist** for cross-cluster messaging with automatic failover.
|
||||
- **Site addressing**: Site Akka base addresses (NodeA and NodeB) are stored in the **Sites database table** and configured via the Central UI. Central creates a ClusterClient per site using both addresses as contact points (cached in memory, refreshed periodically and on admin changes) rather than relying on runtime registration messages from sites.
|
||||
- Two transport layers are used for central-site communication:
|
||||
- **Akka.NET ClusterClient/ClusterClientReceptionist**: Handles **command/control** messaging — deployments, instance lifecycle commands, subscribe/unsubscribe handshake, debug snapshots, health reports, remote queries, and integration routing. Provides automatic failover between contact points.
|
||||
- **gRPC server-streaming (site→central)**: Handles **real-time data streaming** — attribute value updates and alarm state changes. Each site node hosts a **SiteStreamGrpcServer** on a dedicated HTTP/2 port (Kestrel, default port 8083). Central creates per-site **SiteStreamGrpcClient** instances to subscribe to site streams. gRPC provides HTTP/2 flow control and per-stream backpressure that ClusterClient lacks.
|
||||
- **Site addressing**: Site Akka base addresses (NodeA and NodeB) and gRPC endpoints (GrpcNodeAAddress and GrpcNodeBAddress) are stored in the **Sites database table** and configured via the Central UI or CLI. Central creates a ClusterClient per site using both Akka addresses as contact points, and per-site gRPC clients using the gRPC addresses.
|
||||
- **Central contact points**: Sites configure **multiple central contact points** (both central node addresses) for redundancy. ClusterClient handles failover between central nodes automatically.
|
||||
- **Central as integration hub**: Central brokers requests between external systems and sites. For example, a recipe manager sends a recipe to central, which routes it to the appropriate site. MES requests machine values from central, which routes the request to the site and returns the response.
|
||||
- **Real-time data streaming** is not continuous for all machine data. The only real-time stream is an **on-demand debug view** — an engineer in the central UI can open a live view of a specific instance's tag values and alarm states for troubleshooting purposes. This is session-based and temporary. The debug view subscribes to the site-wide Akka stream filtered by instance (see Section 8.1).
|
||||
- **Real-time data streaming** is not continuous for all machine data. The only real-time stream is an **on-demand debug view** — an engineer in the central UI can open a live view of a specific instance's tag values and alarm states for troubleshooting purposes. This is session-based and temporary. The debug view subscribes via gRPC to the site's SiteStreamManager filtered by instance (see Section 8.1).
|
||||
|
||||
### 2.3 Site-Level Storage & Interface
|
||||
- Sites have **no user interface** — they are headless collectors, forwarders, and script executors.
|
||||
@@ -58,11 +60,12 @@
|
||||
- Store-and-forward buffers are persisted to a **local SQLite database on each node** and replicated between nodes via application-level replication (see 1.3).
|
||||
|
||||
### 2.4 Data Connection Protocols
|
||||
- The system supports **OPC UA** and **LmxProxy** (a gRPC-based custom protocol with an existing client SDK).
|
||||
- Both protocols implement a **common interface** supporting: connect, subscribe to tag paths, receive value updates, and write values.
|
||||
- The system supports **OPC UA** as the primary data connection protocol.
|
||||
- All protocols implement a **common interface** supporting: connect, subscribe to tag paths, receive value updates, and write values.
|
||||
- Additional protocols can be added by implementing the common interface.
|
||||
- The Data Connection Layer is a **clean data pipe** — it publishes tag value updates to Instance Actors but performs no evaluation of triggers or alarm conditions.
|
||||
- **Initial attribute quality**: Attributes bound to a data connection start with **uncertain** quality when the Instance Actor initializes. The quality remains uncertain until the first value update is received from the Data Connection Layer. This distinguishes "never received a value" from "received a known-good value" or "connection lost" (bad quality).
|
||||
- Data connections support optional **backup endpoints** with automatic failover after a configurable retry count. On failover, all subscriptions are transparently re-created on the new endpoint.
|
||||
|
||||
### 2.5 Scale
|
||||
- Approximately **10 sites**.
|
||||
@@ -103,16 +106,18 @@ Each alarm has:
|
||||
- **Priority Level**: Numeric value from 0–1000.
|
||||
- **Lock Flag**: Controls whether the alarm can be overridden downstream.
|
||||
- **Trigger Definition**: One of the following trigger types:
|
||||
- **Value Match**: Triggers when a monitored attribute equals a predefined value.
|
||||
- **Value Match**: Triggers when a monitored attribute equals a predefined value. Supports a `!=X` prefix on the match value for not-equals semantics.
|
||||
- **Range Violation**: Triggers when a monitored attribute value falls outside an allowed range.
|
||||
- **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold.
|
||||
- **Rate of Change**: Triggers when a monitored attribute value changes faster than a defined threshold over a configurable time window. A direction filter (rising / falling / either) restricts which side of the rate triggers.
|
||||
- **HiLo**: Multi-setpoint level alarm. Any subset of four setpoints (LoLo, Lo, Hi, HiHi) may be configured. The most severe matching band wins (LoLo/HiHi outrank Lo/Hi). Each setpoint may carry its own priority that overrides the alarm-level priority for that band.
|
||||
- **On-Trigger Script** *(optional)*: A script to execute when the alarm triggers. The alarm on-trigger script executes in the context of the instance and can call instance scripts, but instance scripts **cannot** call alarm on-trigger scripts. The call direction is one-way.
|
||||
|
||||
### 3.4.1 Alarm State
|
||||
- Alarm state (active/normal) is **managed at the site level** per instance, held **in memory** by the Alarm Actor.
|
||||
- Active alarms additionally carry an **alarm level**: `None` for binary trigger types (ValueMatch, RangeViolation, RateOfChange); one of `Low`, `LowLow`, `High`, `HighHigh` for HiLo triggers based on which setpoint the monitored attribute has crossed. Level transitions within an active HiLo alarm (e.g., High → HighHigh) emit fresh state-change events without re-running the on-trigger script — the script only fires on the Normal → non-None edge.
|
||||
- When the alarm condition clears, the alarm **automatically returns to normal state** — no acknowledgment workflow is required.
|
||||
- Alarm state is **not persisted** — on restart, alarm states are re-evaluated from incoming values.
|
||||
- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), priority, timestamp.
|
||||
- Alarm state changes are published to the site-wide Akka stream as `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), alarm level, priority, timestamp.
|
||||
|
||||
### 3.5 Template Relationships
|
||||
|
||||
@@ -362,7 +367,7 @@ The central cluster hosts a **configuration and management UI** (no live machine
|
||||
- **Database Connection Management**: Define named database connections for script use.
|
||||
- **Inbound API Management**: Manage API keys (create, enable/disable, delete). Define API methods (name, parameters, return values, approved keys, implementation script). *(Admin role for keys, Design role for methods.)*
|
||||
- **Instance Management**: Create instances from templates, bind data connections (per-attribute, with **bulk assignment** UI for selecting multiple attributes and assigning a data connection at once), set instance-level attribute overrides, assign instances to areas. **Disable** or **delete** instances.
|
||||
- **Site & Data Connection Management**: Define sites (including optional NodeAAddress and NodeBAddress fields for Akka remoting paths), manage data connections and assign them to sites.
|
||||
- **Site & Data Connection Management**: Define sites (including optional NodeAAddress and NodeBAddress fields for Akka remoting paths, and optional GrpcNodeAAddress and GrpcNodeBAddress fields for gRPC streaming endpoints), manage data connections and assign them to sites.
|
||||
- **Area Management**: Define hierarchical area structures per site for organizing instances.
|
||||
- **Deployment**: View diffs between deployed and current template-derived configurations, deploy updates to individual instances. Filter instances by area. Pre-deployment validation runs automatically before any deployment is sent.
|
||||
- **System-Wide Artifact Deployment**: Explicitly deploy shared scripts, external system definitions, database connection definitions, data connection definitions, notification lists, and SMTP configuration to all sites or to an individual site (requires Deployment role). Per-site deployment is available via the Sites admin page.
|
||||
@@ -373,7 +378,7 @@ The central cluster hosts a **configuration and management UI** (no live machine
|
||||
- **Site Event Log Viewer**: Query and view operational event logs from site clusters (see Section 12).
|
||||
|
||||
### 8.1 Debug View
|
||||
- **Subscribe-on-demand**: When an engineer opens a debug view for an instance, central subscribes to the **site-wide Akka stream** filtered by instance unique name. The site first provides a **snapshot** of all current attribute values and alarm states from the Instance Actor, then streams subsequent changes from the Akka stream.
|
||||
- **Subscribe-on-demand**: When an engineer opens a debug view for an instance, central opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the instance, then requests a **snapshot** of all current attribute values and alarm states via ClusterClient. The gRPC stream delivers subsequent attribute value and alarm state changes directly from the site's `SiteStreamManager`.
|
||||
- Attribute value stream messages are structured as: `[InstanceUniqueName].[AttributePath].[AttributeName]`, attribute value, attribute quality, attribute change timestamp.
|
||||
- Alarm state stream messages are structured as: `[InstanceUniqueName].[AlarmName]`, alarm state (active/normal), priority, timestamp.
|
||||
- The stream continues until the engineer **closes the debug view**, at which point central unsubscribes and the site stops streaming.
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
# LmxProxy Protocol Specification
|
||||
|
||||
The LmxProxy protocol is a gRPC-based SCADA read/write interface for bridging ScadaLink's Data Connection Layer to devices via an intermediary proxy server (LmxProxy). The proxy translates LmxProxy protocol operations into backend device calls (e.g., OPC UA). All communication uses HTTP/2 gRPC with Protocol Buffers.
|
||||
|
||||
## Service Definition
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package scada;
|
||||
|
||||
service ScadaService {
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
||||
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
||||
rpc Read(ReadRequest) returns (ReadResponse);
|
||||
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
||||
rpc Write(WriteRequest) returns (WriteResponse);
|
||||
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
||||
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
||||
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
||||
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
|
||||
}
|
||||
```
|
||||
|
||||
Proto file location: `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto`
|
||||
|
||||
## Connection Lifecycle
|
||||
|
||||
### Session Model
|
||||
|
||||
Every client must call `Connect` before performing any read, write, or subscribe operation. The server returns a session ID (32-character hex GUID) that must be included in all subsequent requests. Sessions persist until `Disconnect` is called or the server restarts — there is no idle timeout.
|
||||
|
||||
### Authentication
|
||||
|
||||
API key authentication is optional, controlled by server configuration:
|
||||
|
||||
- **If required**: The `Connect` RPC fails with `success=false` if the API key doesn't match.
|
||||
- **If not required**: All API keys are accepted (including empty).
|
||||
- The API key is sent both in the `ConnectRequest.api_key` field and as an `x-api-key` gRPC metadata header on the `Connect` call.
|
||||
|
||||
### Connect
|
||||
|
||||
```
|
||||
ConnectRequest {
|
||||
client_id: string // Client identifier (e.g., "ScadaLink-{guid}")
|
||||
api_key: string // API key for authentication (empty if none)
|
||||
}
|
||||
|
||||
ConnectResponse {
|
||||
success: bool // Whether connection succeeded
|
||||
message: string // Status message
|
||||
session_id: string // 32-char hex GUID (only valid if success=true)
|
||||
}
|
||||
```
|
||||
|
||||
The client generates `client_id` as `"ScadaLink-{Guid:N}"` for uniqueness.
|
||||
|
||||
### Disconnect
|
||||
|
||||
```
|
||||
DisconnectRequest {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
DisconnectResponse {
|
||||
success: bool
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
Best-effort — the client calls disconnect but does not retry on failure.
|
||||
|
||||
### GetConnectionState
|
||||
|
||||
```
|
||||
GetConnectionStateRequest {
|
||||
session_id: string
|
||||
}
|
||||
|
||||
GetConnectionStateResponse {
|
||||
is_connected: bool
|
||||
client_id: string
|
||||
connected_since_utc_ticks: int64 // DateTime.UtcNow.Ticks at connect time
|
||||
}
|
||||
```
|
||||
|
||||
### CheckApiKey
|
||||
|
||||
```
|
||||
CheckApiKeyRequest {
|
||||
api_key: string
|
||||
}
|
||||
|
||||
CheckApiKeyResponse {
|
||||
is_valid: bool
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
Standalone API key validation without creating a session.
|
||||
|
||||
## Value-Timestamp-Quality (VTQ)
|
||||
|
||||
The core data structure for all read and subscription results:
|
||||
|
||||
```
|
||||
VtqMessage {
|
||||
tag: string // Tag address
|
||||
value: string // Value encoded as string (see Value Encoding)
|
||||
timestamp_utc_ticks: int64 // UTC DateTime.Ticks (100ns intervals since 0001-01-01)
|
||||
quality: string // "Good", "Uncertain", or "Bad"
|
||||
}
|
||||
```
|
||||
|
||||
### Value Encoding
|
||||
|
||||
All values are transmitted as strings on the wire. Both client and server use the same parsing order:
|
||||
|
||||
| Wire String | Parsed Type | Example |
|
||||
|-------------|------------|---------|
|
||||
| Numeric (double-parseable) | `double` | `"42.5"` → `42.5` |
|
||||
| `"true"` / `"false"` (case-insensitive) | `bool` | `"True"` → `true` |
|
||||
| Everything else | `string` | `"Running"` → `"Running"` |
|
||||
| Empty string | `null` | `""` → `null` |
|
||||
|
||||
For write operations, values are converted to strings via `.ToString()` before transmission.
|
||||
|
||||
Arrays and lists are JSON-serialized (e.g., `[1,2,3]`).
|
||||
|
||||
### Quality Codes
|
||||
|
||||
Quality is transmitted as a case-insensitive string:
|
||||
|
||||
| Wire Value | Meaning | OPC UA Status Code |
|
||||
|-----------|---------|-------------------|
|
||||
| `"Good"` | Value is reliable | `0x00000000` (StatusCode == 0) |
|
||||
| `"Uncertain"` | Value may not be current | Non-zero, high bit clear |
|
||||
| `"Bad"` | Value is unreliable or unavailable | High bit set (`0x80000000`) |
|
||||
|
||||
A null or missing VTQ message is treated as Bad quality with null value and current UTC timestamp.
|
||||
|
||||
### Timestamps
|
||||
|
||||
- All timestamps are UTC.
|
||||
- Encoded as `int64` representing `DateTime.Ticks` (100-nanosecond intervals since 0001-01-01 00:00:00 UTC).
|
||||
- Client reconstructs via `new DateTime(ticks, DateTimeKind.Utc)`.
|
||||
|
||||
## Read Operations
|
||||
|
||||
### Read (Single Tag)
|
||||
|
||||
```
|
||||
ReadRequest {
|
||||
session_id: string // Valid session ID
|
||||
tag: string // Tag address
|
||||
}
|
||||
|
||||
ReadResponse {
|
||||
success: bool // Whether read succeeded
|
||||
message: string // Error message if failed
|
||||
vtq: VtqMessage // Value-timestamp-quality result
|
||||
}
|
||||
```
|
||||
|
||||
### ReadBatch (Multiple Tags)
|
||||
|
||||
```
|
||||
ReadBatchRequest {
|
||||
session_id: string
|
||||
tags: repeated string // Tag addresses
|
||||
}
|
||||
|
||||
ReadBatchResponse {
|
||||
success: bool // false if any tag failed
|
||||
message: string // Error message
|
||||
vtqs: repeated VtqMessage // Results in same order as request
|
||||
}
|
||||
```
|
||||
|
||||
Batch reads are **partially successful** — individual tags may have Bad quality while the overall response succeeds. If a tag read throws an exception, its VTQ is returned with Bad quality and current UTC timestamp.
|
||||
|
||||
## Write Operations
|
||||
|
||||
### Write (Single Tag)
|
||||
|
||||
```
|
||||
WriteRequest {
|
||||
session_id: string
|
||||
tag: string
|
||||
value: string // Value as string (parsed server-side)
|
||||
}
|
||||
|
||||
WriteResponse {
|
||||
success: bool
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
### WriteBatch (Multiple Tags)
|
||||
|
||||
```
|
||||
WriteItem {
|
||||
tag: string
|
||||
value: string
|
||||
}
|
||||
|
||||
WriteResult {
|
||||
tag: string
|
||||
success: bool
|
||||
message: string
|
||||
}
|
||||
|
||||
WriteBatchRequest {
|
||||
session_id: string
|
||||
items: repeated WriteItem
|
||||
}
|
||||
|
||||
WriteBatchResponse {
|
||||
success: bool // Overall success (all items must succeed)
|
||||
message: string
|
||||
results: repeated WriteResult // Per-item results
|
||||
}
|
||||
```
|
||||
|
||||
Batch writes are **all-or-nothing** at the reporting level — if any item fails, overall `success` is `false`.
|
||||
|
||||
### WriteBatchAndWait (Atomic Write + Flag Polling)
|
||||
|
||||
A compound operation: write values, then poll a flag tag until it matches an expected value or times out.
|
||||
|
||||
```
|
||||
WriteBatchAndWaitRequest {
|
||||
session_id: string
|
||||
items: repeated WriteItem // Values to write
|
||||
flag_tag: string // Tag to poll after writes
|
||||
flag_value: string // Expected value (string comparison)
|
||||
timeout_ms: int32 // Timeout in ms (default 5000 if ≤ 0)
|
||||
poll_interval_ms: int32 // Poll interval in ms (default 100 if ≤ 0)
|
||||
}
|
||||
|
||||
WriteBatchAndWaitResponse {
|
||||
success: bool // Overall operation success
|
||||
message: string
|
||||
write_results: repeated WriteResult // Per-item write results
|
||||
flag_reached: bool // Whether flag matched before timeout
|
||||
elapsed_ms: int32 // Total elapsed time
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
1. All writes execute first. If any write fails, the operation returns immediately with `success=false`.
|
||||
2. If writes succeed, polls `flag_tag` at `poll_interval_ms` intervals.
|
||||
3. Compares `readResult.Value?.ToString() == flag_value` (case-sensitive string comparison).
|
||||
4. If flag matches before timeout: `success=true`, `flag_reached=true`.
|
||||
5. If timeout expires: `success=true`, `flag_reached=false` (timeout is not an error).
|
||||
|
||||
## Subscription (Server Streaming)
|
||||
|
||||
### Subscribe
|
||||
|
||||
```
|
||||
SubscribeRequest {
|
||||
session_id: string
|
||||
tags: repeated string // Tag addresses to monitor
|
||||
sampling_ms: int32 // Backend sampling interval in milliseconds
|
||||
}
|
||||
|
||||
// Returns: stream of VtqMessage
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
1. Server validates the session. Invalid session → `RpcException` with `StatusCode.Unauthenticated`.
|
||||
2. Server registers monitored items on the backend (e.g., OPC UA subscriptions) for all requested tags.
|
||||
3. On each value change, the server pushes a `VtqMessage` to the response stream.
|
||||
4. The stream remains open indefinitely until:
|
||||
- The client cancels (disposes the subscription).
|
||||
- The server encounters an error (backend disconnect, etc.).
|
||||
- The gRPC connection drops.
|
||||
5. On stream termination, the client's `onStreamError` callback fires exactly once.
|
||||
|
||||
**Client-side subscription lifecycle:**
|
||||
|
||||
```
|
||||
ILmxSubscription subscription = await client.SubscribeAsync(
|
||||
addresses: ["Motor.Speed", "Motor.Temperature"],
|
||||
onUpdate: (tag, vtq) => { /* handle value change */ },
|
||||
onStreamError: () => { /* handle disconnect */ });
|
||||
|
||||
// Later:
|
||||
await subscription.DisposeAsync(); // Cancels the stream
|
||||
```
|
||||
|
||||
Disposing the subscription cancels the underlying `CancellationTokenSource`, which terminates the background stream-reading task and triggers server-side cleanup of monitored items.
|
||||
|
||||
## Tag Addressing
|
||||
|
||||
Tags are string addresses that identify data points. The proxy maps tag addresses to backend-specific identifiers.
|
||||
|
||||
**LmxFakeProxy example** (OPC UA backend):
|
||||
|
||||
Tag addresses are concatenated with a configurable prefix to form OPC UA node IDs:
|
||||
|
||||
```
|
||||
Prefix: "ns=3;s="
|
||||
Tag: "Motor.Speed"
|
||||
NodeId: "ns=3;s=Motor.Speed"
|
||||
```
|
||||
|
||||
The prefix is configured at server startup via the `OPC_UA_PREFIX` environment variable.
|
||||
|
||||
## Transport Details
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Protocol | gRPC over HTTP/2 |
|
||||
| Default port | 50051 |
|
||||
| TLS | Optional (controlled by `UseTls` connection parameter) |
|
||||
| Metadata headers | `x-api-key` (sent on Connect call if API key configured) |
|
||||
|
||||
### Connection Parameters
|
||||
|
||||
The ScadaLink DCL configures LmxProxy connections via a string dictionary:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `Host` | string | `"localhost"` | gRPC server hostname |
|
||||
| `Port` | string (parsed as int) | `"50051"` | gRPC server port |
|
||||
| `ApiKey` | string | (none) | API key for authentication |
|
||||
| `SamplingIntervalMs` | string (parsed as int) | `"0"` | Backend sampling interval for subscriptions |
|
||||
| `UseTls` | string (parsed as bool) | `"false"` | Use HTTPS instead of HTTP |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Operation | Error Mechanism | Client Behavior |
|
||||
|-----------|----------------|-----------------|
|
||||
| Connect | `success=false` in response | Throws `InvalidOperationException` |
|
||||
| Read/ReadBatch | `success=false` in response | Throws `InvalidOperationException` |
|
||||
| Write/WriteBatch | `success=false` in response | Throws `InvalidOperationException` |
|
||||
| WriteBatchAndWait | `success=false` or `flag_reached=false` | Returns result (timeout is not an exception) |
|
||||
| Subscribe (auth) | `RpcException` with `Unauthenticated` | Propagated to caller |
|
||||
| Subscribe (stream) | Stream ends or gRPC error | `onStreamError` callback invoked; `sessionId` nullified |
|
||||
| Any (disconnected) | Client checks `IsConnected` | Throws `InvalidOperationException("not connected")` |
|
||||
|
||||
When a subscription stream ends unexpectedly, the client immediately nullifies its session ID, causing `IsConnected` to return `false`. The DCL adapter fires its `Disconnected` event, which triggers the reconnection cycle in the `DataConnectionActor`.
|
||||
|
||||
## Implementation Files
|
||||
|
||||
| Component | File |
|
||||
|-----------|------|
|
||||
| Proto definition | `src/ScadaLink.DataConnectionLayer/Adapters/Protos/scada.proto` |
|
||||
| Client interface | `src/ScadaLink.DataConnectionLayer/Adapters/ILmxProxyClient.cs` |
|
||||
| Client implementation | `src/ScadaLink.DataConnectionLayer/Adapters/RealLmxProxyClient.cs` |
|
||||
| DCL adapter | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyDataConnection.cs` |
|
||||
| Client factory | `src/ScadaLink.DataConnectionLayer/Adapters/LmxProxyClientFactory.cs` |
|
||||
| Server implementation | `infra/lmxfakeproxy/Services/ScadaServiceImpl.cs` |
|
||||
| Session manager | `infra/lmxfakeproxy/Sessions/SessionManager.cs` |
|
||||
| Tag mapper | `infra/lmxfakeproxy/TagMapper.cs` |
|
||||
| OPC UA bridge interface | `infra/lmxfakeproxy/Bridge/IOpcUaBridge.cs` |
|
||||
| OPC UA bridge impl | `infra/lmxfakeproxy/Bridge/OpcUaBridge.cs` |
|
||||
@@ -1,17 +1,18 @@
|
||||
# Test Infrastructure
|
||||
|
||||
This document describes the local Docker-based test infrastructure for ScadaLink development. Six services provide the external dependencies needed to run and test the system locally. The first six run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`.
|
||||
This document describes the local Docker-based test infrastructure for ScadaLink development. Seven services provide the external dependencies needed to run and test the system locally. The first seven run in `infra/docker-compose.yml`; Traefik runs alongside the cluster nodes in `docker/docker-compose.yml`.
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Image | Port(s) | Config | Compose File |
|
||||
|---------|-------|---------|--------|-------------|
|
||||
| OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` | `infra/` |
|
||||
| OPC UA Server 2 | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50010 (OPC UA), 8081 (web) | `infra/opcua/nodes.json` | `infra/` |
|
||||
| LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | `infra/` |
|
||||
| MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | `infra/` |
|
||||
| SMTP (Mailpit) | `axllent/mailpit:latest` | 1025 (SMTP), 8025 (web) | Environment vars | `infra/` |
|
||||
| REST API (Flask) | Custom build (`infra/restapi/Dockerfile`) | 5200 | `infra/restapi/app.py` | `infra/` |
|
||||
| LmxFakeProxy | Custom build (`infra/lmxfakeproxy/Dockerfile`) | 50051 (gRPC) | Environment vars | `infra/` |
|
||||
| Playwright | `mcr.microsoft.com/playwright:v1.58.2-noble` | 3000 (WebSocket) | Command args | `infra/` |
|
||||
| Traefik LB | `traefik:v3.4` | 9000 (proxy), 8180 (dashboard) | `docker/traefik/` | `docker/` |
|
||||
|
||||
## Quick Start
|
||||
@@ -42,9 +43,15 @@ Each service has a dedicated document with configuration details, verification s
|
||||
- [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database
|
||||
- [test_infra_smtp.md](test_infra_smtp.md) — SMTP test server (Mailpit)
|
||||
- [test_infra_restapi.md](test_infra_restapi.md) — REST API test server (Flask)
|
||||
- [test_infra_lmxfakeproxy.md](test_infra_lmxfakeproxy.md) — LmxProxy fake server (OPC UA bridge)
|
||||
- [test_infra_playwright.md](test_infra_playwright.md) — Playwright browser server (Central UI testing)
|
||||
- Traefik LB — see `docker/README.md` and `docker/traefik/` (runs with the cluster, not in `infra/`)
|
||||
|
||||
## Remote Test Infrastructure
|
||||
|
||||
In addition to the local Docker services, the following remote services are available for testing against real AVEVA System Platform hardware.
|
||||
|
||||
**Primary/backup testing**: The dual OPC UA test servers (ports 50000 and 50010) in local Docker provide primary/backup endpoint pairs for testing Data Connection Layer failover. Use `docker compose stop opcua` to simulate primary failure and verify automatic failover to the backup.
|
||||
|
||||
## Connection Strings
|
||||
|
||||
For use in `appsettings.Development.json`:
|
||||
@@ -64,6 +71,9 @@ For use in `appsettings.Development.json`:
|
||||
"OpcUa": {
|
||||
"EndpointUrl": "opc.tcp://localhost:50000"
|
||||
},
|
||||
"OpcUa2": {
|
||||
"EndpointUrl": "opc.tcp://localhost:50010"
|
||||
},
|
||||
"Smtp": {
|
||||
"Server": "localhost",
|
||||
"Port": 1025,
|
||||
@@ -86,7 +96,7 @@ For use in `appsettings.Development.json`:
|
||||
```bash
|
||||
cd infra
|
||||
docker compose down # stop containers, preserve SQL data volume
|
||||
docker compose stop opcua # stop a single service (also: ldap, mssql, smtp, restapi)
|
||||
docker compose stop opcua # stop a single service (also: opcua2, ldap, mssql, smtp, restapi)
|
||||
```
|
||||
|
||||
**Full teardown** (removes volumes, optionally images and venv):
|
||||
@@ -103,7 +113,7 @@ After a full teardown, the next `docker compose up -d` starts fresh — re-run t
|
||||
|
||||
```
|
||||
infra/
|
||||
docker-compose.yml # All five services
|
||||
docker-compose.yml # All seven services
|
||||
teardown.sh # Teardown script (volumes, images, venv)
|
||||
glauth/config.toml # LDAP users and groups
|
||||
mssql/setup.sql # Database and user creation
|
||||
@@ -111,7 +121,6 @@ infra/
|
||||
opcua/nodes.json # Custom OPC UA tag definitions
|
||||
restapi/app.py # Flask REST API server
|
||||
restapi/Dockerfile # REST API container build
|
||||
lmxfakeproxy/ # .NET gRPC proxy bridging LmxProxy protocol to OPC UA
|
||||
tools/ # Python CLI tools (opcua, ldap, mssql, smtp, restapi)
|
||||
README.md # Quick-start for the infra folder
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Test Infrastructure: LmxFakeProxy
|
||||
|
||||
## Overview
|
||||
|
||||
LmxFakeProxy is a .NET gRPC server that implements the `scada.ScadaService` proto (full parity with the real LmxProxy server) but bridges to the OPC UA test server instead of System Platform MXAccess. This enables end-to-end testing of `RealLmxProxyClient` and the LmxProxy DCL adapter.
|
||||
|
||||
## Image & Ports
|
||||
|
||||
- **Image**: Custom build (`infra/lmxfakeproxy/Dockerfile`)
|
||||
- **gRPC endpoint**: `localhost:50051`
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `PORT` | `50051` | gRPC listen port |
|
||||
| `OPC_ENDPOINT` | `opc.tcp://localhost:50000` | Backend OPC UA server |
|
||||
| `OPC_PREFIX` | `ns=3;s=` | Prefix prepended to LMX tags to form OPC UA NodeIds |
|
||||
| `API_KEY` | *(none)* | If set, enforces API key on all gRPC calls |
|
||||
|
||||
## Tag Address Mapping
|
||||
|
||||
LMX-style flat addresses are mapped to OPC UA NodeIds by prepending the configured prefix:
|
||||
|
||||
| LMX Tag | OPC UA NodeId |
|
||||
|---------|--------------|
|
||||
| `Motor.Speed` | `ns=3;s=Motor.Speed` |
|
||||
| `Pump.FlowRate` | `ns=3;s=Pump.FlowRate` |
|
||||
| `Tank.Level` | `ns=3;s=Tank.Level` |
|
||||
|
||||
## Supported RPCs
|
||||
|
||||
Full parity with the `scada.ScadaService` proto:
|
||||
|
||||
- **Connect / Disconnect / GetConnectionState** — Session management
|
||||
- **Read / ReadBatch** — Read tag values via OPC UA
|
||||
- **Write / WriteBatch / WriteBatchAndWait** — Write values via OPC UA
|
||||
- **Subscribe** — Server-streaming subscriptions via OPC UA MonitoredItems
|
||||
- **CheckApiKey** — API key validation
|
||||
|
||||
## Verification
|
||||
|
||||
1. Ensure the OPC UA test server is running:
|
||||
```bash
|
||||
docker ps --filter name=scadalink-opcua
|
||||
```
|
||||
|
||||
2. Start the fake proxy:
|
||||
```bash
|
||||
docker compose up -d lmxfakeproxy
|
||||
```
|
||||
|
||||
3. Check logs:
|
||||
```bash
|
||||
docker logs scadalink-lmxfakeproxy
|
||||
```
|
||||
|
||||
4. Test with the ScadaLink CLI or a gRPC client.
|
||||
|
||||
## Running Standalone (without Docker)
|
||||
|
||||
```bash
|
||||
cd infra/lmxfakeproxy
|
||||
dotnet run -- --opc-endpoint opc.tcp://localhost:50000 --opc-prefix "ns=3;s="
|
||||
```
|
||||
|
||||
With API key enforcement:
|
||||
```bash
|
||||
dotnet run -- --api-key my-secret-key
|
||||
```
|
||||
|
||||
## Relevance to ScadaLink Components
|
||||
|
||||
- **Data Connection Layer** — Test `RealLmxProxyClient` and `LmxProxyDataConnection` against real OPC UA data
|
||||
- **Site Runtime** — Deploy instances with LmxProxy data connections pointing at this server
|
||||
- **Integration Tests** — End-to-end tests of the LmxProxy protocol path
|
||||
@@ -6,9 +6,14 @@ The test OPC UA server uses [Azure IoT OPC PLC](https://github.com/Azure-Samples
|
||||
|
||||
## Image & Ports
|
||||
|
||||
Two identical OPC UA server instances run with the same tag configuration, on different ports:
|
||||
|
||||
| Instance | OPC UA Endpoint | Web UI | Container |
|
||||
|----------|----------------|--------|-----------|
|
||||
| opcua | `opc.tcp://localhost:50000` | `http://localhost:8080` | scadalink-opcua |
|
||||
| opcua2 | `opc.tcp://localhost:50010` | `http://localhost:8081` | scadalink-opcua2 |
|
||||
|
||||
- **Image**: `mcr.microsoft.com/iotedge/opc-plc:latest`
|
||||
- **OPC UA endpoint**: `opc.tcp://localhost:50000`
|
||||
- **Web/config UI**: `http://localhost:8080`
|
||||
|
||||
## Startup Flags
|
||||
|
||||
@@ -43,20 +48,21 @@ The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check the container is running:
|
||||
1. Check both containers are running:
|
||||
|
||||
```bash
|
||||
docker ps --filter name=scadalink-opcua
|
||||
```
|
||||
|
||||
2. Verify the OPC UA endpoint using any OPC UA client (e.g., UaExpert, opcua-commander):
|
||||
2. Verify both OPC UA endpoints using any OPC UA client (e.g., UaExpert, opcua-commander):
|
||||
|
||||
```bash
|
||||
# Using opcua-commander (npm install -g opcua-commander)
|
||||
opcua-commander -e opc.tcp://localhost:50000
|
||||
opcua-commander -e opc.tcp://localhost:50010
|
||||
```
|
||||
|
||||
3. Check the web UI at `http://localhost:8080` for server status and node listing.
|
||||
3. Check the web UIs at `http://localhost:8080` (opcua) and `http://localhost:8081` (opcua2) for server status and node listing.
|
||||
|
||||
## CLI Tool
|
||||
|
||||
@@ -89,7 +95,14 @@ python infra/tools/opcua_tool.py write --node "ns=3;s=Motor.Running" --value tru
|
||||
python infra/tools/opcua_tool.py monitor --nodes "ns=3;s=Motor.Speed,ns=3;s=Pump.FlowRate" --duration 15
|
||||
```
|
||||
|
||||
Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). Run with `--help` for full usage.
|
||||
Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). For the second instance:
|
||||
|
||||
```bash
|
||||
python infra/tools/opcua_tool.py --endpoint opc.tcp://localhost:50010 check
|
||||
python infra/tools/opcua_tool.py --endpoint opc.tcp://localhost:50010 browse --path "3:OpcPlc.3:ScadaLink.3:Motor"
|
||||
```
|
||||
|
||||
Run with `--help` for full usage.
|
||||
|
||||
## Relevance to ScadaLink Components
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Test Infrastructure: Playwright Browser Server
|
||||
|
||||
## Overview
|
||||
|
||||
The Playwright browser server provides a remote headless browser (Chromium, Firefox, WebKit) that test scripts connect to over the network. It runs as a Playwright Server on port 3000, allowing UI tests for the Central UI (Blazor Server) to run from the host machine while the browser executes inside the container with access to the Docker network.
|
||||
|
||||
## Image & Ports
|
||||
|
||||
- **Image**: `mcr.microsoft.com/playwright:v1.58.2-noble` (Ubuntu 24.04 LTS)
|
||||
- **Server port**: 3000 (Playwright Server WebSocket endpoint)
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Value | Description |
|
||||
|---------|-------|-------------|
|
||||
| `--host 0.0.0.0` | Bind address | Listen on all interfaces |
|
||||
| `--port 3000` | Server port | Playwright Server WebSocket port |
|
||||
| `ipc: host` | Docker IPC | Shared IPC namespace (required for Chromium) |
|
||||
|
||||
No additional config files are needed. The container runs `npx playwright run-server` on startup.
|
||||
|
||||
## Connecting from Test Scripts
|
||||
|
||||
Test scripts run on the host and connect to the browser server via WebSocket. The connection URL is:
|
||||
|
||||
```
|
||||
ws://localhost:3000
|
||||
```
|
||||
|
||||
### .NET (Microsoft.Playwright)
|
||||
|
||||
```csharp
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
var browser = await playwright.Chromium.ConnectAsync("ws://localhost:3000");
|
||||
var page = await browser.NewPageAsync();
|
||||
|
||||
// Browser runs inside Docker — use the Docker network hostname for Traefik.
|
||||
await page.GotoAsync("http://scadalink-traefik");
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const { chromium } = require('playwright');
|
||||
const browser = await chromium.connect('ws://localhost:3000');
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://scadalink-traefik');
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.connect("ws://localhost:3000")
|
||||
page = browser.new_page()
|
||||
page.goto("http://scadalink-traefik")
|
||||
```
|
||||
|
||||
## Central UI Access
|
||||
|
||||
The Playwright container is on the `scadalink-net` Docker network, so it can reach the Central UI cluster nodes directly:
|
||||
|
||||
| Target | URL in Test Scripts |
|
||||
|--------|---------------------|
|
||||
| Traefik LB | `http://scadalink-traefik` |
|
||||
| Central Node A | `http://scadalink-central-a:5000` |
|
||||
| Central Node B | `http://scadalink-central-b:5000` |
|
||||
|
||||
**Important**: The browser runs inside the Docker container, so `page.goto()` URLs must use Docker network hostnames (not `localhost`). The test script itself connects to the Playwright server via `ws://localhost:3000` (host-mapped port), but all URLs navigated by the browser resolve inside the container.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check the container is running:
|
||||
|
||||
```bash
|
||||
docker ps --filter name=scadalink-playwright
|
||||
```
|
||||
|
||||
2. Check the server is accepting connections (look for the WebSocket endpoint in logs):
|
||||
|
||||
```bash
|
||||
docker logs scadalink-playwright 2>&1 | head -5
|
||||
```
|
||||
|
||||
3. Quick smoke test with a one-liner (requires `npx` and `playwright` on the host):
|
||||
|
||||
```bash
|
||||
npx playwright@1.58.2 test --browser chromium --connect ws://localhost:3000
|
||||
```
|
||||
|
||||
## Relevance to ScadaLink Components
|
||||
|
||||
- **Central UI** — end-to-end UI testing of all Blazor Server pages (login, admin, design, deployment, monitoring workflows).
|
||||
- **Traefik Proxy** — verify load balancer behavior, failover, and active node routing from a browser perspective.
|
||||
|
||||
## Notes
|
||||
|
||||
- The container includes Chromium, Firefox, and WebKit. Connect to the desired browser via `playwright.chromium.connect()`, `playwright.firefox.connect()`, or `playwright.webkit.connect()`.
|
||||
- The `ipc: host` flag is required for Chromium to avoid out-of-memory crashes in the container.
|
||||
- The Playwright Server version (`1.58.2`) must match the `@playwright` package version used by test scripts on the host.
|
||||
- The container is stateless — no test data or browser state persists between restarts.
|
||||
- To stop only the Playwright container: `cd infra && docker compose stop playwright`.
|
||||
+3
-3
@@ -8,16 +8,16 @@ Local Docker-based test services for ScadaLink development.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts five services:
|
||||
This starts the following services:
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags |
|
||||
| OPC UA 2 (Azure IoT OPC PLC) | 50010 (OPC UA), 8081 (web) | Second OPC UA server instance (same tags, independent state) |
|
||||
| LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles |
|
||||
| MS SQL 2022 | 1433 | Configuration and machine data databases |
|
||||
| SMTP (Mailpit) | 1025 (SMTP), 8025 (web) | Email capture for notification testing |
|
||||
| REST API (Flask) | 5200 | External REST API for Gateway and Inbound API testing |
|
||||
| LmxFakeProxy (.NET gRPC) | 50051 (gRPC) | LmxProxy-compatible server bridging to OPC UA test server |
|
||||
|
||||
## First-Time SQL Setup
|
||||
|
||||
@@ -46,7 +46,7 @@ docker compose down
|
||||
|
||||
**Stop a single service** (leave the others running):
|
||||
```bash
|
||||
docker compose stop opcua # or: ldap, mssql, smtp, restapi
|
||||
docker compose stop opcua # or: opcua2, ldap, mssql, smtp, restapi
|
||||
docker compose start opcua # bring it back without recreating
|
||||
```
|
||||
|
||||
|
||||
@@ -20,6 +20,27 @@ services:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
|
||||
opcua2:
|
||||
image: mcr.microsoft.com/iotedge/opc-plc:latest
|
||||
container_name: scadalink-opcua2
|
||||
ports:
|
||||
- "50010:50010"
|
||||
- "8081:8080"
|
||||
volumes:
|
||||
- ./opcua/nodes.json:/app/config/nodes.json:ro
|
||||
command: >
|
||||
--autoaccept
|
||||
--unsecuretransport
|
||||
--sph
|
||||
--sn=5 --sr=10 --st=uint
|
||||
--fn=5 --fr=1 --ft=uint
|
||||
--gn=5
|
||||
--nf=/app/config/nodes.json
|
||||
--pn=50010
|
||||
networks:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
|
||||
ldap:
|
||||
image: glauth/glauth:latest
|
||||
container_name: scadalink-ldap
|
||||
@@ -74,16 +95,16 @@ services:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
|
||||
lmxfakeproxy:
|
||||
build: ./lmxfakeproxy
|
||||
container_name: scadalink-lmxfakeproxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
container_name: scadalink-playwright
|
||||
ports:
|
||||
- "50051:50051"
|
||||
environment:
|
||||
OPC_ENDPOINT: "opc.tcp://opcua:50000"
|
||||
OPC_PREFIX: "ns=3;s="
|
||||
depends_on:
|
||||
- opcua
|
||||
- "3000:3000"
|
||||
command: >
|
||||
npx -y playwright@1.58.2 run-server
|
||||
--host 0.0.0.0
|
||||
--port 3000
|
||||
ipc: host
|
||||
networks:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
tests/
|
||||
bin/
|
||||
obj/
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace LmxFakeProxy.Bridge;
|
||||
|
||||
public record OpcUaReadResult(object? Value, DateTime SourceTimestamp, uint StatusCode);
|
||||
|
||||
public interface IOpcUaBridge : IAsyncDisposable
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string> AddMonitoredItemsAsync(
|
||||
IEnumerable<string> nodeIds,
|
||||
int samplingIntervalMs,
|
||||
Action<string, object?, DateTime, uint> onValueChanged,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default);
|
||||
|
||||
event Action? Disconnected;
|
||||
event Action? Reconnected;
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
|
||||
namespace LmxFakeProxy.Bridge;
|
||||
|
||||
public class OpcUaBridge : IOpcUaBridge
|
||||
{
|
||||
private readonly string _endpointUrl;
|
||||
private readonly ILogger<OpcUaBridge> _logger;
|
||||
private Opc.Ua.Client.ISession? _session;
|
||||
private Subscription? _subscription;
|
||||
private volatile bool _connected;
|
||||
private volatile bool _reconnecting;
|
||||
private CancellationTokenSource? _reconnectCts;
|
||||
|
||||
private readonly Dictionary<string, List<MonitoredItem>> _handleItems = new();
|
||||
private readonly Dictionary<string, Action<string, object?, DateTime, uint>> _handleCallbacks = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public OpcUaBridge(string endpointUrl, ILogger<OpcUaBridge> logger)
|
||||
{
|
||||
_endpointUrl = endpointUrl;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
public event Action? Disconnected;
|
||||
public event Action? Reconnected;
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "LmxFakeProxy",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
ApplicationCertificate = new CertificateIdentifier(),
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StorePath = Path.Combine(Path.GetTempPath(), "LmxFakeProxy", "pki", "rejected") }
|
||||
},
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }
|
||||
};
|
||||
|
||||
await appConfig.Validate(ApplicationType.Client);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
EndpointDescription? endpoint;
|
||||
try
|
||||
{
|
||||
#pragma warning disable CS0618
|
||||
using var discoveryClient = DiscoveryClient.Create(new Uri(_endpointUrl));
|
||||
var endpoints = discoveryClient.GetEndpoints(null);
|
||||
#pragma warning restore CS0618
|
||||
endpoint = endpoints
|
||||
.Where(e => e.SecurityMode == MessageSecurityMode.None)
|
||||
.FirstOrDefault() ?? endpoints.FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
endpoint = new EndpointDescription(_endpointUrl);
|
||||
}
|
||||
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
|
||||
|
||||
_session = await Session.Create(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"LmxFakeProxy-Session", 60000, null, null, cancellationToken);
|
||||
|
||||
_session.KeepAlive += OnSessionKeepAlive;
|
||||
|
||||
_subscription = new Subscription(_session.DefaultSubscription)
|
||||
{
|
||||
DisplayName = "LmxFakeProxy",
|
||||
PublishingEnabled = true,
|
||||
PublishingInterval = 500,
|
||||
KeepAliveCount = 10,
|
||||
LifetimeCount = 30,
|
||||
MaxNotificationsPerPublish = 1000
|
||||
};
|
||||
|
||||
_session.AddSubscription(_subscription);
|
||||
await _subscription.CreateAsync(cancellationToken);
|
||||
|
||||
_connected = true;
|
||||
_logger.LogInformation("OPC UA bridge connected to {Endpoint}", _endpointUrl);
|
||||
}
|
||||
|
||||
public async Task<OpcUaReadResult> ReadAsync(string nodeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var readValue = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value };
|
||||
var response = await _session!.ReadAsync(
|
||||
null, 0, TimestampsToReturn.Source,
|
||||
new ReadValueIdCollection { readValue }, cancellationToken);
|
||||
var result = response.Results[0];
|
||||
return new OpcUaReadResult(result.Value, result.SourceTimestamp, result.StatusCode.Code);
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(string nodeId, object? value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var writeValue = new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value))
|
||||
};
|
||||
var response = await _session!.WriteAsync(
|
||||
null, new WriteValueCollection { writeValue }, cancellationToken);
|
||||
return response.Results[0].Code;
|
||||
}
|
||||
|
||||
public async Task<string> AddMonitoredItemsAsync(
|
||||
IEnumerable<string> nodeIds, int samplingIntervalMs,
|
||||
Action<string, object?, DateTime, uint> onValueChanged,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var handle = Guid.NewGuid().ToString("N");
|
||||
var items = new List<MonitoredItem>();
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
|
||||
{
|
||||
DisplayName = nodeId,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = samplingIntervalMs,
|
||||
QueueSize = 10,
|
||||
DiscardOldest = true
|
||||
};
|
||||
|
||||
monitoredItem.Notification += (item, e) =>
|
||||
{
|
||||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||||
{
|
||||
var val = notification.Value?.Value;
|
||||
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
||||
var sc = notification.Value?.StatusCode.Code ?? 0;
|
||||
onValueChanged(nodeId, val, ts, sc);
|
||||
}
|
||||
};
|
||||
|
||||
items.Add(monitoredItem);
|
||||
_subscription!.AddItem(monitoredItem);
|
||||
}
|
||||
|
||||
await _subscription!.ApplyChangesAsync(cancellationToken);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_handleItems[handle] = items;
|
||||
_handleCallbacks[handle] = onValueChanged;
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task RemoveMonitoredItemsAsync(string handle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<MonitoredItem>? items;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handleItems.Remove(handle, out items))
|
||||
return;
|
||||
_handleCallbacks.Remove(handle);
|
||||
}
|
||||
|
||||
if (_subscription != null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
_subscription.RemoveItem(item);
|
||||
try { await _subscription.ApplyChangesAsync(cancellationToken); }
|
||||
catch { /* best-effort during cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionKeepAlive(Opc.Ua.Client.ISession session, KeepAliveEventArgs e)
|
||||
{
|
||||
if (ServiceResult.IsBad(e.Status))
|
||||
{
|
||||
if (!_connected) return;
|
||||
_connected = false;
|
||||
_logger.LogWarning("OPC UA backend connection lost");
|
||||
Disconnected?.Invoke();
|
||||
StartReconnectLoop();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartReconnectLoop()
|
||||
{
|
||||
if (_reconnecting) return;
|
||||
_reconnecting = true;
|
||||
_reconnectCts = new CancellationTokenSource();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (!_reconnectCts.Token.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(5000, _reconnectCts.Token);
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Attempting OPC UA reconnection...");
|
||||
if (_session != null)
|
||||
{
|
||||
_session.KeepAlive -= OnSessionKeepAlive;
|
||||
try { await _session.CloseAsync(); } catch { }
|
||||
_session = null;
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
await ConnectAsync(_reconnectCts.Token);
|
||||
|
||||
// Re-add monitored items for active handles
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var (handle, callback) in _handleCallbacks)
|
||||
{
|
||||
if (_handleItems.TryGetValue(handle, out var oldItems))
|
||||
{
|
||||
var nodeIds = oldItems.Select(i => i.StartNodeId.ToString()).ToList();
|
||||
var newItems = new List<MonitoredItem>();
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
var monitoredItem = new MonitoredItem(_subscription!.DefaultItem)
|
||||
{
|
||||
DisplayName = nodeId,
|
||||
StartNodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
SamplingInterval = oldItems[0].SamplingInterval,
|
||||
QueueSize = 10,
|
||||
DiscardOldest = true
|
||||
};
|
||||
var capturedNodeId = nodeId;
|
||||
var capturedCallback = callback;
|
||||
monitoredItem.Notification += (item, ev) =>
|
||||
{
|
||||
if (ev.NotificationValue is MonitoredItemNotification notification)
|
||||
{
|
||||
var val = notification.Value?.Value;
|
||||
var ts = notification.Value?.SourceTimestamp ?? DateTime.UtcNow;
|
||||
var sc = notification.Value?.StatusCode.Code ?? 0;
|
||||
capturedCallback(capturedNodeId, val, ts, sc);
|
||||
}
|
||||
};
|
||||
newItems.Add(monitoredItem);
|
||||
_subscription!.AddItem(monitoredItem);
|
||||
}
|
||||
_handleItems[handle] = newItems;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_subscription != null)
|
||||
await _subscription.ApplyChangesAsync();
|
||||
|
||||
_reconnecting = false;
|
||||
_logger.LogInformation("OPC UA reconnection successful");
|
||||
Reconnected?.Invoke();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OPC UA reconnection attempt failed, retrying in 5s");
|
||||
}
|
||||
}
|
||||
}, _reconnectCts.Token);
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (!_connected || _session == null)
|
||||
throw new InvalidOperationException("OPC UA backend unavailable");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_reconnectCts?.Cancel();
|
||||
_reconnectCts?.Dispose();
|
||||
if (_subscription != null)
|
||||
{
|
||||
try { await _subscription.DeleteAsync(true); } catch { }
|
||||
_subscription = null;
|
||||
}
|
||||
if (_session != null)
|
||||
{
|
||||
_session.KeepAlive -= OnSessionKeepAlive;
|
||||
try { await _session.CloseAsync(); } catch { }
|
||||
_session = null;
|
||||
}
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# Build stage forced to amd64: Grpc.Tools protoc crashes on linux/arm64 (Apple Silicon)
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY LmxFakeProxy.csproj .
|
||||
RUN dotnet restore
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
EXPOSE 50051
|
||||
ENTRYPOINT ["dotnet", "LmxFakeProxy.dll"]
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>LmxFakeProxy</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests\**\*" />
|
||||
<Content Remove="tests\**\*" />
|
||||
<None Remove="tests\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos/scada.proto" GrpcServices="Server" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,57 +0,0 @@
|
||||
using LmxFakeProxy;
|
||||
using LmxFakeProxy.Bridge;
|
||||
using LmxFakeProxy.Services;
|
||||
using LmxFakeProxy.Sessions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configuration: env vars take precedence over CLI args
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? GetArg(args, "--port") ?? "50051";
|
||||
var opcEndpoint = Environment.GetEnvironmentVariable("OPC_ENDPOINT") ?? GetArg(args, "--opc-endpoint") ?? "opc.tcp://localhost:50000";
|
||||
var opcPrefix = Environment.GetEnvironmentVariable("OPC_PREFIX") ?? GetArg(args, "--opc-prefix") ?? "ns=3;s=";
|
||||
var apiKey = Environment.GetEnvironmentVariable("API_KEY") ?? GetArg(args, "--api-key");
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenAnyIP(int.Parse(port), listenOptions =>
|
||||
{
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
});
|
||||
|
||||
// Register services
|
||||
builder.Services.AddSingleton(new SessionManager(apiKey));
|
||||
builder.Services.AddSingleton(new TagMapper(opcPrefix));
|
||||
builder.Services.AddSingleton<IOpcUaBridge>(sp =>
|
||||
new OpcUaBridge(opcEndpoint, sp.GetRequiredService<ILogger<OpcUaBridge>>()));
|
||||
builder.Services.AddGrpc();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGrpcService<ScadaServiceImpl>();
|
||||
app.MapGet("/", () => "LmxFakeProxy is running");
|
||||
|
||||
// Connect to OPC UA backend
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogInformation("LmxFakeProxy starting on port {Port}", port);
|
||||
logger.LogInformation("OPC UA endpoint: {Endpoint}, prefix: {Prefix}", opcEndpoint, opcPrefix);
|
||||
logger.LogInformation("API key enforcement: {Enforced}", apiKey != null ? "enabled" : "disabled (accept all)");
|
||||
|
||||
var bridge = app.Services.GetRequiredService<IOpcUaBridge>();
|
||||
try
|
||||
{
|
||||
await ((OpcUaBridge)bridge).ConnectAsync();
|
||||
logger.LogInformation("OPC UA bridge connected");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Initial OPC UA connection failed — will retry when first request arrives");
|
||||
}
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
static string? GetArg(string[] args, string name)
|
||||
{
|
||||
var idx = Array.IndexOf(args, name);
|
||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
option csharp_namespace = "LmxFakeProxy.Grpc";
|
||||
|
||||
package scada;
|
||||
|
||||
// The SCADA service definition
|
||||
service ScadaService {
|
||||
// Connection management
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
||||
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
||||
|
||||
// Read operations
|
||||
rpc Read(ReadRequest) returns (ReadResponse);
|
||||
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
||||
|
||||
// Write operations
|
||||
rpc Write(WriteRequest) returns (WriteResponse);
|
||||
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
||||
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
||||
|
||||
// Subscription operations (server streaming) - now streams VtqMessage directly
|
||||
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
||||
|
||||
// Authentication
|
||||
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
|
||||
}
|
||||
|
||||
// === CONNECTION MESSAGES ===
|
||||
|
||||
message ConnectRequest {
|
||||
string client_id = 1;
|
||||
string api_key = 2;
|
||||
}
|
||||
|
||||
message ConnectResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
string session_id = 3;
|
||||
}
|
||||
|
||||
message DisconnectRequest {
|
||||
string session_id = 1;
|
||||
}
|
||||
|
||||
message DisconnectResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message GetConnectionStateRequest {
|
||||
string session_id = 1;
|
||||
}
|
||||
|
||||
message GetConnectionStateResponse {
|
||||
bool is_connected = 1;
|
||||
string client_id = 2;
|
||||
int64 connected_since_utc_ticks = 3;
|
||||
}
|
||||
|
||||
// === VTQ MESSAGE ===
|
||||
|
||||
message VtqMessage {
|
||||
string tag = 1;
|
||||
string value = 2;
|
||||
int64 timestamp_utc_ticks = 3;
|
||||
string quality = 4; // "Good", "Uncertain", "Bad"
|
||||
}
|
||||
|
||||
// === READ MESSAGES ===
|
||||
|
||||
message ReadRequest {
|
||||
string session_id = 1;
|
||||
string tag = 2;
|
||||
}
|
||||
|
||||
message ReadResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
VtqMessage vtq = 3;
|
||||
}
|
||||
|
||||
message ReadBatchRequest {
|
||||
string session_id = 1;
|
||||
repeated string tags = 2;
|
||||
}
|
||||
|
||||
message ReadBatchResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
repeated VtqMessage vtqs = 3;
|
||||
}
|
||||
|
||||
// === WRITE MESSAGES ===
|
||||
|
||||
message WriteRequest {
|
||||
string session_id = 1;
|
||||
string tag = 2;
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message WriteResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message WriteItem {
|
||||
string tag = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message WriteResult {
|
||||
string tag = 1;
|
||||
bool success = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message WriteBatchRequest {
|
||||
string session_id = 1;
|
||||
repeated WriteItem items = 2;
|
||||
}
|
||||
|
||||
message WriteBatchResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
repeated WriteResult results = 3;
|
||||
}
|
||||
|
||||
message WriteBatchAndWaitRequest {
|
||||
string session_id = 1;
|
||||
repeated WriteItem items = 2;
|
||||
string flag_tag = 3;
|
||||
string flag_value = 4;
|
||||
int32 timeout_ms = 5;
|
||||
int32 poll_interval_ms = 6;
|
||||
}
|
||||
|
||||
message WriteBatchAndWaitResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
repeated WriteResult write_results = 3;
|
||||
bool flag_reached = 4;
|
||||
int32 elapsed_ms = 5;
|
||||
}
|
||||
|
||||
// === SUBSCRIPTION MESSAGES ===
|
||||
|
||||
message SubscribeRequest {
|
||||
string session_id = 1;
|
||||
repeated string tags = 2;
|
||||
int32 sampling_ms = 3;
|
||||
}
|
||||
|
||||
// Note: Subscribe RPC now streams VtqMessage directly (defined above)
|
||||
|
||||
// === AUTHENTICATION MESSAGES ===
|
||||
|
||||
message CheckApiKeyRequest {
|
||||
string api_key = 1;
|
||||
}
|
||||
|
||||
message CheckApiKeyResponse {
|
||||
bool is_valid = 1;
|
||||
string message = 2;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
using Grpc.Core;
|
||||
using LmxFakeProxy.Bridge;
|
||||
using LmxFakeProxy.Grpc;
|
||||
using LmxFakeProxy.Sessions;
|
||||
|
||||
namespace LmxFakeProxy.Services;
|
||||
|
||||
public class ScadaServiceImpl : ScadaService.ScadaServiceBase
|
||||
{
|
||||
private readonly SessionManager _sessions;
|
||||
private readonly IOpcUaBridge _bridge;
|
||||
private readonly TagMapper _tagMapper;
|
||||
|
||||
public ScadaServiceImpl(SessionManager sessions, IOpcUaBridge bridge, TagMapper tagMapper)
|
||||
{
|
||||
_sessions = sessions;
|
||||
_bridge = bridge;
|
||||
_tagMapper = tagMapper;
|
||||
}
|
||||
|
||||
public override Task<ConnectResponse> Connect(ConnectRequest request, ServerCallContext context)
|
||||
{
|
||||
var (success, message, sessionId) = _sessions.Connect(request.ClientId, request.ApiKey);
|
||||
return Task.FromResult(new ConnectResponse { Success = success, Message = message, SessionId = sessionId });
|
||||
}
|
||||
|
||||
public override Task<DisconnectResponse> Disconnect(DisconnectRequest request, ServerCallContext context)
|
||||
{
|
||||
var ok = _sessions.Disconnect(request.SessionId);
|
||||
return Task.FromResult(new DisconnectResponse
|
||||
{
|
||||
Success = ok,
|
||||
Message = ok ? "Disconnected" : "Session not found"
|
||||
});
|
||||
}
|
||||
|
||||
public override Task<GetConnectionStateResponse> GetConnectionState(
|
||||
GetConnectionStateRequest request, ServerCallContext context)
|
||||
{
|
||||
var (found, clientId, ticks) = _sessions.GetConnectionState(request.SessionId);
|
||||
return Task.FromResult(new GetConnectionStateResponse
|
||||
{
|
||||
IsConnected = found, ClientId = clientId, ConnectedSinceUtcTicks = ticks
|
||||
});
|
||||
}
|
||||
|
||||
public override Task<CheckApiKeyResponse> CheckApiKey(CheckApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
var valid = _sessions.CheckApiKey(request.ApiKey);
|
||||
return Task.FromResult(new CheckApiKeyResponse
|
||||
{
|
||||
IsValid = valid, Message = valid ? "Valid" : "Invalid API key"
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task<ReadResponse> Read(ReadRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
return new ReadResponse { Success = false, Message = "Invalid or expired session" };
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
|
||||
var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
|
||||
return new ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
Vtq = TagMapper.ToVtqMessage(request.Tag, result.Value, result.SourceTimestamp, result.StatusCode)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ReadResponse { Success = false, Message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<ReadBatchResponse> ReadBatch(ReadBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
return new ReadBatchResponse { Success = false, Message = "Invalid or expired session" };
|
||||
|
||||
var response = new ReadBatchResponse { Success = true };
|
||||
foreach (var tag in request.Tags)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nodeId = _tagMapper.ToOpcNodeId(tag);
|
||||
var result = await _bridge.ReadAsync(nodeId, context.CancellationToken);
|
||||
response.Vtqs.Add(TagMapper.ToVtqMessage(tag, result.Value, result.SourceTimestamp, result.StatusCode));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.Vtqs.Add(new VtqMessage
|
||||
{
|
||||
Tag = tag, Value = "", Quality = "Bad", TimestampUtcTicks = DateTime.UtcNow.Ticks
|
||||
});
|
||||
response.Message = ex.Message;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<WriteResponse> Write(WriteRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
return new WriteResponse { Success = false, Message = "Invalid or expired session" };
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = _tagMapper.ToOpcNodeId(request.Tag);
|
||||
var value = TagMapper.ParseWriteValue(request.Value);
|
||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
||||
return statusCode == 0
|
||||
? new WriteResponse { Success = true }
|
||||
: new WriteResponse { Success = false, Message = $"OPC UA write failed: 0x{statusCode:X8}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new WriteResponse { Success = false, Message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<WriteBatchResponse> WriteBatch(WriteBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
return new WriteBatchResponse { Success = false, Message = "Invalid or expired session" };
|
||||
|
||||
var response = new WriteBatchResponse { Success = true };
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
|
||||
var value = TagMapper.ParseWriteValue(item.Value);
|
||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
||||
response.Results.Add(new Grpc.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = statusCode == 0,
|
||||
Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
|
||||
});
|
||||
if (statusCode != 0) response.Success = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.Results.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message });
|
||||
response.Success = false;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<WriteBatchAndWaitResponse> WriteBatchAndWait(
|
||||
WriteBatchAndWaitRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
return new WriteBatchAndWaitResponse { Success = false, Message = "Invalid or expired session" };
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
var writeResults = new List<Grpc.WriteResult>();
|
||||
var allWritesOk = true;
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nodeId = _tagMapper.ToOpcNodeId(item.Tag);
|
||||
var value = TagMapper.ParseWriteValue(item.Value);
|
||||
var statusCode = await _bridge.WriteAsync(nodeId, value, context.CancellationToken);
|
||||
writeResults.Add(new Grpc.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = statusCode == 0,
|
||||
Message = statusCode == 0 ? "" : $"0x{statusCode:X8}"
|
||||
});
|
||||
if (statusCode != 0) allWritesOk = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
writeResults.Add(new Grpc.WriteResult { Tag = item.Tag, Success = false, Message = ex.Message });
|
||||
allWritesOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allWritesOk)
|
||||
{
|
||||
var failResp = new WriteBatchAndWaitResponse { Success = false, Message = "Write failed" };
|
||||
failResp.WriteResults.AddRange(writeResults);
|
||||
return failResp;
|
||||
}
|
||||
|
||||
var flagNodeId = _tagMapper.ToOpcNodeId(request.FlagTag);
|
||||
var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000;
|
||||
var pollMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
|
||||
var deadline = startTime.AddMilliseconds(timeoutMs);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
context.CancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var readResult = await _bridge.ReadAsync(flagNodeId, context.CancellationToken);
|
||||
if (readResult.Value?.ToString() == request.FlagValue)
|
||||
{
|
||||
var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
var resp = new WriteBatchAndWaitResponse { Success = true, FlagReached = true, ElapsedMs = elapsed };
|
||||
resp.WriteResults.AddRange(writeResults);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
await Task.Delay(pollMs, context.CancellationToken);
|
||||
}
|
||||
|
||||
var finalResp = new WriteBatchAndWaitResponse
|
||||
{
|
||||
Success = true, FlagReached = false,
|
||||
ElapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds,
|
||||
Message = "Timeout waiting for flag value"
|
||||
};
|
||||
finalResp.WriteResults.AddRange(writeResults);
|
||||
return finalResp;
|
||||
}
|
||||
|
||||
public override async Task Subscribe(
|
||||
SubscribeRequest request, IServerStreamWriter<VtqMessage> responseStream, ServerCallContext context)
|
||||
{
|
||||
if (!_sessions.ValidateSession(request.SessionId))
|
||||
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid or expired session"));
|
||||
|
||||
var nodeIds = request.Tags.Select(t => _tagMapper.ToOpcNodeId(t)).ToList();
|
||||
var tagByNodeId = request.Tags.Zip(nodeIds).ToDictionary(p => p.Second, p => p.First);
|
||||
|
||||
var handle = await _bridge.AddMonitoredItemsAsync(
|
||||
nodeIds, request.SamplingMs,
|
||||
(nodeId, value, timestamp, statusCode) =>
|
||||
{
|
||||
if (tagByNodeId.TryGetValue(nodeId, out var tag))
|
||||
{
|
||||
var vtq = TagMapper.ToVtqMessage(tag, value, timestamp, statusCode);
|
||||
try { responseStream.WriteAsync(vtq).Wait(); }
|
||||
catch { }
|
||||
}
|
||||
},
|
||||
context.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, context.CancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
finally
|
||||
{
|
||||
await _bridge.RemoveMonitoredItemsAsync(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LmxFakeProxy.Sessions;
|
||||
|
||||
public record SessionInfo(string ClientId, long ConnectedSinceUtcTicks);
|
||||
|
||||
public class SessionManager
|
||||
{
|
||||
private readonly string? _requiredApiKey;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
|
||||
public SessionManager(string? requiredApiKey)
|
||||
{
|
||||
_requiredApiKey = requiredApiKey;
|
||||
}
|
||||
|
||||
public (bool Success, string Message, string SessionId) Connect(string clientId, string apiKey)
|
||||
{
|
||||
if (!CheckApiKey(apiKey))
|
||||
return (false, "Invalid API key", string.Empty);
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
var info = new SessionInfo(clientId, DateTime.UtcNow.Ticks);
|
||||
_sessions[sessionId] = info;
|
||||
return (true, "Connected", sessionId);
|
||||
}
|
||||
|
||||
public bool Disconnect(string sessionId)
|
||||
{
|
||||
return _sessions.TryRemove(sessionId, out _);
|
||||
}
|
||||
|
||||
public bool ValidateSession(string sessionId)
|
||||
{
|
||||
return _sessions.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
public (bool Found, string ClientId, long ConnectedSinceUtcTicks) GetConnectionState(string sessionId)
|
||||
{
|
||||
if (_sessions.TryGetValue(sessionId, out var info))
|
||||
return (true, info.ClientId, info.ConnectedSinceUtcTicks);
|
||||
return (false, string.Empty, 0);
|
||||
}
|
||||
|
||||
public bool CheckApiKey(string apiKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_requiredApiKey))
|
||||
return true;
|
||||
return apiKey == _requiredApiKey;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Text.Json;
|
||||
using LmxFakeProxy.Grpc;
|
||||
|
||||
namespace LmxFakeProxy;
|
||||
|
||||
public class TagMapper
|
||||
{
|
||||
private readonly string _prefix;
|
||||
|
||||
public TagMapper(string prefix)
|
||||
{
|
||||
_prefix = prefix;
|
||||
}
|
||||
|
||||
public string ToOpcNodeId(string lmxTag) => $"{_prefix}{lmxTag}";
|
||||
|
||||
public static object ParseWriteValue(string value)
|
||||
{
|
||||
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var d))
|
||||
return d;
|
||||
if (bool.TryParse(value, out var b))
|
||||
return b;
|
||||
return value;
|
||||
}
|
||||
|
||||
public static string MapQuality(uint statusCode)
|
||||
{
|
||||
if (statusCode == 0) return "Good";
|
||||
if ((statusCode & 0x80000000) != 0) return "Bad";
|
||||
return "Uncertain";
|
||||
}
|
||||
|
||||
public static string FormatValue(object? value)
|
||||
{
|
||||
if (value is null) return string.Empty;
|
||||
if (value is Array or IList)
|
||||
return JsonSerializer.Serialize(value);
|
||||
return value.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
public static VtqMessage ToVtqMessage(string tag, object? value, DateTime timestampUtc, uint statusCode)
|
||||
{
|
||||
return new VtqMessage
|
||||
{
|
||||
Tag = tag,
|
||||
Value = FormatValue(value),
|
||||
TimestampUtcTicks = timestampUtc.Ticks,
|
||||
Quality = MapQuality(statusCode)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
global using Xunit;
|
||||
@@ -1,64 +0,0 @@
|
||||
using ScadaLink.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace LmxFakeProxy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end smoke test connecting RealLmxProxyClient to LmxFakeProxy.
|
||||
/// Requires both OPC UA test server and LmxFakeProxy to be running.
|
||||
/// Run manually: dotnet test --filter "Category=Integration"
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public class IntegrationSmokeTest
|
||||
{
|
||||
private const string Host = "localhost";
|
||||
private const int Port = 50051;
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectReadWriteSubscribe_EndToEnd()
|
||||
{
|
||||
var factory = new RealLmxProxyClientFactory();
|
||||
var client = factory.Create(Host, Port, null);
|
||||
|
||||
try
|
||||
{
|
||||
// Connect
|
||||
await client.ConnectAsync();
|
||||
Assert.True(client.IsConnected);
|
||||
|
||||
// Read initial value
|
||||
var vtq = await client.ReadAsync("Motor.Speed");
|
||||
Assert.Equal(LmxQuality.Good, vtq.Quality);
|
||||
|
||||
// Write a value
|
||||
await client.WriteAsync("Motor.Speed", 42.5);
|
||||
|
||||
// Read back
|
||||
var vtq2 = await client.ReadAsync("Motor.Speed");
|
||||
Assert.Equal(42.5, (double)vtq2.Value!);
|
||||
|
||||
// ReadBatch
|
||||
var batch = await client.ReadBatchAsync(new[] { "Motor.Speed", "Pump.FlowRate" });
|
||||
Assert.Equal(2, batch.Count);
|
||||
|
||||
// Subscribe briefly
|
||||
LmxVtq? lastUpdate = null;
|
||||
var sub = await client.SubscribeAsync(
|
||||
new[] { "Motor.Speed" },
|
||||
(tag, v) => lastUpdate = v);
|
||||
|
||||
// Write to trigger subscription update
|
||||
await client.WriteAsync("Motor.Speed", 99.0);
|
||||
await Task.Delay(2000);
|
||||
|
||||
await sub.DisposeAsync();
|
||||
Assert.NotNull(lastUpdate);
|
||||
|
||||
// Disconnect
|
||||
await client.DisconnectAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using Grpc.Core;
|
||||
using NSubstitute;
|
||||
using LmxFakeProxy.Bridge;
|
||||
using LmxFakeProxy.Grpc;
|
||||
using LmxFakeProxy.Sessions;
|
||||
using LmxFakeProxy.Services;
|
||||
|
||||
namespace LmxFakeProxy.Tests;
|
||||
|
||||
public class ScadaServiceTests
|
||||
{
|
||||
private readonly IOpcUaBridge _mockBridge;
|
||||
private readonly SessionManager _sessionMgr;
|
||||
private readonly TagMapper _tagMapper;
|
||||
private readonly ScadaServiceImpl _service;
|
||||
|
||||
public ScadaServiceTests()
|
||||
{
|
||||
_mockBridge = Substitute.For<IOpcUaBridge>();
|
||||
_mockBridge.IsConnected.Returns(true);
|
||||
_sessionMgr = new SessionManager(null);
|
||||
_tagMapper = new TagMapper("ns=3;s=");
|
||||
_service = new ScadaServiceImpl(_sessionMgr, _mockBridge, _tagMapper);
|
||||
}
|
||||
|
||||
private string ConnectClient(string clientId = "test-client")
|
||||
{
|
||||
var (_, _, sessionId) = _sessionMgr.Connect(clientId, "");
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
private static ServerCallContext MockContext()
|
||||
{
|
||||
return new TestServerCallContext();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ReturnsSessionId()
|
||||
{
|
||||
var resp = await _service.Connect(
|
||||
new ConnectRequest { ClientId = "c1", ApiKey = "" }, MockContext());
|
||||
Assert.True(resp.Success);
|
||||
Assert.NotEmpty(resp.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_ValidSession_ReturnsVtq()
|
||||
{
|
||||
var sid = ConnectClient();
|
||||
_mockBridge.ReadAsync("ns=3;s=Motor.Speed", Arg.Any<CancellationToken>())
|
||||
.Returns(new OpcUaReadResult(42.5, DateTime.UtcNow, 0));
|
||||
|
||||
var resp = await _service.Read(
|
||||
new ReadRequest { SessionId = sid, Tag = "Motor.Speed" }, MockContext());
|
||||
|
||||
Assert.True(resp.Success);
|
||||
Assert.Equal("42.5", resp.Vtq.Value);
|
||||
Assert.Equal("Good", resp.Vtq.Quality);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_InvalidSession_ReturnsFailure()
|
||||
{
|
||||
var resp = await _service.Read(
|
||||
new ReadRequest { SessionId = "bogus", Tag = "Motor.Speed" }, MockContext());
|
||||
Assert.False(resp.Success);
|
||||
Assert.Contains("Invalid", resp.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatch_ReturnsAllTags()
|
||||
{
|
||||
var sid = ConnectClient();
|
||||
_mockBridge.ReadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new OpcUaReadResult(1.0, DateTime.UtcNow, 0));
|
||||
|
||||
var req = new ReadBatchRequest { SessionId = sid };
|
||||
req.Tags.AddRange(new[] { "Motor.Speed", "Pump.FlowRate" });
|
||||
|
||||
var resp = await _service.ReadBatch(req, MockContext());
|
||||
|
||||
Assert.True(resp.Success);
|
||||
Assert.Equal(2, resp.Vtqs.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_ValidSession_Succeeds()
|
||||
{
|
||||
var sid = ConnectClient();
|
||||
_mockBridge.WriteAsync("ns=3;s=Motor.Speed", Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0u);
|
||||
|
||||
var resp = await _service.Write(
|
||||
new WriteRequest { SessionId = sid, Tag = "Motor.Speed", Value = "42.5" }, MockContext());
|
||||
|
||||
Assert.True(resp.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_InvalidSession_ReturnsFailure()
|
||||
{
|
||||
var resp = await _service.Write(
|
||||
new WriteRequest { SessionId = "bogus", Tag = "Motor.Speed", Value = "42.5" }, MockContext());
|
||||
Assert.False(resp.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBatch_ReturnsPerItemResults()
|
||||
{
|
||||
var sid = ConnectClient();
|
||||
_mockBridge.WriteAsync(Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(0u);
|
||||
|
||||
var req = new WriteBatchRequest { SessionId = sid };
|
||||
req.Items.Add(new WriteItem { Tag = "Motor.Speed", Value = "42.5" });
|
||||
req.Items.Add(new WriteItem { Tag = "Pump.FlowRate", Value = "10.0" });
|
||||
|
||||
var resp = await _service.WriteBatch(req, MockContext());
|
||||
|
||||
Assert.True(resp.Success);
|
||||
Assert.Equal(2, resp.Results.Count);
|
||||
Assert.All(resp.Results, r => Assert.True(r.Success));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_Valid_ReturnsTrue()
|
||||
{
|
||||
var resp = await _service.CheckApiKey(
|
||||
new CheckApiKeyRequest { ApiKey = "anything" }, MockContext());
|
||||
Assert.True(resp.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_Invalid_ReturnsFalse()
|
||||
{
|
||||
var mgr = new SessionManager("secret");
|
||||
var svc = new ScadaServiceImpl(mgr, _mockBridge, _tagMapper);
|
||||
|
||||
var resp = await svc.CheckApiKey(
|
||||
new CheckApiKeyRequest { ApiKey = "wrong" }, MockContext());
|
||||
Assert.False(resp.IsValid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal ServerCallContext for unit testing gRPC services.
|
||||
/// </summary>
|
||||
internal class TestServerCallContext : ServerCallContext
|
||||
{
|
||||
protected override string MethodCore => "test";
|
||||
protected override string HostCore => "localhost";
|
||||
protected override string PeerCore => "test-peer";
|
||||
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
||||
protected override Metadata RequestHeadersCore => new();
|
||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
||||
protected override Metadata ResponseTrailersCore => new();
|
||||
protected override Status StatusCore { get; set; }
|
||||
protected override WriteOptions? WriteOptionsCore { get; set; }
|
||||
protected override AuthContext AuthContextCore => new("test", new Dictionary<string, List<AuthProperty>>());
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||
throw new NotImplementedException();
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
namespace LmxFakeProxy.Tests;
|
||||
|
||||
using LmxFakeProxy.Sessions;
|
||||
|
||||
public class SessionManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Connect_ReturnsUniqueSessionId()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (ok1, _, id1) = mgr.Connect("client1", "");
|
||||
var (ok2, _, id2) = mgr.Connect("client2", "");
|
||||
Assert.True(ok1);
|
||||
Assert.True(ok2);
|
||||
Assert.NotEqual(id1, id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_WithValidApiKey_Succeeds()
|
||||
{
|
||||
var mgr = new SessionManager("secret");
|
||||
var (ok, _, _) = mgr.Connect("client1", "secret");
|
||||
Assert.True(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_WithInvalidApiKey_Fails()
|
||||
{
|
||||
var mgr = new SessionManager("secret");
|
||||
var (ok, msg, id) = mgr.Connect("client1", "wrong");
|
||||
Assert.False(ok);
|
||||
Assert.Empty(id);
|
||||
Assert.Contains("Invalid API key", msg);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_WithNoKeyConfigured_AcceptsAnyKey()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (ok1, _, _) = mgr.Connect("c1", "anykey");
|
||||
var (ok2, _, _) = mgr.Connect("c2", "");
|
||||
Assert.True(ok1);
|
||||
Assert.True(ok2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disconnect_RemovesSession()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (_, _, id) = mgr.Connect("client1", "");
|
||||
Assert.True(mgr.ValidateSession(id));
|
||||
var ok = mgr.Disconnect(id);
|
||||
Assert.True(ok);
|
||||
Assert.False(mgr.ValidateSession(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disconnect_UnknownSession_ReturnsFalse()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
Assert.False(mgr.Disconnect("nonexistent"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_ValidId_ReturnsTrue()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (_, _, id) = mgr.Connect("client1", "");
|
||||
Assert.True(mgr.ValidateSession(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateSession_InvalidId_ReturnsFalse()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
Assert.False(mgr.ValidateSession("bogus"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionState_ReturnsCorrectInfo()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (_, _, id) = mgr.Connect("myClient", "");
|
||||
var (found, clientId, ticks) = mgr.GetConnectionState(id);
|
||||
Assert.True(found);
|
||||
Assert.Equal("myClient", clientId);
|
||||
Assert.True(ticks > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConnectionState_UnknownSession_ReturnsNotConnected()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
var (found, clientId, ticks) = mgr.GetConnectionState("unknown");
|
||||
Assert.False(found);
|
||||
Assert.Empty(clientId);
|
||||
Assert.Equal(0, ticks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApiKey_NoKeyConfigured_AlwaysValid()
|
||||
{
|
||||
var mgr = new SessionManager(null);
|
||||
Assert.True(mgr.CheckApiKey("anything"));
|
||||
Assert.True(mgr.CheckApiKey(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckApiKey_WithKeyConfigured_ValidatesCorrectly()
|
||||
{
|
||||
var mgr = new SessionManager("mykey");
|
||||
Assert.True(mgr.CheckApiKey("mykey"));
|
||||
Assert.False(mgr.CheckApiKey("wrong"));
|
||||
Assert.False(mgr.CheckApiKey(""));
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using Xunit;
|
||||
|
||||
namespace LmxFakeProxy.Tests;
|
||||
|
||||
public class TagMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToOpcNodeId_PrependsPrefix()
|
||||
{
|
||||
var mapper = new TagMapper("ns=3;s=");
|
||||
Assert.Equal("ns=3;s=Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOpcNodeId_CustomPrefix()
|
||||
{
|
||||
var mapper = new TagMapper("ns=2;s=MyFolder.");
|
||||
Assert.Equal("ns=2;s=MyFolder.Pump.Pressure", mapper.ToOpcNodeId("Pump.Pressure"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOpcNodeId_EmptyPrefix_PassesThrough()
|
||||
{
|
||||
var mapper = new TagMapper("");
|
||||
Assert.Equal("Motor.Speed", mapper.ToOpcNodeId("Motor.Speed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWriteValue_Double()
|
||||
{
|
||||
Assert.Equal(42.5, TagMapper.ParseWriteValue("42.5"));
|
||||
Assert.IsType<double>(TagMapper.ParseWriteValue("42.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWriteValue_Bool()
|
||||
{
|
||||
Assert.Equal(true, TagMapper.ParseWriteValue("true"));
|
||||
Assert.Equal(false, TagMapper.ParseWriteValue("False"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWriteValue_Uint()
|
||||
{
|
||||
// "100" parses as double first (double.TryParse succeeds for integers)
|
||||
var result = TagMapper.ParseWriteValue("100");
|
||||
Assert.IsType<double>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseWriteValue_FallsBackToString()
|
||||
{
|
||||
Assert.Equal("hello", TagMapper.ParseWriteValue("hello"));
|
||||
Assert.IsType<string>(TagMapper.ParseWriteValue("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatusCode_Good()
|
||||
{
|
||||
Assert.Equal("Good", TagMapper.MapQuality(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatusCode_Bad()
|
||||
{
|
||||
Assert.Equal("Bad", TagMapper.MapQuality(0x80000000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapStatusCode_Uncertain()
|
||||
{
|
||||
Assert.Equal("Uncertain", TagMapper.MapQuality(0x40000000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToVtqMessage_ConvertsCorrectly()
|
||||
{
|
||||
var vtq = TagMapper.ToVtqMessage("Motor.Speed", 42.5, DateTime.UtcNow, 0);
|
||||
Assert.Equal("Motor.Speed", vtq.Tag);
|
||||
Assert.Equal("42.5", vtq.Value);
|
||||
Assert.Equal("Good", vtq.Quality);
|
||||
Assert.True(vtq.TimestampUtcTicks > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
-- ScadaLink design-data seed.
|
||||
-- Auto-generated by infra/tools/dump_seed.py against ScadaLinkConfig.
|
||||
-- Replays the design-time configuration (templates, scripts,
|
||||
-- data connections, external systems). Idempotent: deletes
|
||||
-- existing rows in the covered tables before inserting.
|
||||
--
|
||||
-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,
|
||||
-- InstanceConnectionBindings, notifications, SMTP, API keys,
|
||||
-- areas, LDAP mappings.
|
||||
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
BEGIN TRAN;
|
||||
|
||||
-- Wipe existing design + dependent rows so the seed is idempotent.
|
||||
-- Order matters: dependents first.
|
||||
DELETE FROM DeployedConfigSnapshots;
|
||||
DELETE FROM DeploymentRecords;
|
||||
DELETE FROM InstanceAlarmOverrides;
|
||||
DELETE FROM InstanceAttributeOverrides;
|
||||
DELETE FROM InstanceConnectionBindings;
|
||||
DELETE FROM Instances;
|
||||
DELETE FROM ExternalSystemMethods;
|
||||
DELETE FROM ExternalSystemDefinitions;
|
||||
DELETE FROM DataConnections;
|
||||
DELETE FROM SharedScripts;
|
||||
DELETE FROM TemplateCompositions;
|
||||
UPDATE TemplateAlarms SET OnTriggerScriptId = NULL;
|
||||
DELETE FROM TemplateAlarms;
|
||||
DELETE FROM TemplateScripts;
|
||||
DELETE FROM TemplateAttributes;
|
||||
UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL;
|
||||
DELETE FROM Templates;
|
||||
UPDATE TemplateFolders SET ParentFolderId = NULL;
|
||||
DELETE FROM TemplateFolders;
|
||||
|
||||
-- TemplateFolders (1 rows)
|
||||
SET IDENTITY_INSERT [TemplateFolders] ON;
|
||||
INSERT INTO [TemplateFolders] ([Id], [Name], [ParentFolderId], [SortOrder]) VALUES (1002, N'Test', NULL, 0);
|
||||
SET IDENTITY_INSERT [TemplateFolders] OFF;
|
||||
|
||||
-- Templates (18 rows)
|
||||
SET IDENTITY_INSERT [Templates] ON;
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1, N'Base Device', N'Root template for all devices', NULL, NULL, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2, N'Pump', N'Centrifugal pump template', 1, NULL, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (3, N'Sensor Module', N'Reusable sensor feature module', NULL, 1002, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (4, N'Motor Controller', N'Motor with OPC UA tags from test server', NULL, 1002, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (5, N'Variable Speed Motor', N'VFD motor extending Motor Controller with sensor composition', 4, NULL, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (1002, N'Tank Monitor', N'Tank level and temperature monitoring module', NULL, NULL, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2003, N'Pump.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 1);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2004, N'Variable Speed Motor.TempSensor', N'Reusable sensor feature module', 3, NULL, 1, 2);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2005, N'Motor Controller.CoolingTank', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1002);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2006, N'Motor Controller.CoolingTank2', N'Tank level and temperature monitoring module', 1002, NULL, 1, 1003);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2007, N'aaa', NULL, 3, NULL, 0, NULL);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2008, N'Pump.AlarmSensor', N'Reusable sensor feature module', 3, NULL, 1, 1004);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2012, N'Tank Monitor.DrivePump', N'Centrifugal pump template', 2, NULL, 1, 1008);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2013, N'Tank Monitor.DrivePump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1009);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2014, N'Tank Monitor.DrivePump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1010);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2018, N'Motor Controller.Pump', N'Centrifugal pump template', 2, NULL, 1, 1014);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2019, N'Motor Controller.Pump.TempSensor', N'Reusable sensor feature module', 2003, NULL, 1, 1015);
|
||||
INSERT INTO [Templates] ([Id], [Name], [Description], [ParentTemplateId], [FolderId], [IsDerived], [OwnerCompositionId]) VALUES (2020, N'Motor Controller.Pump.AlarmSensor', N'Reusable sensor feature module', 2008, NULL, 1, 1016);
|
||||
SET IDENTITY_INSERT [Templates] OFF;
|
||||
|
||||
-- TemplateAttributes (48 rows)
|
||||
SET IDENTITY_INSERT [TemplateAttributes] ON;
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'Status', N'Offline', N'String', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2, 1, N'Temperature', N'0.0', N'Double', 0, NULL, N'ns=3;s=Temperature', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (9, 3, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (10, 3, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (11, 5, N'MaxRPM', N'3600', N'Double', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (12, 5, N'MinRPM', N'0', N'Double', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'Weather', N'Unknown', N'String', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'Greeting', N'', N'String', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'Goodbye', N'', N'String', 0, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1005, 1002, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1006, 1002, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1007, 1002, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (1008, 1002, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2009, 4, N'TestBool', NULL, N'Boolean', 0, NULL, N'ns=3;s=TestChildObject.TestBool', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2010, 4, N'TestInt', NULL, N'Int32', 0, NULL, N'ns=3;s=TestChildObject.TestInt', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2011, 4, N'TestFloat', NULL, N'Float', 0, NULL, N'ns=3;s=TestChildObject.TestFloat', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2012, 4, N'TestDouble', NULL, N'Double', 0, NULL, N'ns=3;s=TestChildObject.TestDouble', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2013, 4, N'TestString', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestString', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2014, 4, N'TestDateTime', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTime', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2015, 4, N'TestBoolArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestBoolArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2016, 4, N'TestDateTimeArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDateTimeArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2017, 4, N'TestDoubleArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestDoubleArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2018, 4, N'TestFloatArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestFloatArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2019, 4, N'TestIntArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestIntArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2020, 4, N'TestStringArray', NULL, N'String', 0, NULL, N'ns=3;s=TestChildObject.TestStringArray', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (2021, 4, N'ScanTime', NULL, N'String', 0, NULL, N'ns=3;s=DevAppEngine.Scheduler.ScanTime', 0, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3009, 2003, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3010, 2003, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3011, 2004, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3012, 2004, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3013, 2005, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3014, 2005, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3015, 2005, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3016, 2005, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3017, 2006, N'Level', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Level', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3018, 2006, N'Temperature', N'0', N'Float', 0, NULL, N'ns=3;s=Tank.Temperature', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3019, 2006, N'HighLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.HighLevel', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3020, 2006, N'LowLevel', N'false', N'Boolean', 0, NULL, N'ns=3;s=Tank.LowLevel', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3021, 2008, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3022, 2008, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3025, 2013, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3026, 2013, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3027, 2014, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3028, 2014, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3033, 2019, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3034, 2019, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3035, 2020, N'SensorReading', N'0', N'Double', 0, NULL, N'ns=3;s=Sensor.Reading', 1, 0);
|
||||
INSERT INTO [TemplateAttributes] ([Id], [TemplateId], [Name], [Value], [DataType], [IsLocked], [Description], [DataSourceReference], [IsInherited], [LockedInDerived]) VALUES (3036, 2020, N'SensorUnit', N'Celsius', N'String', 0, NULL, NULL, 1, 0);
|
||||
SET IDENTITY_INSERT [TemplateAttributes] OFF;
|
||||
|
||||
-- TemplateScripts (12 rows)
|
||||
SET IDENTITY_INSERT [TemplateScripts] ON;
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1, 1, N'CheckTemp', 0, N'var temp = Instance.GetAttribute("Temperature");
|
||||
if (temp.Value > 90.0) {
|
||||
Instance.SetAttribute("Status", "HighTemp");
|
||||
}', N'ValueChange', NULL, NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1002, 4, N'TestExternalSystem', 0, N'var parms = new Dictionary<string, object?> { ["a"] = 2, ["b"] = 3 }; var result = await ExternalSystem.Call("Test REST API", "Add", parms); Instance.SetAttribute("Status", "API result: " + result.Response.result);', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1003, 4, N'TestDatabaseQuery', 0, N'var conn = await Database.Connection("Machine Data DB"); var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT COUNT(*) FROM TagHistory"; var count = await cmd.ExecuteScalarAsync(); conn.Dispose(); Instance.SetAttribute("Status", "DB: " + count + " rows");', N'Interval', N'{"intervalMs":60000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1004, 4, N'UpdateWeather', 0, N'var weather = await Scripts.CallShared("GetWeather"); Instance.SetAttribute("Weather", weather?.ToString() ?? "Unknown");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1005, 4, N'UpdateGreeting', 0, N'var parms = new Dictionary<string, object?> { ["name"] = "BOB" }; var greeting = await Scripts.CallShared("Greet", parms); Instance.SetAttribute("Greeting", greeting?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1007, 4, N'SayGoodbye', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Goodbye {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1008, 4, N'UpdateGoodbye', 0, N'var parms = new Dictionary<string, object?> { ["Name"] = "Bob" }; var result = await Instance.CallScript("SayGoodbye", parms); Instance.SetAttribute("Goodbye", result?.ToString() ?? "");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1009, 4, N'Hello', 0, N'var name = (string)(Parameters?["Name"] ?? "World"); return $"Hello {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'Call', N'{}', N'{"type":"object","properties":{"Name":{"type":"string"}},"required":["Name"]}', N'{"type":"string"}', NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1010, 4, N'SendEmailAlert', 0, N'await Notify.To("Engineering Alerts").Send("Motor Status Update", "Motor check-in at " + DateTimeOffset.UtcNow.ToString("HH:mm:ss") + " UTC");', N'Interval', N'{"intervalMs":10000}', NULL, NULL, NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1011, 1002, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 0, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1012, 2005, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0);
|
||||
INSERT INTO [TemplateScripts] ([Id], [TemplateId], [Name], [IsLocked], [Code], [TriggerType], [TriggerConfiguration], [ParameterDefinitions], [ReturnDefinition], [MinTimeBetweenRuns], [IsInherited], [LockedInDerived]) VALUES (1013, 2006, N'AddNumbers', 0, N'var a = Convert.ToDouble(Parameters?["a"] ?? 0); var b = Convert.ToDouble(Parameters?["b"] ?? 0); return a + b;', N'Call', N'{}', N'{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}', N'{"type":"number"}', NULL, 1, 0);
|
||||
SET IDENTITY_INSERT [TemplateScripts] OFF;
|
||||
|
||||
-- TemplateAlarms (4 rows)
|
||||
SET IDENTITY_INSERT [TemplateAlarms] ON;
|
||||
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1, 1, N'HighTemp', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Temperature","high":95.0}', NULL);
|
||||
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1002, 1002, N'HighLevel', NULL, 800, 0, N'RangeViolation', N'{"attribute":"Level","high":80}', NULL);
|
||||
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1003, 2, N'RatePump', NULL, 750, 0, N'RateOfChange', N'{"attributeName":"AlarmSensor.SensorReading","thresholdPerSecond":25,"windowSeconds":2,"direction":"falling"}', NULL);
|
||||
INSERT INTO [TemplateAlarms] ([Id], [TemplateId], [Name], [Description], [PriorityLevel], [IsLocked], [TriggerType], [TriggerConfiguration], [OnTriggerScriptId]) VALUES (1004, 2, N'TempLevels', NULL, 500, 0, N'HiLo', N'{"attributeName":"AlarmSensor.SensorReading","loLo":-10,"lo":5,"hi":80,"hiHi":100,"loLoPriority":900,"loPriority":600,"hiPriority":600,"hiHiPriority":900,"hiDeadband":3,"hiHiDeadband":5,"hiMessage":"Temperature high — investigate","hiHiMessage":"CRITICAL: shut down immediately"}', NULL);
|
||||
SET IDENTITY_INSERT [TemplateAlarms] OFF;
|
||||
|
||||
-- TemplateCompositions (11 rows)
|
||||
SET IDENTITY_INSERT [TemplateCompositions] ON;
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1, 2, 2003, N'TempSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (2, 5, 2004, N'TempSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1002, 4, 2005, N'CoolingTank');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1003, 4, 2006, N'CoolingTank2');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1004, 2, 2008, N'AlarmSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1008, 1002, 2012, N'DrivePump');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1009, 2012, 2013, N'TempSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1010, 2012, 2014, N'AlarmSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1014, 4, 2018, N'Pump');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1015, 2018, 2019, N'TempSensor');
|
||||
INSERT INTO [TemplateCompositions] ([Id], [TemplateId], [ComposedTemplateId], [InstanceName]) VALUES (1016, 2018, 2020, N'AlarmSensor');
|
||||
SET IDENTITY_INSERT [TemplateCompositions] OFF;
|
||||
|
||||
-- SharedScripts (2 rows)
|
||||
SET IDENTITY_INSERT [SharedScripts] ON;
|
||||
INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, N'GetWeather', N'var conditions = new[]
|
||||
{
|
||||
"Sunny",
|
||||
"Cloudy",
|
||||
"Rainy",
|
||||
"Stormy",
|
||||
"Windy",
|
||||
"Foggy",
|
||||
"Snowy",
|
||||
"Clear"
|
||||
};
|
||||
var temps = new Random().Next(-10, 40);
|
||||
var condition = conditions[new Random().Next(conditions.Length)];
|
||||
return $"{condition}, {temps}°C";', NULL, N'{"type":"string"}');
|
||||
INSERT INTO [SharedScripts] ([Id], [Name], [Code], [ParameterDefinitions], [ReturnDefinition]) VALUES (2, N'Greet', N'var name = (string)(Parameters?["name"] ?? "World"); return $"Hello, {name}! It is {DateTimeOffset.UtcNow:HH:mm:ss} UTC";', N'{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', N'{"type":"string"}');
|
||||
SET IDENTITY_INSERT [SharedScripts] OFF;
|
||||
|
||||
-- DataConnections (3 rows)
|
||||
SET IDENTITY_INSERT [DataConnections] ON;
|
||||
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (1, N'OPC PLC Simulator', N'OpcUa', N'{"endpointUrl":"opc.tcp://scadalink-opcua:50000","securityMode":"none","autoAcceptUntrustedCerts":true,"sessionTimeoutMs":60000,"operationTimeoutMs":15000,"publishingIntervalMs":1000,"samplingIntervalMs":1000,"queueSize":10,"keepAliveCount":10,"lifetimeCount":30,"maxNotificationsPerPublish":100,"discardOldest":true,"subscriptionPriority":0,"subscriptionDisplayName":"ScadaLink","timestampsToReturn":"source","deadband":null,"userIdentity":null,"heartbeat":null}', 1, NULL, 3);
|
||||
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3014, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 2, NULL, 3);
|
||||
INSERT INTO [DataConnections] ([Id], [Name], [Protocol], [PrimaryConfiguration], [SiteId], [BackupConfiguration], [FailoverRetryCount]) VALUES (3015, N'OPC PLC Simulator', N'OpcUa', N'{"endpoint":"opc.tcp://scadalink-opcua:50000","securityMode":"None","publishInterval":1000}', 3, NULL, 3);
|
||||
SET IDENTITY_INSERT [DataConnections] OFF;
|
||||
|
||||
-- ExternalSystemDefinitions (1 rows)
|
||||
SET IDENTITY_INSERT [ExternalSystemDefinitions] ON;
|
||||
INSERT INTO [ExternalSystemDefinitions] ([Id], [Name], [EndpointUrl], [AuthType], [AuthConfiguration], [MaxRetries], [RetryDelay]) VALUES (1, N'Test REST API', N'http://scadalink-restapi:5200', N'ApiKey', N'scadalink-test-key-1', 0, '00:00:00.000000');
|
||||
SET IDENTITY_INSERT [ExternalSystemDefinitions] OFF;
|
||||
|
||||
-- ExternalSystemMethods (1 rows)
|
||||
SET IDENTITY_INSERT [ExternalSystemMethods] ON;
|
||||
INSERT INTO [ExternalSystemMethods] ([Id], [ExternalSystemDefinitionId], [Name], [HttpMethod], [Path], [ParameterDefinitions], [ReturnDefinition]) VALUES (1, 1, N'Add', N'POST', N'/api/Add', N'{"a":"number","b":"number"}', N'{"result":"number"}');
|
||||
SET IDENTITY_INSERT [ExternalSystemMethods] OFF;
|
||||
|
||||
COMMIT;
|
||||
@@ -170,6 +170,158 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Folder": "DevAppEngine",
|
||||
"NodeList": [],
|
||||
"FolderList": [
|
||||
{
|
||||
"Folder": "Scheduler",
|
||||
"NodeList": [
|
||||
{
|
||||
"NodeId": "DevAppEngine.Scheduler.ScanTime",
|
||||
"Name": "ScanTime",
|
||||
"DataType": "DateTime",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Current scan time for DevAppEngine"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Folder": "Sensor",
|
||||
"NodeList": [
|
||||
{
|
||||
"NodeId": "Sensor.Reading",
|
||||
"Name": "Reading",
|
||||
"DataType": "Double",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Generic sensor reading"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Folder": "Misc",
|
||||
"NodeList": [
|
||||
{
|
||||
"NodeId": "Temperature",
|
||||
"Name": "Temperature",
|
||||
"DataType": "Double",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Standalone Temperature tag (Base Device default)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Folder": "TestChildObject",
|
||||
"NodeList": [
|
||||
{
|
||||
"NodeId": "TestChildObject.TestBool",
|
||||
"Name": "TestBool",
|
||||
"DataType": "Boolean",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar Boolean"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestBoolArray",
|
||||
"Name": "TestBoolArray",
|
||||
"DataType": "Boolean",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test Boolean array"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestDateTime",
|
||||
"Name": "TestDateTime",
|
||||
"DataType": "DateTime",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar DateTime"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestDateTimeArray",
|
||||
"Name": "TestDateTimeArray",
|
||||
"DataType": "DateTime",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test DateTime array"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestDouble",
|
||||
"Name": "TestDouble",
|
||||
"DataType": "Double",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar Double"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestDoubleArray",
|
||||
"Name": "TestDoubleArray",
|
||||
"DataType": "Double",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test Double array"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestFloat",
|
||||
"Name": "TestFloat",
|
||||
"DataType": "Float",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar Float"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestFloatArray",
|
||||
"Name": "TestFloatArray",
|
||||
"DataType": "Float",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test Float array"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestInt",
|
||||
"Name": "TestInt",
|
||||
"DataType": "Int32",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar Int32"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestIntArray",
|
||||
"Name": "TestIntArray",
|
||||
"DataType": "Int32",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test Int32 array"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestString",
|
||||
"Name": "TestString",
|
||||
"DataType": "String",
|
||||
"ValueRank": -1,
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test scalar String"
|
||||
},
|
||||
{
|
||||
"NodeId": "TestChildObject.TestStringArray",
|
||||
"Name": "TestStringArray",
|
||||
"DataType": "String",
|
||||
"ValueRank": 1,
|
||||
"ArrayDimensions": [4],
|
||||
"AccessLevel": "CurrentReadOrWrite",
|
||||
"Description": "Test String array"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full reseed of the ScadaLink test cluster.
|
||||
#
|
||||
# Tears down infra + app containers, drops the MSSQL volume, brings
|
||||
# everything back, lets EF Core migrations create the schema, replays
|
||||
# infra/mssql/seed-config.sql for templates/scripts/data-connections, and
|
||||
# re-seeds sites via docker/seed-sites.sh.
|
||||
#
|
||||
# Usage:
|
||||
# infra/reseed.sh Full reseed (default seed file)
|
||||
# infra/reseed.sh --seed PATH Replay a different seed SQL
|
||||
# infra/reseed.sh --skip-teardown Replay seed against running stack
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker / OrbStack running
|
||||
# - Python 3 with pymssql (used by infra/tools/mssql_tool.py + dump_seed.py)
|
||||
# - Built scadalink:latest image (docker/build.sh — deploy.sh runs it)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SEED_FILE="$SCRIPT_DIR/mssql/seed-config.sql"
|
||||
SKIP_TEARDOWN=false
|
||||
MGMT_URL="http://localhost:9000"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--seed)
|
||||
SEED_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-teardown)
|
||||
SKIP_TEARDOWN=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$SEED_FILE" ]; then
|
||||
echo "Seed file not found: $SEED_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ScadaLink Reseed ==="
|
||||
echo "Seed file: $SEED_FILE"
|
||||
echo ""
|
||||
|
||||
if ! $SKIP_TEARDOWN; then
|
||||
echo "--- Stage 1/6: tear down application containers ---"
|
||||
"$PROJECT_ROOT/docker/teardown.sh"
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 2/6: wipe site SQLite state ---"
|
||||
shopt -s nullglob
|
||||
for d in "$PROJECT_ROOT"/docker/site-*/data; do
|
||||
rm -rf "$d"/*
|
||||
echo " cleared $d"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 3/6: tear down infra (drops MSSQL volume) ---"
|
||||
(cd "$SCRIPT_DIR" && docker compose down -v)
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 4/6: bring infra back up ---"
|
||||
(cd "$SCRIPT_DIR" && docker compose up -d)
|
||||
|
||||
echo " Waiting for MSSQL to accept connections..."
|
||||
until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -Q "SELECT 1" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
echo " MSSQL ready."
|
||||
|
||||
echo " Waiting for setup.sql to create ScadaLinkConfig..."
|
||||
until docker exec scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P 'ScadaLink_Dev1#' -C \
|
||||
-Q "IF DB_ID('ScadaLinkConfig') IS NULL THROW 50000, 'not ready', 1;" \
|
||||
>/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
echo " ScadaLinkConfig present."
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 5/6: deploy central + site nodes ---"
|
||||
"$PROJECT_ROOT/docker/deploy.sh"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 6a/6: wait for central cluster /health/ready ---"
|
||||
until curl -fs "$MGMT_URL/health/ready" >/dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
echo " Central cluster ready (EF Core migrations applied)."
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 6b/6: seed sites (CLI) ---"
|
||||
# Sites must exist before the design seed: DataConnections.SiteId FKs to Sites.
|
||||
"$PROJECT_ROOT/docker/seed-sites.sh"
|
||||
|
||||
echo ""
|
||||
echo "--- Stage 6c/6: replay seed SQL ---"
|
||||
docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P 'ScadaLink_Dev1#' -C -d ScadaLinkConfig -b < "$SEED_FILE"
|
||||
echo " Seed replayed."
|
||||
|
||||
echo ""
|
||||
echo "=== Reseed complete ==="
|
||||
echo ""
|
||||
echo "Verify:"
|
||||
echo " $PROJECT_ROOT/src/ScadaLink.CLI/bin/Debug/net*/ScadaLink.CLI --url $MGMT_URL --username multi-role --password password template list"
|
||||
echo ""
|
||||
echo "To refresh the seed file from the current DB state:"
|
||||
echo " python3 $SCRIPT_DIR/tools/dump_seed.py --output $SEED_FILE"
|
||||
+11
-1
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tear down ScadaLink test infrastructure.
|
||||
#
|
||||
# Drops the MSSQL data volume by default, so the ScadaLinkConfig DB
|
||||
# (templates, scripts, data connections, etc.) is wiped. Use
|
||||
# infra/reseed.sh afterwards to restore the design state from
|
||||
# infra/mssql/seed-config.sql.
|
||||
#
|
||||
# Usage:
|
||||
# ./teardown.sh Stop containers and delete the SQL data volume
|
||||
# ./teardown.sh --images Also remove downloaded Docker images
|
||||
@@ -44,4 +49,9 @@ fi
|
||||
|
||||
echo ""
|
||||
echo "Teardown complete."
|
||||
echo "To start fresh: docker compose up -d && python tools/mssql_tool.py setup --script mssql/setup.sql"
|
||||
echo ""
|
||||
echo "To restore the full test cluster (infra + app + design seed + sites):"
|
||||
echo " infra/reseed.sh"
|
||||
echo ""
|
||||
echo "To start only infra (no app, no seed):"
|
||||
echo " cd infra && docker compose up -d"
|
||||
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump design tables from ScadaLinkConfig to a replayable SQL seed file.
|
||||
|
||||
Usage:
|
||||
python3 infra/tools/dump_seed.py --output infra/mssql/seed-config.sql
|
||||
|
||||
Tables covered (insert order; reverse for delete):
|
||||
TemplateFolders, Templates, TemplateAttributes, TemplateScripts,
|
||||
TemplateAlarms, TemplateCompositions, SharedScripts, DataConnections,
|
||||
ExternalSystemDefinitions, ExternalSystemMethods
|
||||
|
||||
Excluded by design (per-environment, not design-time): Sites (seeded via
|
||||
seed-sites.sh), Instances + InstanceConnectionBindings + InstanceOverrides,
|
||||
NotificationLists/Recipients, SmtpConfigurations, ApiKeys, Areas,
|
||||
SiteScopeRules, LdapGroupMappings, DataProtectionKeys, audit, deployment.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
import pymssql
|
||||
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 1433
|
||||
DEFAULT_USER = "sa"
|
||||
DEFAULT_PASSWORD = "ScadaLink_Dev1#"
|
||||
DEFAULT_DATABASE = "ScadaLinkConfig"
|
||||
|
||||
INSERT_ORDER = [
|
||||
"TemplateFolders",
|
||||
"Templates",
|
||||
"TemplateAttributes",
|
||||
"TemplateScripts",
|
||||
"TemplateAlarms",
|
||||
"TemplateCompositions",
|
||||
"SharedScripts",
|
||||
"DataConnections",
|
||||
"ExternalSystemDefinitions",
|
||||
"ExternalSystemMethods",
|
||||
]
|
||||
|
||||
# Identity columns get IDENTITY_INSERT wrapped around inserts and are kept in
|
||||
# the column list. All listed tables happen to use Id as their identity.
|
||||
IDENTITY_TABLES = set(INSERT_ORDER)
|
||||
|
||||
# Templates has self-FK Templates.ParentTemplateId; emit a single batch that
|
||||
# inserts shallow rows first then deeper ones. pymssql returns rows in Id order
|
||||
# from our ORDER BY, which matches insertion order for this schema (parent Id
|
||||
# is always less than child Id in the live data).
|
||||
|
||||
|
||||
def quote(value):
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return "0x" + value.hex()
|
||||
if isinstance(value, datetime.datetime):
|
||||
return "'" + value.isoformat(sep=" ", timespec="microseconds") + "'"
|
||||
if isinstance(value, datetime.date):
|
||||
return "'" + value.isoformat() + "'"
|
||||
if isinstance(value, datetime.time):
|
||||
return "'" + value.isoformat(timespec="microseconds") + "'"
|
||||
if isinstance(value, datetime.timedelta):
|
||||
total = value.total_seconds()
|
||||
hours, rem = divmod(int(total), 3600)
|
||||
minutes, seconds = divmod(rem, 60)
|
||||
micros = value.microseconds
|
||||
return "'{:02d}:{:02d}:{:02d}.{:06d}'".format(hours, minutes, seconds, micros)
|
||||
text = str(value).replace("'", "''")
|
||||
return "N'" + text + "'"
|
||||
|
||||
|
||||
def get_columns(cursor, table):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = %s
|
||||
ORDER BY ORDINAL_POSITION
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def dump(args):
|
||||
conn = pymssql.connect(
|
||||
server=args.host,
|
||||
port=args.port,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
out = []
|
||||
out.append("-- ScadaLink design-data seed.")
|
||||
out.append("-- Auto-generated by infra/tools/dump_seed.py against " + args.database + ".")
|
||||
out.append("-- Replays the design-time configuration (templates, scripts,")
|
||||
out.append("-- data connections, external systems). Idempotent: deletes")
|
||||
out.append("-- existing rows in the covered tables before inserting.")
|
||||
out.append("--")
|
||||
out.append("-- Excluded: Sites (seed via docker/seed-sites.sh), Instances,")
|
||||
out.append("-- InstanceConnectionBindings, notifications, SMTP, API keys,")
|
||||
out.append("-- areas, LDAP mappings.")
|
||||
out.append("")
|
||||
out.append("SET NOCOUNT ON;")
|
||||
out.append("SET XACT_ABORT ON;")
|
||||
# sqlcmd defaults QUOTED_IDENTIFIER OFF; EF Core's filtered indexes
|
||||
# and computed columns require ON, so force it here.
|
||||
out.append("SET QUOTED_IDENTIFIER ON;")
|
||||
out.append("BEGIN TRAN;")
|
||||
out.append("")
|
||||
|
||||
# Wipe in reverse FK order. Beyond the design tables themselves, we also
|
||||
# clear instance + deployment rows because they FK to Templates and
|
||||
# DataConnections; without this, an idempotent replay against a populated
|
||||
# DB fails on the FK to DataConnections. On a fresh reseed (after
|
||||
# teardown.sh) these tables are already empty so the DELETEs are no-ops.
|
||||
out.append("-- Wipe existing design + dependent rows so the seed is idempotent.")
|
||||
out.append("-- Order matters: dependents first.")
|
||||
delete_order = [
|
||||
# Dependents on Instances / DataConnections / Sites.
|
||||
"DeployedConfigSnapshots",
|
||||
"DeploymentRecords",
|
||||
"InstanceAlarmOverrides",
|
||||
"InstanceAttributeOverrides",
|
||||
"InstanceConnectionBindings",
|
||||
"Instances",
|
||||
# Design tables themselves.
|
||||
"ExternalSystemMethods",
|
||||
"ExternalSystemDefinitions",
|
||||
"DataConnections",
|
||||
"SharedScripts",
|
||||
"TemplateCompositions",
|
||||
# Alarms reference scripts via OnTriggerScriptId; null it first so we
|
||||
# can delete scripts without FK violations.
|
||||
"UPDATE TemplateAlarms SET OnTriggerScriptId = NULL",
|
||||
"TemplateAlarms",
|
||||
"TemplateScripts",
|
||||
"TemplateAttributes",
|
||||
# Templates is self-referential and references TemplateCompositions
|
||||
# (OwnerCompositionId); null parent links first.
|
||||
"UPDATE Templates SET ParentTemplateId = NULL, OwnerCompositionId = NULL",
|
||||
"Templates",
|
||||
# Folders is self-referential too.
|
||||
"UPDATE TemplateFolders SET ParentFolderId = NULL",
|
||||
"TemplateFolders",
|
||||
]
|
||||
for step in delete_order:
|
||||
if step.startswith("UPDATE "):
|
||||
out.append(step + ";")
|
||||
else:
|
||||
out.append("DELETE FROM " + step + ";")
|
||||
out.append("")
|
||||
|
||||
for table in INSERT_ORDER:
|
||||
columns = get_columns(cursor, table)
|
||||
if not columns:
|
||||
print("Skipping {} (no columns found)".format(table), file=sys.stderr)
|
||||
continue
|
||||
|
||||
# Order by Id so self-referential rows insert in dependency order
|
||||
# (in the live data, parent Id < child Id by construction).
|
||||
order_clause = "ORDER BY Id" if "Id" in columns else ""
|
||||
cursor.execute(
|
||||
"SELECT [{}] FROM [{}] {}".format("], [".join(columns), table, order_clause)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
out.append("-- " + table + " (" + str(len(rows)) + " rows)")
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
col_list = ", ".join("[" + c + "]" for c in columns)
|
||||
identity = table in IDENTITY_TABLES
|
||||
if identity:
|
||||
out.append("SET IDENTITY_INSERT [{}] ON;".format(table))
|
||||
for row in rows:
|
||||
values = ", ".join(quote(v) for v in row)
|
||||
out.append(
|
||||
"INSERT INTO [{}] ({}) VALUES ({});".format(table, col_list, values)
|
||||
)
|
||||
if identity:
|
||||
out.append("SET IDENTITY_INSERT [{}] OFF;".format(table))
|
||||
out.append("")
|
||||
|
||||
out.append("COMMIT;")
|
||||
out.append("")
|
||||
|
||||
sql = "\n".join(out)
|
||||
with open(args.output, "w") as f:
|
||||
f.write(sql)
|
||||
|
||||
print("Wrote " + args.output + " (" + str(sum(1 for line in out if line.startswith('INSERT'))) + " inserts).")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default=DEFAULT_HOST)
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument("--user", default=DEFAULT_USER)
|
||||
parser.add_argument("--password", default=DEFAULT_PASSWORD)
|
||||
parser.add_argument("--database", default=DEFAULT_DATABASE)
|
||||
parser.add_argument("--output", required=True, help="Path to write seed SQL")
|
||||
args = parser.parse_args()
|
||||
dump(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick smoke test: verify Playwright can reach the Central UI through Traefik."""
|
||||
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# The browser runs inside Docker, so use the Docker network hostname for Traefik.
|
||||
# The Playwright server WebSocket is exposed to the host on port 3000.
|
||||
TRAEFIK_URL = "http://scadalink-traefik"
|
||||
PLAYWRIGHT_WS = "ws://localhost:3000"
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
print(f"Connecting to Playwright server at {PLAYWRIGHT_WS} ...")
|
||||
browser = p.chromium.connect(PLAYWRIGHT_WS)
|
||||
|
||||
page = browser.new_page()
|
||||
print(f"Navigating to {TRAEFIK_URL} ...")
|
||||
response = page.goto(TRAEFIK_URL, wait_until="networkidle", timeout=15000)
|
||||
|
||||
status = response.status if response else None
|
||||
title = page.title()
|
||||
url = page.url
|
||||
|
||||
print(f" Status: {status}")
|
||||
print(f" Title: {title}")
|
||||
print(f" URL: {url}")
|
||||
|
||||
# Check for the login page (unauthenticated users get redirected)
|
||||
has_login = page.locator("input[type='password'], form[action*='login'], button:has-text('Login'), button:has-text('Sign in')").count() > 0
|
||||
if has_login:
|
||||
print(" Login form detected: YES")
|
||||
|
||||
browser.close()
|
||||
|
||||
if status and 200 <= status < 400:
|
||||
print("\nSMOKE TEST PASSED: Central UI is reachable through Traefik.")
|
||||
return 0
|
||||
else:
|
||||
print(f"\nSMOKE TEST FAILED: unexpected status {status}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -15,8 +15,6 @@ public static class DataConnectionCommands
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAssign(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildUnassign(urlOption, formatOption, usernameOption, passwordOption));
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -40,69 +38,73 @@ public static class DataConnectionCommands
|
||||
var idOption = new Option<int>("--id") { Description = "Data connection ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var protocolOption = new Option<string>("--protocol") { Description = "Protocol", Required = true };
|
||||
var configOption = new Option<string?>("--configuration") { Description = "Configuration JSON" };
|
||||
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
|
||||
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
|
||||
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update a data connection" };
|
||||
cmd.Add(idOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(protocolOption);
|
||||
cmd.Add(configOption);
|
||||
cmd.Add(backupConfigOption);
|
||||
cmd.Add(failoverRetryOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var protocol = result.GetValue(protocolOption)!;
|
||||
var config = result.GetValue(configOption);
|
||||
var backupConfig = result.GetValue(backupConfigOption);
|
||||
var failoverRetryCount = result.GetValue(failoverRetryOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateDataConnectionCommand(id, name, protocol, config));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildUnassign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--assignment-id") { Description = "Assignment ID", Required = true };
|
||||
var cmd = new Command("unassign") { Description = "Unassign a data connection from a site" };
|
||||
cmd.Add(idOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new UnassignDataConnectionFromSiteCommand(id));
|
||||
new UpdateDataConnectionCommand(id, name, protocol, config, backupConfig, failoverRetryCount));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("list") { Description = "List all data connections" };
|
||||
var siteIdOption = new Option<int?>("--site-id") { Description = "Filter by site ID" };
|
||||
var cmd = new Command("list") { Description = "List data connections" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand());
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new ListDataConnectionsCommand(siteId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildCreate(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
var nameOption = new Option<string>("--name") { Description = "Connection name", Required = true };
|
||||
var protocolOption = new Option<string>("--protocol") { Description = "Protocol (e.g. OpcUa)", Required = true };
|
||||
var configOption = new Option<string?>("--configuration") { Description = "Connection configuration JSON" };
|
||||
var configOption = new Option<string?>("--primary-config", "--configuration") { Description = "Primary configuration JSON" };
|
||||
var backupConfigOption = new Option<string?>("--backup-config") { Description = "Backup configuration JSON" };
|
||||
var failoverRetryOption = new Option<int>("--failover-retry-count") { Description = "Number of retries before failover to backup", DefaultValueFactory = _ => 3 };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new data connection" };
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.Add(nameOption);
|
||||
cmd.Add(protocolOption);
|
||||
cmd.Add(configOption);
|
||||
cmd.Add(backupConfigOption);
|
||||
cmd.Add(failoverRetryOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
var name = result.GetValue(nameOption)!;
|
||||
var protocol = result.GetValue(protocolOption)!;
|
||||
var config = result.GetValue(configOption);
|
||||
var backupConfig = result.GetValue(backupConfigOption);
|
||||
var failoverRetryCount = result.GetValue(failoverRetryOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateDataConnectionCommand(name, protocol, config));
|
||||
new CreateDataConnectionCommand(siteId, name, protocol, config, backupConfig, failoverRetryCount));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
@@ -120,23 +122,4 @@ public static class DataConnectionCommands
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAssign(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var connectionIdOption = new Option<int>("--connection-id") { Description = "Data connection ID", Required = true };
|
||||
var siteIdOption = new Option<int>("--site-id") { Description = "Site ID", Required = true };
|
||||
|
||||
var cmd = new Command("assign") { Description = "Assign a data connection to a site" };
|
||||
cmd.Add(connectionIdOption);
|
||||
cmd.Add(siteIdOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var connectionId = result.GetValue(connectionIdOption);
|
||||
var siteId = result.GetValue(siteIdOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new AssignDataConnectionToSiteCommand(connectionId, siteId));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public static class InstanceCommands
|
||||
command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetBindings(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetOverrides(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildAlarmOverride(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSetArea(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDiff(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildDeploy(urlOption, formatOption, usernameOption, passwordOption));
|
||||
@@ -51,10 +52,10 @@ public static class InstanceCommands
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
var bindingsJson = result.GetValue(bindingsOption)!;
|
||||
var pairs = System.Text.Json.JsonSerializer.Deserialize<List<List<object>>>(bindingsJson)
|
||||
var pairs = System.Text.Json.JsonSerializer.Deserialize<List<List<System.Text.Json.JsonElement>>>(bindingsJson)
|
||||
?? throw new InvalidOperationException("Invalid bindings JSON");
|
||||
var bindings = pairs.Select(p =>
|
||||
(p[0].ToString()!, int.Parse(p[1].ToString()!))).ToList();
|
||||
(p[0].GetString()!, p[1].GetInt32())).ToList();
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetConnectionBindingsCommand(id, bindings));
|
||||
@@ -186,6 +187,59 @@ public static class InstanceCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildAlarmOverride(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var group = new Command("alarm-override") { Description = "Manage per-instance alarm overrides" };
|
||||
|
||||
// set
|
||||
var setIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var setAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name (e.g., 'TempLevels' or 'Pump.TempSensor.Heat')", Required = true };
|
||||
var setConfigOption = new Option<string?>("--trigger-config") { Description = "JSON override for TriggerConfiguration (HiLo: partial merge; others: whole-replace)" };
|
||||
var setPriorityOption = new Option<int?>("--priority") { Description = "Priority override (0-1000)" };
|
||||
var setCmd = new Command("set") { Description = "Set (upsert) an alarm override on an instance" };
|
||||
setCmd.Add(setIdOption); setCmd.Add(setAlarmOption); setCmd.Add(setConfigOption); setCmd.Add(setPriorityOption);
|
||||
setCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new SetInstanceAlarmOverrideCommand(
|
||||
result.GetValue(setIdOption),
|
||||
result.GetValue(setAlarmOption)!,
|
||||
result.GetValue(setConfigOption),
|
||||
result.GetValue(setPriorityOption)));
|
||||
});
|
||||
group.Add(setCmd);
|
||||
|
||||
// delete
|
||||
var delIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var delAlarmOption = new Option<string>("--alarm") { Description = "Alarm canonical name", Required = true };
|
||||
var delCmd = new Command("delete") { Description = "Remove an alarm override on an instance" };
|
||||
delCmd.Add(delIdOption); delCmd.Add(delAlarmOption);
|
||||
delCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new DeleteInstanceAlarmOverrideCommand(
|
||||
result.GetValue(delIdOption),
|
||||
result.GetValue(delAlarmOption)!));
|
||||
});
|
||||
group.Add(delCmd);
|
||||
|
||||
// list
|
||||
var listIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||
var listCmd = new Command("list") { Description = "List all alarm overrides for an instance" };
|
||||
listCmd.Add(listIdOption);
|
||||
listCmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new ListInstanceAlarmOverridesCommand(result.GetValue(listIdOption)));
|
||||
});
|
||||
group.Add(listCmd);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static Command BuildSetArea(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
|
||||
|
||||
@@ -53,6 +53,8 @@ public static class SiteCommands
|
||||
var descOption = new Option<string?>("--description") { Description = "Site description" };
|
||||
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
|
||||
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
|
||||
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
|
||||
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
|
||||
|
||||
var cmd = new Command("create") { Description = "Create a new site" };
|
||||
cmd.Add(nameOption);
|
||||
@@ -60,6 +62,8 @@ public static class SiteCommands
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(nodeAOption);
|
||||
cmd.Add(nodeBOption);
|
||||
cmd.Add(grpcNodeAOption);
|
||||
cmd.Add(grpcNodeBOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var name = result.GetValue(nameOption)!;
|
||||
@@ -67,9 +71,11 @@ public static class SiteCommands
|
||||
var desc = result.GetValue(descOption);
|
||||
var nodeA = result.GetValue(nodeAOption);
|
||||
var nodeB = result.GetValue(nodeBOption);
|
||||
var grpcNodeA = result.GetValue(grpcNodeAOption);
|
||||
var grpcNodeB = result.GetValue(grpcNodeBOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB));
|
||||
new CreateSiteCommand(name, identifier, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
@@ -81,6 +87,8 @@ public static class SiteCommands
|
||||
var descOption = new Option<string?>("--description") { Description = "Site description" };
|
||||
var nodeAOption = new Option<string?>("--node-a-address") { Description = "Akka address for Node A" };
|
||||
var nodeBOption = new Option<string?>("--node-b-address") { Description = "Akka address for Node B" };
|
||||
var grpcNodeAOption = new Option<string?>("--grpc-node-a-address") { Description = "gRPC address for Node A" };
|
||||
var grpcNodeBOption = new Option<string?>("--grpc-node-b-address") { Description = "gRPC address for Node B" };
|
||||
|
||||
var cmd = new Command("update") { Description = "Update an existing site" };
|
||||
cmd.Add(idOption);
|
||||
@@ -88,6 +96,8 @@ public static class SiteCommands
|
||||
cmd.Add(descOption);
|
||||
cmd.Add(nodeAOption);
|
||||
cmd.Add(nodeBOption);
|
||||
cmd.Add(grpcNodeAOption);
|
||||
cmd.Add(grpcNodeBOption);
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
var id = result.GetValue(idOption);
|
||||
@@ -95,9 +105,11 @@ public static class SiteCommands
|
||||
var desc = result.GetValue(descOption);
|
||||
var nodeA = result.GetValue(nodeAOption);
|
||||
var nodeB = result.GetValue(nodeBOption);
|
||||
var grpcNodeA = result.GetValue(grpcNodeAOption);
|
||||
var grpcNodeB = result.GetValue(grpcNodeBOption);
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||
new UpdateSiteCommand(id, name, desc, nodeA, nodeB));
|
||||
new UpdateSiteCommand(id, name, desc, nodeA, nodeB, grpcNodeA, grpcNodeB));
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
+17
-36
@@ -603,22 +603,27 @@ scadalink --url <url> data-connection get --id <int>
|
||||
|
||||
#### `data-connection list`
|
||||
|
||||
List all configured data connections.
|
||||
List data connections, optionally filtered by site.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection list
|
||||
```
|
||||
|
||||
#### `data-connection create`
|
||||
|
||||
Create a new data connection definition.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection create --name <string> --protocol <string> [--configuration <json>]
|
||||
scadalink --url <url> data-connection list [--site-id <int>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--site-id` | no | Filter by site ID |
|
||||
|
||||
#### `data-connection create`
|
||||
|
||||
Create a new data connection belonging to a specific site.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection create --site-id <int> --name <string> --protocol <string> [--configuration <json>]
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--site-id` | yes | Site ID the connection belongs to |
|
||||
| `--name` | yes | Connection name |
|
||||
| `--protocol` | yes | Protocol identifier (e.g. `OpcUa`) |
|
||||
| `--configuration` | no | Protocol-specific configuration as a JSON string |
|
||||
@@ -650,32 +655,6 @@ scadalink --url <url> data-connection delete --id <int>
|
||||
|--------|----------|-------------|
|
||||
| `--id` | yes | Data connection ID |
|
||||
|
||||
#### `data-connection assign`
|
||||
|
||||
Assign a data connection to a site.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection assign --connection-id <int> --site-id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--connection-id` | yes | Data connection ID |
|
||||
| `--site-id` | yes | Site ID |
|
||||
|
||||
#### `data-connection unassign`
|
||||
|
||||
Remove a data connection assignment from a site.
|
||||
|
||||
```sh
|
||||
scadalink --url <url> data-connection unassign --connection-id <int> --site-id <int>
|
||||
```
|
||||
|
||||
| Option | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `--connection-id` | yes | Data connection ID |
|
||||
| `--site-id` | yes | Site ID |
|
||||
|
||||
---
|
||||
|
||||
### `external-system` — Manage external HTTP systems
|
||||
@@ -1260,6 +1239,8 @@ scadalink --url <url> api-method update --id <int> [--name <string>] [--code <st
|
||||
| `--code` | no | Updated script source code (or `@filepath`) |
|
||||
| `--description` | no | Updated description |
|
||||
|
||||
Script changes take effect immediately — the updated code is recompiled in-memory on the active central node. No restart is required.
|
||||
|
||||
#### `api-method delete`
|
||||
|
||||
Delete an inbound API method.
|
||||
|
||||
@@ -44,12 +44,15 @@ public static class AuthEndpoints
|
||||
// Map LDAP groups to roles
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(30);
|
||||
|
||||
// Build claims from LDAP auth + role mapping
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, authResult.Username ?? username),
|
||||
new(JwtTokenService.DisplayNameClaimType, authResult.DisplayName ?? username),
|
||||
new(JwtTokenService.UsernameClaimType, authResult.Username ?? username),
|
||||
new("expires_at", expiresAt.ToUnixTimeSeconds().ToString()),
|
||||
};
|
||||
|
||||
foreach (var role in roleMappingResult.Roles)
|
||||
@@ -74,12 +77,53 @@ public static class AuthEndpoints
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30)
|
||||
ExpiresUtc = expiresAt
|
||||
});
|
||||
|
||||
context.Response.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
endpoints.MapPost("/auth/token", async (HttpContext context) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<LdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password);
|
||||
if (!authResult.Success)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = authResult.ErrorMessage ?? "Authentication failed." },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []);
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
authResult.DisplayName ?? username,
|
||||
authResult.Username ?? username,
|
||||
roleMappingResult.Roles,
|
||||
roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds);
|
||||
|
||||
return Results.Json(new
|
||||
{
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
username = authResult.Username ?? username,
|
||||
display_name = authResult.DisplayName ?? username,
|
||||
roles = roleMappingResult.Roles,
|
||||
});
|
||||
}).DisableAntiforgery();
|
||||
|
||||
endpoints.MapPost("/auth/logout", async (HttpContext context) =>
|
||||
{
|
||||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
@namespace ScadaLink.CentralUI.Components.Forms
|
||||
@using ScadaLink.Commons.Types.DataConnections
|
||||
@using ScadaLink.Commons.Types.Flattening
|
||||
|
||||
<div class="opcua-endpoint-editor">
|
||||
<h6 class="text-muted border-bottom pb-1">@Title</h6>
|
||||
|
||||
@if (IsLegacy)
|
||||
{
|
||||
<div class="alert alert-warning py-1 small mb-2">
|
||||
This connection was migrated from a legacy format.
|
||||
Review the settings and Save to update.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small">Endpoint URL</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
@bind="Config.EndpointUrl"
|
||||
placeholder="opc.tcp://host:4840" />
|
||||
@RenderFieldError("EndpointUrl")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Security Mode</label>
|
||||
<select class="form-select form-select-sm" @bind="Config.SecurityMode">
|
||||
<option value="@OpcUaSecurityMode.None">None</option>
|
||||
<option value="@OpcUaSecurityMode.Sign">Sign</option>
|
||||
<option value="@OpcUaSecurityMode.SignAndEncrypt">Sign & Encrypt</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@($"{IdPrefix}-autoaccept")"
|
||||
@bind="Config.AutoAcceptUntrustedCerts" />
|
||||
<label class="form-check-label small"
|
||||
for="@($"{IdPrefix}-autoaccept")">Auto-accept certs</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Authentication</div>
|
||||
@if (Config.UserIdentity is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@onclick="EnableAuthentication">Enable Authentication</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Token type</label>
|
||||
<select class="form-select form-select-sm" @bind="Config.UserIdentity.TokenType">
|
||||
<option value="@OpcUaUserTokenType.Anonymous">Anonymous</option>
|
||||
<option value="@OpcUaUserTokenType.UsernamePassword">Username / Password</option>
|
||||
<option value="@OpcUaUserTokenType.X509Certificate">X.509 Certificate</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (Config.UserIdentity.TokenType == OpcUaUserTokenType.UsernamePassword)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Username</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
@bind="Config.UserIdentity.Username" />
|
||||
@RenderFieldError("UserIdentity.Username")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Password</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
@bind="Config.UserIdentity.Password" />
|
||||
</div>
|
||||
}
|
||||
else if (Config.UserIdentity.TokenType == OpcUaUserTokenType.X509Certificate)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Certificate path</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
@bind="Config.UserIdentity.CertificatePath"
|
||||
placeholder="/etc/scadalink/pki/client.pfx" />
|
||||
@RenderFieldError("UserIdentity.CertificatePath")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Certificate password</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
@bind="Config.UserIdentity.CertificatePassword" />
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => Config.UserIdentity = null">
|
||||
Remove Authentication
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Timing</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Session timeout (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.SessionTimeoutMs" min="1" />
|
||||
@RenderFieldError("SessionTimeoutMs")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Operation timeout (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.OperationTimeoutMs" min="1" />
|
||||
@RenderFieldError("OperationTimeoutMs")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Subscription</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Publishing interval (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.PublishingIntervalMs" min="1" />
|
||||
@RenderFieldError("PublishingIntervalMs")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Sampling interval (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.SamplingIntervalMs" min="1" />
|
||||
@RenderFieldError("SamplingIntervalMs")
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Queue size</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.QueueSize" min="1" />
|
||||
@RenderFieldError("QueueSize")
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Keep-alive count</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.KeepAliveCount" min="1" />
|
||||
@RenderFieldError("KeepAliveCount")
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Lifetime count</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.LifetimeCount" min="1" />
|
||||
@RenderFieldError("LifetimeCount")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Max notifications / publish</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.MaxNotificationsPerPublish" min="1" />
|
||||
@RenderFieldError("MaxNotificationsPerPublish")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Subscription display name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
@bind="Config.SubscriptionDisplayName" />
|
||||
@RenderFieldError("SubscriptionDisplayName")
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Subscription priority</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.SubscriptionPriority" min="0" max="255" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Timestamps to return</label>
|
||||
<select class="form-select form-select-sm" @bind="Config.TimestampsToReturn">
|
||||
<option value="@OpcUaTimestampsToReturn.Source">Source</option>
|
||||
<option value="@OpcUaTimestampsToReturn.Server">Server</option>
|
||||
<option value="@OpcUaTimestampsToReturn.Both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="@($"{IdPrefix}-discardoldest")"
|
||||
@bind="Config.DiscardOldest" />
|
||||
<label class="form-check-label small"
|
||||
for="@($"{IdPrefix}-discardoldest")">Discard oldest</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
|
||||
@if (Config.Deadband is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@onclick="EnableDeadband">Enable Deadband</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Type</label>
|
||||
<select class="form-select form-select-sm" @bind="Config.Deadband.Type">
|
||||
<option value="@OpcUaDeadbandType.Absolute">Absolute</option>
|
||||
<option value="@OpcUaDeadbandType.Percent">Percent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Value</label>
|
||||
<input type="number" step="0.01" class="form-control form-control-sm"
|
||||
@bind="Config.Deadband.Value" min="0" />
|
||||
@RenderFieldError("Deadband.Value")
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => Config.Deadband = null">
|
||||
Remove Deadband
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
|
||||
@if (Config.Heartbeat is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@onclick="EnableHeartbeat">Enable Heartbeat</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Tag path</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
@bind="Config.Heartbeat.TagPath"
|
||||
placeholder="Sensors.Heartbeat" />
|
||||
@RenderFieldError("Heartbeat.TagPath")
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Max silence (s)</label>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
@bind="Config.Heartbeat.MaxSilenceSeconds" min="1" />
|
||||
@RenderFieldError("Heartbeat.MaxSilenceSeconds")
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => Config.Heartbeat = null">
|
||||
Remove Heartbeat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public OpcUaEndpointConfig Config { get; set; } = default!;
|
||||
[Parameter] public string Title { get; set; } = "Endpoint";
|
||||
[Parameter] public string IdPrefix { get; set; } = "endpoint";
|
||||
[Parameter] public bool IsLegacy { get; set; }
|
||||
[Parameter] public ValidationResult? Errors { get; set; }
|
||||
|
||||
private void EnableHeartbeat() =>
|
||||
Config.Heartbeat = new OpcUaHeartbeatConfig();
|
||||
|
||||
private void EnableAuthentication() =>
|
||||
Config.UserIdentity = new OpcUaUserIdentityConfig();
|
||||
|
||||
private void EnableDeadband() =>
|
||||
Config.Deadband = new OpcUaDeadbandConfig();
|
||||
|
||||
private RenderFragment? RenderFieldError(string field)
|
||||
{
|
||||
var match = Errors?.Errors.FirstOrDefault(e =>
|
||||
e.EntityName != null
|
||||
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
|
||||
return match is null
|
||||
? null
|
||||
: @<div class="text-danger small">@match.Message</div>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@* Minimal layout for the login page: no nav sidebar, no session-expiry
|
||||
watchdog, no dialog host. The page renders its own centred card. *@
|
||||
@Body
|
||||
@@ -1,8 +1,29 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex">
|
||||
<NavMenu />
|
||||
<main class="flex-grow-1 p-3" style="min-height: 100vh; background-color: #f8f9fa;">
|
||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
||||
@* Hamburger toggle: visible only on viewports <lg.
|
||||
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#sidebar-collapse"
|
||||
aria-controls="sidebar-collapse"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
|
||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main class="flex-grow-1 p-3" style="background-color: #f8f9fa;">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@* Global host for IDialogService. One instance per layout renders all confirm/prompt
|
||||
dialogs raised via IDialogService.ConfirmAsync / PromptAsync. *@
|
||||
<DialogHost />
|
||||
|
||||
<SessionExpiry />
|
||||
|
||||
@@ -3,90 +3,92 @@
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand">ScadaLink</div>
|
||||
|
||||
<ul class="nav flex-column flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<li class="nav-section-header">Admin</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/data-connections">Data Connections</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<div role="presentation" class="nav-section-header">Admin</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<li class="nav-section-header">Design</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<div role="presentation" class="nav-section-header">Design</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/connections">Connections</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<li class="nav-section-header">Deployment</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<div role="presentation" class="nav-section-header">Deployment</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/topology">Topology</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Monitoring — visible to all authenticated users *@
|
||||
<li class="nav-section-header">Monitoring</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||
</li>
|
||||
@* Monitoring — visible to all authenticated users *@
|
||||
<div role="presentation" class="nav-section-header">Monitoring</div>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||
</li>
|
||||
|
||||
@* Audit Log — Admin only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="auditContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
@* Audit Log — Admin only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="auditContext">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
@page "/admin/api-keys/create"
|
||||
@page "/admin/api-keys/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.InboundApi
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2"
|
||||
aria-label="Back to API Keys">← Back</a>
|
||||
<span class="text-muted me-2">·</span>
|
||||
<h4 class="mb-0">
|
||||
@if (_saved)
|
||||
{
|
||||
@:API Key Created
|
||||
}
|
||||
else if (IsEditMode)
|
||||
{
|
||||
@:Edit API Key
|
||||
}
|
||||
else
|
||||
{
|
||||
@:Add API Key
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_saved && _newlyCreatedKeyValue != null)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<strong>New API Key Created</strong>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<code class="me-2">@_newlyCreatedKeyValue</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
|
||||
</div>
|
||||
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodIds.Contains(method.Id)"
|
||||
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool IsEditMode => _editingKey != null;
|
||||
|
||||
private ApiKey? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private string? _newlyCreatedKeyValue;
|
||||
private bool _loading = true;
|
||||
private bool _saved;
|
||||
|
||||
private List<ApiMethod> _allMethods = new();
|
||||
private HashSet<int> _initialMethodIds = new();
|
||||
private HashSet<int> _selectedMethodIds = new();
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
|
||||
if (_editingKey == null)
|
||||
{
|
||||
_errorMessage = $"API key with ID {Id.Value} not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_formName = _editingKey.Name;
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
_initialMethodIds = _allMethods
|
||||
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToHashSet();
|
||||
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load API key: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SaveKey()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingKey != null)
|
||||
{
|
||||
_editingKey.Name = _formName.Trim();
|
||||
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
|
||||
|
||||
var changedIds = _selectedMethodIds
|
||||
.Except(_initialMethodIds)
|
||||
.Concat(_initialMethodIds.Except(_selectedMethodIds))
|
||||
.ToHashSet();
|
||||
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
|
||||
{
|
||||
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
|
||||
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
|
||||
else ids.Remove(_editingKey.Id);
|
||||
method.ApprovedApiKeyIds = ids.Count == 0
|
||||
? null
|
||||
: string.Join(",", ids.OrderBy(x => x));
|
||||
await InboundApiRepository.UpdateApiMethodAsync(method);
|
||||
}
|
||||
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/api-keys");
|
||||
}
|
||||
else
|
||||
{
|
||||
var keyValue = GenerateApiKey();
|
||||
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
|
||||
await InboundApiRepository.AddApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_newlyCreatedKeyValue = keyValue;
|
||||
_saved = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
||||
|
||||
private void ToggleMethod(int methodId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedMethodIds.Add(methodId);
|
||||
else _selectedMethodIds.Remove(methodId);
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private async Task CopyKeyToClipboard()
|
||||
{
|
||||
if (_newlyCreatedKeyValue == null) return;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,16 @@
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">API Key Management</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add API Key</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/api-keys/create")'>Add API Key</button>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -24,95 +25,75 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_showForm)
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (_keys.Count == 0)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_editingKey == null ? "Add New API Key" : "Edit API Key")</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
<p class="text-muted text-center">No API keys configured.</p>
|
||||
}
|
||||
else if (!FilteredKeys.Any())
|
||||
{
|
||||
<p class="text-muted small">No API keys match the filter.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Value</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var key in FilteredKeys)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_formError</div>
|
||||
<tr @key="key.Id">
|
||||
<td>@key.Id</td>
|
||||
<td>
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="@($"More actions for {key.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => ToggleKey(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteKey(key)">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_newlyCreatedKeyValue != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<strong>New API Key Created</strong>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<code class="me-2">@_newlyCreatedKeyValue</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
|
||||
<button type="button" class="btn-close" @onclick="() => _newlyCreatedKeyValue = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Value</th>
|
||||
<th>Status</th>
|
||||
<th style="width: 240px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_keys.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var key in _keys)
|
||||
{
|
||||
<tr>
|
||||
<td>@key.Id</td>
|
||||
<td>@key.Name</td>
|
||||
<td><code>@MaskKeyValue(key.KeyValue)</code></td>
|
||||
<td>
|
||||
@if (key.IsEnabled)
|
||||
{
|
||||
<span class="badge bg-success">Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditKey(key)">Edit</button>
|
||||
@if (key.IsEnabled)
|
||||
{
|
||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleKey(key)">Disable</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleKey(key)">Enable</button>
|
||||
}
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteKey(key)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -120,15 +101,15 @@
|
||||
private List<ApiKey> _keys = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private bool _showForm;
|
||||
private ApiKey? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _newlyCreatedKeyValue;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
private IEnumerable<ApiKey> FilteredKeys =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _keys
|
||||
: _keys.Where(k =>
|
||||
k.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -156,63 +137,6 @@
|
||||
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingKey = null;
|
||||
_formName = string.Empty;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void EditKey(ApiKey key)
|
||||
{
|
||||
_editingKey = key;
|
||||
_formName = key.Name;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void CancelForm()
|
||||
{
|
||||
_showForm = false;
|
||||
_editingKey = null;
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private async Task SaveKey()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingKey != null)
|
||||
{
|
||||
_editingKey.Name = _formName.Trim();
|
||||
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var keyValue = GenerateApiKey();
|
||||
var key = new ApiKey(_formName.Trim(), keyValue)
|
||||
{
|
||||
IsEnabled = true
|
||||
};
|
||||
await InboundApiRepository.AddApiKeyAsync(key);
|
||||
_newlyCreatedKeyValue = keyValue;
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_showForm = false;
|
||||
_editingKey = null;
|
||||
_toast.ShowSuccess("API key saved.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleKey(ApiKey key)
|
||||
{
|
||||
try
|
||||
@@ -230,8 +154,10 @@
|
||||
|
||||
private async Task DeleteKey(ApiKey key)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Delete API key '{key.Name}'? This cannot be undone.", "Delete API Key");
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete API Key",
|
||||
$"Delete API key '{key.Name}'? This cannot be undone.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
@@ -247,18 +173,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyKeyToClipboard()
|
||||
{
|
||||
// Note: JS interop for clipboard would be needed for actual copy.
|
||||
// For now the key is displayed for manual copy.
|
||||
_toast.ShowInfo("Key displayed above. Select and copy manually.");
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
@page "/admin/areas"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Area Management</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Sites</h6>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<div class="list-group-item text-muted small">No sites configured.</div>
|
||||
}
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action @(site.Id == _selectedSiteId ? "active" : "")"
|
||||
@onclick="() => SelectSite(site.Id)">
|
||||
@site.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
@if (_selectedSiteId == 0)
|
||||
{
|
||||
<div class="text-muted">Select a site to manage its areas.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="mb-0">Areas for @(_sites.FirstOrDefault(s => s.Id == _selectedSiteId)?.Name)</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Area</button>
|
||||
</div>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_editingArea == null ? "Add New Area" : "Edit Area")</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
@if (_editingArea == null)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Parent Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_formParentAreaId">
|
||||
<option value="0">(Root level)</option>
|
||||
@foreach (var area in _areas)
|
||||
{
|
||||
<option value="@area.Id">@GetAreaPath(area)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveArea">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_formError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_areas.Count == 0)
|
||||
{
|
||||
<div class="text-muted">No areas configured for this site.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
@foreach (var node in BuildFlatTree())
|
||||
{
|
||||
<div class="d-flex align-items-center py-1 border-bottom"
|
||||
style="padding-left: @(node.Depth * 24 + 8)px;">
|
||||
<span class="me-2 text-muted small">
|
||||
@(node.HasChildren ? "[+]" : " -")
|
||||
</span>
|
||||
<span class="flex-grow-1">@node.Area.Name</span>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditArea(node.Area)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteArea(node.Area)">Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<Site> _sites = new();
|
||||
private List<Area> _areas = new();
|
||||
private int _selectedSiteId;
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private bool _showForm;
|
||||
private Area? _editingArea;
|
||||
private string _formName = string.Empty;
|
||||
private int _formParentAreaId;
|
||||
private string? _formError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load sites: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task SelectSite(int siteId)
|
||||
{
|
||||
_selectedSiteId = siteId;
|
||||
_showForm = false;
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAreasAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_areas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_selectedSiteId)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load areas: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private record AreaTreeNode(Area Area, int Depth, bool HasChildren);
|
||||
|
||||
private List<AreaTreeNode> BuildFlatTree()
|
||||
{
|
||||
var result = new List<AreaTreeNode>();
|
||||
AddChildren(null, 0, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void AddChildren(int? parentId, int depth, List<AreaTreeNode> result)
|
||||
{
|
||||
var children = _areas.Where(a => a.ParentAreaId == parentId).OrderBy(a => a.Name);
|
||||
foreach (var child in children)
|
||||
{
|
||||
var hasChildren = _areas.Any(a => a.ParentAreaId == child.Id);
|
||||
result.Add(new AreaTreeNode(child, depth, hasChildren));
|
||||
AddChildren(child.Id, depth + 1, result);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAreaPath(Area area)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
var current = area;
|
||||
while (current != null)
|
||||
{
|
||||
parts.Insert(0, current.Name);
|
||||
current = current.ParentAreaId.HasValue
|
||||
? _areas.FirstOrDefault(a => a.Id == current.ParentAreaId.Value)
|
||||
: null;
|
||||
}
|
||||
return string.Join(" / ", parts);
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingArea = null;
|
||||
_formName = string.Empty;
|
||||
_formParentAreaId = 0;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void EditArea(Area area)
|
||||
{
|
||||
_editingArea = area;
|
||||
_formName = area.Name;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void CancelForm()
|
||||
{
|
||||
_showForm = false;
|
||||
_editingArea = null;
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private async Task SaveArea()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingArea != null)
|
||||
{
|
||||
_editingArea.Name = _formName.Trim();
|
||||
await TemplateEngineRepository.UpdateAreaAsync(_editingArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
var area = new Area(_formName.Trim())
|
||||
{
|
||||
SiteId = _selectedSiteId,
|
||||
ParentAreaId = _formParentAreaId == 0 ? null : _formParentAreaId
|
||||
};
|
||||
await TemplateEngineRepository.AddAreaAsync(area);
|
||||
}
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_showForm = false;
|
||||
_editingArea = null;
|
||||
_toast.ShowSuccess("Area saved.");
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteArea(Area area)
|
||||
{
|
||||
var hasChildren = _areas.Any(a => a.ParentAreaId == area.Id);
|
||||
var message = hasChildren
|
||||
? $"Area '{area.Name}' has child areas. Delete child areas first."
|
||||
: $"Delete area '{area.Name}'?";
|
||||
|
||||
var confirmed = await _confirmDialog.ShowAsync(message, "Delete Area");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await TemplateEngineRepository.DeleteAreaAsync(area.Id);
|
||||
await TemplateEngineRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Area '{area.Name}' deleted.");
|
||||
await LoadAreasAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
@page "/admin/data-connections"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Data Connections</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Connection</button>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_editingConnection == null ? "Add New Connection" : "Edit Connection")</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Protocol</label>
|
||||
<select class="form-select form-select-sm" @bind="_formProtocol">
|
||||
<option value="">Select...</option>
|
||||
<option value="OpcUa">OPC UA</option>
|
||||
<option value="LmxProxy">LMX Proxy</option>
|
||||
<option value="Custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Configuration (JSON)</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formConfiguration"
|
||||
placeholder='e.g. {"endpoint":"opc.tcp://..."}' />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_formError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Assignment form *@
|
||||
@if (_showAssignForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Assign Connection to Site</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Connection</label>
|
||||
<select class="form-select form-select-sm" @bind="_assignConnectionId">
|
||||
<option value="0">Select connection...</option>
|
||||
@foreach (var conn in _connections)
|
||||
{
|
||||
<option value="@conn.Id">@conn.Name (@conn.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_assignSiteId">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveAssignment">Assign</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelAssignForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_assignError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_assignError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-2">
|
||||
<button class="btn btn-outline-info btn-sm" @onclick="ShowAssignForm">Assign to Site</button>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Protocol</th>
|
||||
<th>Configuration</th>
|
||||
<th>Assigned Sites</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_connections.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-center">No data connections configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var conn in _connections)
|
||||
{
|
||||
<tr>
|
||||
<td>@conn.Id</td>
|
||||
<td>@conn.Name</td>
|
||||
<td><span class="badge bg-secondary">@conn.Protocol</span></td>
|
||||
<td class="text-muted small text-truncate" style="max-width: 300px;">@(conn.Configuration ?? "—")</td>
|
||||
<td>
|
||||
@{
|
||||
var assignedSites = _connectionSites.GetValueOrDefault(conn.Id);
|
||||
}
|
||||
@if (assignedSites != null && assignedSites.Count > 0)
|
||||
{
|
||||
@foreach (var assignment in assignedSites)
|
||||
{
|
||||
var siteName = _sites.FirstOrDefault(s => s.Id == assignment.SiteId)?.Name ?? $"Site {assignment.SiteId}";
|
||||
<span class="badge bg-info text-dark me-1">
|
||||
@siteName
|
||||
<button type="button" class="btn-close btn-close-white ms-1"
|
||||
style="font-size: 0.5rem;"
|
||||
@onclick="() => RemoveAssignment(assignment)"></button>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">None</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditConnection(conn)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteConnection(conn)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<DataConnection> _connections = new();
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, List<SiteDataConnectionAssignment>> _connectionSites = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private bool _showForm;
|
||||
private DataConnection? _editingConnection;
|
||||
private string _formName = string.Empty;
|
||||
private string _formProtocol = string.Empty;
|
||||
private string? _formConfiguration;
|
||||
private string? _formError;
|
||||
|
||||
private bool _showAssignForm;
|
||||
private int _assignConnectionId;
|
||||
private int _assignSiteId;
|
||||
private string? _assignError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
||||
|
||||
// Load site assignments for each connection
|
||||
_connectionSites.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
|
||||
foreach (var conn in siteConns)
|
||||
{
|
||||
if (!_connectionSites.ContainsKey(conn.Id))
|
||||
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
|
||||
|
||||
var assignment = await SiteRepository.GetSiteDataConnectionAssignmentAsync(site.Id, conn.Id);
|
||||
if (assignment != null)
|
||||
_connectionSites[conn.Id].Add(assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load data: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingConnection = null;
|
||||
_formName = string.Empty;
|
||||
_formProtocol = string.Empty;
|
||||
_formConfiguration = null;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void EditConnection(DataConnection conn)
|
||||
{
|
||||
_editingConnection = conn;
|
||||
_formName = conn.Name;
|
||||
_formProtocol = conn.Protocol;
|
||||
_formConfiguration = conn.Configuration;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void CancelForm()
|
||||
{
|
||||
_showForm = false;
|
||||
_editingConnection = null;
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private async Task SaveConnection()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_formProtocol)) { _formError = "Protocol is required."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_editingConnection.Name = _formName.Trim();
|
||||
_editingConnection.Protocol = _formProtocol;
|
||||
_editingConnection.Configuration = _formConfiguration?.Trim();
|
||||
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
|
||||
}
|
||||
else
|
||||
{
|
||||
var conn = new DataConnection(_formName.Trim(), _formProtocol)
|
||||
{
|
||||
Configuration = _formConfiguration?.Trim()
|
||||
};
|
||||
await SiteRepository.AddDataConnectionAsync(conn);
|
||||
}
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_showForm = false;
|
||||
_toast.ShowSuccess("Connection saved.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteConnection(DataConnection conn)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Delete data connection '{conn.Name}'?", "Delete Connection");
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowAssignForm()
|
||||
{
|
||||
_assignConnectionId = 0;
|
||||
_assignSiteId = 0;
|
||||
_assignError = null;
|
||||
_showAssignForm = true;
|
||||
}
|
||||
|
||||
private void CancelAssignForm()
|
||||
{
|
||||
_showAssignForm = false;
|
||||
_assignError = null;
|
||||
}
|
||||
|
||||
private async Task SaveAssignment()
|
||||
{
|
||||
_assignError = null;
|
||||
if (_assignConnectionId == 0) { _assignError = "Select a connection."; return; }
|
||||
if (_assignSiteId == 0) { _assignError = "Select a site."; return; }
|
||||
|
||||
try
|
||||
{
|
||||
var assignment = new SiteDataConnectionAssignment
|
||||
{
|
||||
SiteId = _assignSiteId,
|
||||
DataConnectionId = _assignConnectionId
|
||||
};
|
||||
await SiteRepository.AddSiteDataConnectionAssignmentAsync(assignment);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_showAssignForm = false;
|
||||
_toast.ShowSuccess("Connection assigned to site.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_assignError = $"Assignment failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAssignment(SiteDataConnectionAssignment assignment)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SiteRepository.DeleteSiteDataConnectionAssignmentAsync(assignment.Id);
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess("Assignment removed.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Remove failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
@page "/admin/ldap-mappings/create"
|
||||
@page "/admin/ldap-mappings/{Id:int}/edit"
|
||||
@using ScadaLink.Commons.Entities.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISecurityRepository SecurityRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
aria-label="Back to LDAP mappings"
|
||||
@onclick="GoBack">
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Mapping</h5>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">LDAP Group Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
</select>
|
||||
<div class="form-text">Deployment role: configure site scope below after saving.</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Site Scope Rules</h5>
|
||||
|
||||
@if (!IsEditMode)
|
||||
{
|
||||
<p class="text-muted small mb-0">Save the mapping first to configure site scope.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_scopeRules.Count > 0)
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
@foreach (var rule in _scopeRules)
|
||||
{
|
||||
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
|
||||
<span class="badge bg-info text-dark d-inline-flex align-items-center">
|
||||
@siteName
|
||||
<button type="button"
|
||||
class="btn-close btn-close-white ms-2"
|
||||
style="font-size: 0.6rem;"
|
||||
aria-label="@($"Remove scope rule for {siteName}")"
|
||||
@onclick="() => DeleteScopeRule(rule)"></button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-3">All sites (no restrictions)</p>
|
||||
}
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_scopeRuleSiteId">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add scope rule</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_scopeRuleError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_scopeRuleError</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool IsEditMode => Id.HasValue;
|
||||
|
||||
private LdapGroupMapping? _editingMapping;
|
||||
private string _formGroupName = string.Empty;
|
||||
private string _formRole = string.Empty;
|
||||
private string? _formError;
|
||||
|
||||
private List<SiteScopeRule> _scopeRules = new();
|
||||
private List<Site> _sites = new();
|
||||
private Dictionary<int, Site> _siteLookup = new();
|
||||
private int _scopeRuleSiteId;
|
||||
private string? _scopeRuleError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_siteLookup = _sites.ToDictionary(s => s.Id);
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
_editingMapping = await SecurityRepository.GetMappingByIdAsync(Id.Value);
|
||||
if (_editingMapping != null)
|
||||
{
|
||||
_formGroupName = _editingMapping.LdapGroupName;
|
||||
_formRole = _editingMapping.Role;
|
||||
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/admin/ldap-mappings");
|
||||
}
|
||||
|
||||
private async Task SaveMapping()
|
||||
{
|
||||
_formError = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_formGroupName))
|
||||
{
|
||||
_formError = "LDAP Group Name is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(_formRole))
|
||||
{
|
||||
_formError = "Role is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingMapping != null)
|
||||
{
|
||||
_editingMapping.LdapGroupName = _formGroupName.Trim();
|
||||
_editingMapping.Role = _formRole;
|
||||
await SecurityRepository.UpdateMappingAsync(_editingMapping);
|
||||
}
|
||||
else
|
||||
{
|
||||
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
|
||||
await SecurityRepository.AddMappingAsync(mapping);
|
||||
}
|
||||
|
||||
await SecurityRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/admin/ldap-mappings");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddScopeRule()
|
||||
{
|
||||
_scopeRuleError = null;
|
||||
|
||||
if (_scopeRuleSiteId <= 0)
|
||||
{
|
||||
_scopeRuleError = "Select a site to add a scope rule.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
|
||||
await SecurityRepository.AddScopeRuleAsync(rule);
|
||||
await SecurityRepository.SaveChangesAsync();
|
||||
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
|
||||
_scopeRuleSiteId = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scopeRuleError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteScopeRule(SiteScopeRule rule)
|
||||
{
|
||||
var siteName = _siteLookup.GetValueOrDefault(rule.SiteId)?.Name ?? $"Site {rule.SiteId}";
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Remove Scope Rule",
|
||||
$"Remove scope rule for '{siteName}'?",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
try
|
||||
{
|
||||
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
|
||||
await SecurityRepository.SaveChangesAsync();
|
||||
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_scopeRuleError = $"Delete failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user