Compare commits
225 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 |
@@ -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.
|
||||
@@ -160,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,7 +34,7 @@ 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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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/"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -68,6 +71,8 @@ Central cluster only. Sites have no user interface.
|
||||
### Site & Data Connection Management (Admin Role)
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -156,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -258,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.
|
||||
|
||||
@@ -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`.
|
||||
@@ -60,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**.
|
||||
@@ -105,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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
@@ -7,11 +7,11 @@ This document describes the local Docker-based test infrastructure for ScadaLink
|
||||
| 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/` |
|
||||
|
||||
@@ -43,10 +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`:
|
||||
@@ -66,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,
|
||||
@@ -88,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):
|
||||
@@ -113,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
|
||||
|
||||
|
||||
+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
|
||||
```
|
||||
|
||||
|
||||
+21
-14
@@ -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,20 +95,6 @@ services:
|
||||
- scadalink-net
|
||||
restart: unless-stopped
|
||||
|
||||
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
|
||||
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
container_name: scadalink-playwright
|
||||
|
||||
@@ -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,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>LmxFakeProxy.Tests</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Grpc.Core" Version="2.46.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../LmxFakeProxy.csproj" />
|
||||
<ProjectReference Include="../../../../src/ScadaLink.DataConnectionLayer/ScadaLink.DataConnectionLayer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
|
||||
+15
-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
|
||||
|
||||
@@ -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,7 +77,7 @@ public static class AuthEndpoints
|
||||
new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(30)
|
||||
ExpiresUtc = expiresAt
|
||||
});
|
||||
|
||||
context.Response.Redirect("/");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,20 +6,31 @@
|
||||
@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">
|
||||
@if (_saved)
|
||||
{
|
||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">← Back to API Keys</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/admin/api-keys" class="btn btn-outline-secondary btn-sm me-2">← Back</a>
|
||||
}
|
||||
<h4 class="mb-0">@(_saved ? "API Key Created" : (_editingKey != null ? "Edit API Key" : "Add API Key"))</h4>
|
||||
<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" />
|
||||
@@ -42,21 +53,48 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
<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="text-danger small mt-2">@_formError</div>
|
||||
<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 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>
|
||||
}
|
||||
@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>
|
||||
@@ -64,6 +102,8 @@
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool IsEditMode => _editingKey != null;
|
||||
|
||||
private ApiKey? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
@@ -72,6 +112,12 @@
|
||||
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
|
||||
@@ -86,6 +132,12 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +159,22 @@
|
||||
{
|
||||
_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");
|
||||
}
|
||||
@@ -128,10 +196,34 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
||||
|
||||
private void CopyKeyToClipboard()
|
||||
private void ToggleMethod(int methodId, bool isChecked)
|
||||
{
|
||||
// Note: JS interop for clipboard would be needed for actual copy.
|
||||
// For now the key is displayed for manual copy.
|
||||
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()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@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">
|
||||
@@ -13,7 +14,6 @@
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -25,59 +25,75 @@
|
||||
}
|
||||
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>Status</th>
|
||||
<th style="width: 240px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_keys.Count == 0)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<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>
|
||||
<td colspan="5" class="text-muted text-center">No API keys configured.</td>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Value</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</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='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var key in FilteredKeys)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -85,9 +101,15 @@
|
||||
private List<ApiKey> _keys = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
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()
|
||||
{
|
||||
@@ -132,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
|
||||
|
||||
@@ -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,123 +0,0 @@
|
||||
@page "/admin/data-connections/create"
|
||||
@page "/admin/data-connections/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
<div class="mb-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="mb-2">
|
||||
<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>
|
||||
@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="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private DataConnection? _editingConnection;
|
||||
private string _formName = string.Empty;
|
||||
private string _formProtocol = string.Empty;
|
||||
private string? _formConfiguration;
|
||||
private string? _formError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (Id.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_formName = _editingConnection.Name;
|
||||
_formProtocol = _editingConnection.Protocol;
|
||||
_formConfiguration = _editingConnection.Configuration;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Failed to load connection: {ex.Message}";
|
||||
}
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
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();
|
||||
NavigationManager.NavigateTo("/admin/data-connections");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack()
|
||||
{
|
||||
NavigationManager.NavigateTo("/admin/data-connections");
|
||||
}
|
||||
}
|
||||
@@ -1,256 +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
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<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='() => NavigationManager.NavigateTo("/admin/data-connections/create")'>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
|
||||
{
|
||||
@* 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='() => NavigationManager.NavigateTo($"/admin/data-connections/{conn.Id}/edit")'>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 _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 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
@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" @onclick="GoBack">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
aria-label="Back to LDAP mappings"
|
||||
@onclick="GoBack">
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(IsEditMode ? "Edit LDAP Mapping" : "Add LDAP Mapping")</h6>
|
||||
<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" />
|
||||
@@ -31,6 +34,7 @@
|
||||
<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)
|
||||
{
|
||||
@@ -43,56 +47,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsEditMode && _formRole.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Site Scope Rules</h6>
|
||||
<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)
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover mb-3">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Site ID</th>
|
||||
<th style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var rule in _scopeRules)
|
||||
{
|
||||
<tr>
|
||||
<td>@rule.Id</td>
|
||||
<td>@rule.SiteId</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteScopeRule(rule)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<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="mb-2">
|
||||
<label class="form-label small">Site ID</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
|
||||
<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 class="mt-3">
|
||||
<button class="btn btn-success btn-sm" @onclick="AddScopeRule">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -106,13 +114,16 @@
|
||||
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;
|
||||
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
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);
|
||||
@@ -174,7 +185,7 @@
|
||||
|
||||
if (_scopeRuleSiteId <= 0)
|
||||
{
|
||||
_scopeRuleError = "Site ID must be a positive number.";
|
||||
_scopeRuleError = "Select a site to add a scope rule.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,9 +205,11 @@
|
||||
|
||||
private async Task DeleteScopeRule(SiteScopeRule rule)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Delete scope rule for Site {rule.SiteId}? This cannot be undone.",
|
||||
"Delete Scope 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
|
||||
|
||||
@@ -22,61 +22,75 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Mappings table *@
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>LDAP Group Name</th>
|
||||
<th>Role</th>
|
||||
<th>Site Scope Rules</th>
|
||||
<th style="width: 200px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_mappings.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center">No mappings configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var mapping in _mappings)
|
||||
{
|
||||
<tr>
|
||||
<td>@mapping.Id</td>
|
||||
<td>@mapping.LdapGroupName</td>
|
||||
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
||||
<td>
|
||||
@{
|
||||
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
|
||||
}
|
||||
@if (rules != null && rules.Count > 0)
|
||||
{
|
||||
@foreach (var rule in rules)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">Site @rule.SiteId</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">All sites</span>
|
||||
}
|
||||
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<span class="text-muted small ms-2">(manage on edit page)</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name, LDAP group, or role…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (_mappings.Count == 0)
|
||||
{
|
||||
<p class="text-muted text-center">No mappings configured.</p>
|
||||
}
|
||||
else if (!FilteredMappings.Any())
|
||||
{
|
||||
<p class="text-muted small">No mappings match the filter.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>LDAP Group Name</th>
|
||||
<th>Role</th>
|
||||
<th>Site Scope</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var mapping in FilteredMappings)
|
||||
{
|
||||
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
|
||||
var ruleCount = rules?.Count ?? 0;
|
||||
<tr @key="mapping.Id">
|
||||
<td>@mapping.Id</td>
|
||||
<td>@mapping.LdapGroupName</td>
|
||||
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
||||
<td>
|
||||
@if (ruleCount > 0)
|
||||
{
|
||||
<span class="badge bg-info text-dark">@ruleCount rule(s)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">All sites</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.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 {mapping.LdapGroupName}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteMapping(mapping.Id)">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -85,6 +99,14 @@
|
||||
private Dictionary<int, List<SiteScopeRule>> _scopeRules = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private IEnumerable<LdapGroupMapping> FilteredMappings =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _mappings
|
||||
: _mappings.Where(m =>
|
||||
(m.LdapGroupName?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(m.Role?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@
|
||||
@page "/admin/sites"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@@ -10,25 +11,38 @@
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@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">Site Management</h4>
|
||||
<div>
|
||||
<button class="btn btn-outline-warning btn-sm me-1" @onclick="DeployArtifactsToAllSites"
|
||||
disabled="@_deploying">
|
||||
@if (_deploying)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
Deploy Artifacts to All Sites
|
||||
<div class="d-flex gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
data-bs-toggle="dropdown" disabled="@_deploying">
|
||||
@if (_deploying)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
Bulk actions
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="DeployArtifactsToAllSites">
|
||||
Deploy Artifacts to All Sites
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
||||
+ Add Site
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>Add Site</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -38,70 +52,109 @@
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else if (_sites.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No sites configured.</p>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo("/admin/sites/create")'>
|
||||
Add your first site
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Identifier</th>
|
||||
<th>Description</th>
|
||||
<th>Node A</th>
|
||||
<th>Node B</th>
|
||||
<th>gRPC Node A</th>
|
||||
<th>gRPC Node B</th>
|
||||
<th>Data Connections</th>
|
||||
<th style="width: 260px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_sites.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="10" class="text-muted text-center">No sites configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<tr>
|
||||
<td>@site.Id</td>
|
||||
<td>@site.Name</td>
|
||||
<td><code>@site.SiteIdentifier</code></td>
|
||||
<td class="text-muted small">@(site.Description ?? "—")</td>
|
||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeAAddress">@(site.NodeAAddress ?? "—")</td>
|
||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.NodeBAddress">@(site.NodeBAddress ?? "—")</td>
|
||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeAAddress">@(site.GrpcNodeAAddress ?? "—")</td>
|
||||
<td class="small text-truncate" style="max-width: 200px;" title="@site.GrpcNodeBAddress">@(site.GrpcNodeBAddress ?? "—")</td>
|
||||
<td>
|
||||
@{
|
||||
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
||||
}
|
||||
@if (conns != null && conns.Count > 0)
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="Filter by name or identifier…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (!FilteredSites.Any())
|
||||
{
|
||||
<p class="text-muted small">No sites match the filter.</p>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (var site in FilteredSites)
|
||||
{
|
||||
var conns = _siteConnections.GetValueOrDefault(site.Id);
|
||||
var collapseId = $"cluster-{site.Id}";
|
||||
<div class="col-lg-6 col-12" @key="site.Id">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">@site.Name</h5>
|
||||
<code class="small">@site.SiteIdentifier</code>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown" aria-label="More actions">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => DeployArtifacts(site)"
|
||||
disabled="@_deploying">
|
||||
Deploy Artifacts
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteSite(site)">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mb-3">
|
||||
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
|
||||
</p>
|
||||
|
||||
<div class="small text-muted mb-1">Data connections</div>
|
||||
@if (conns is { Count: > 0 })
|
||||
{
|
||||
@foreach (var conn in conns)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">@conn.Name (@conn.Protocol)</span>
|
||||
}
|
||||
<ul class="list-unstyled mb-3">
|
||||
@foreach (var c in conns)
|
||||
{
|
||||
<li class="mb-1">
|
||||
<span class="badge bg-info text-dark me-1">@c.Protocol</span>
|
||||
@c.Name
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">None</span>
|
||||
<p class="text-muted small fst-italic mb-3">No connections.</p>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/sites/{site.Id}/edit")'>Edit</button>
|
||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => DeployArtifacts(site)"
|
||||
disabled="@_deploying">Deploy Artifacts</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteSite(site)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="@($"#{collapseId}")"
|
||||
aria-expanded="false">
|
||||
Cluster nodes (Akka, gRPC)
|
||||
</button>
|
||||
<div class="collapse mt-2" id="@collapseId">
|
||||
@ClusterRow("Node A", site.NodeAAddress)
|
||||
@ClusterRow("Node B", site.NodeBAddress)
|
||||
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
|
||||
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -118,9 +171,16 @@
|
||||
private string? _errorMessage;
|
||||
|
||||
private bool _deploying;
|
||||
private string _search = "";
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
private IEnumerable<Site> FilteredSites =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _sites
|
||||
: _sites.Where(s =>
|
||||
(s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(s.SiteIdentifier?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -153,9 +213,10 @@
|
||||
|
||||
private async Task DeleteSite(Site site)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Site",
|
||||
$"Delete site '{site.Name}' ({site.SiteIdentifier})? This cannot be undone.",
|
||||
"Delete Site");
|
||||
danger: true);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -178,10 +239,9 @@
|
||||
_deploying = true;
|
||||
try
|
||||
{
|
||||
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await ArtifactDeploymentService.RetryForSiteAsync(
|
||||
site.SiteIdentifier, command, user);
|
||||
site.Id, site.SiteIdentifier, user);
|
||||
|
||||
if (result.IsSuccess)
|
||||
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
|
||||
@@ -203,9 +263,8 @@
|
||||
_deploying = true;
|
||||
try
|
||||
{
|
||||
var command = await ArtifactDeploymentService.BuildDeployArtifactsCommandAsync();
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(command, user);
|
||||
var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -227,4 +286,36 @@
|
||||
_deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
private RenderFragment ClusterRow(string label, string? address) => __builder =>
|
||||
{
|
||||
<div class="row g-1 align-items-center mb-1">
|
||||
<div class="col-2 small text-muted">@label</div>
|
||||
<div class="col-9">
|
||||
<code class="small d-block text-truncate" title="@address">
|
||||
@(string.IsNullOrWhiteSpace(address) ? "—" : address)
|
||||
</code>
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
@if (!string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
<button class="btn btn-link btn-sm p-0"
|
||||
@onclick="() => CopyAsync(address!)" title="Copy">📋</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
private async Task CopyAsync(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
@page "/admin/smtp"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject INotificationRepository NotificationRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">SMTP Configuration</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_smtpConfigs.Count == 0 && !_showForm)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-3">No SMTP configuration set.</p>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">
|
||||
Add SMTP configuration
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var smtp in _smtpConfigs)
|
||||
{
|
||||
<div class="card mb-3" @key="smtp.Id">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@smtp.Host</strong>
|
||||
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
|
||||
{
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4 text-muted">Host</div>
|
||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||
<div class="col-md-4 text-muted">Auth Type</div>
|
||||
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
||||
<div class="col-md-4 text-muted">From Address</div>
|
||||
<div class="col-md-8">@smtp.FromAddress</div>
|
||||
<div class="col-md-4 text-muted">Credentials</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" @bind="_host" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" @bind="_port" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Auth Type</label>
|
||||
<select class="form-select" @bind="_authType">
|
||||
<option>OAuth2</option>
|
||||
<option>Basic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Credentials</label>
|
||||
<input type="password" class="form-control" @bind="_credentials"
|
||||
placeholder="OAuth2 client secret or SMTP password" />
|
||||
<div class="form-text">Treat as sensitive — visible to admins only.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">From Address</label>
|
||||
<input type="email" class="form-control" @bind="_fromAddress"
|
||||
placeholder="noreply@example.com" />
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||
}
|
||||
<div class="col-12 text-end">
|
||||
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_smtpConfigs.Count == 0)
|
||||
{
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add SMTP configuration</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
|
||||
private List<SmtpConfigurationEntity> _smtpConfigs = new();
|
||||
private bool _showForm;
|
||||
private SmtpConfigurationEntity? _editingSmtp;
|
||||
|
||||
private string _host = string.Empty;
|
||||
private int _port = 587;
|
||||
private string _authType = "OAuth2";
|
||||
private string? _credentials;
|
||||
private string _fromAddress = string.Empty;
|
||||
private string? _formError;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = ex.Message;
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void ShowAddForm()
|
||||
{
|
||||
_editingSmtp = null;
|
||||
_host = string.Empty;
|
||||
_port = 587;
|
||||
_authType = "OAuth2";
|
||||
_credentials = null;
|
||||
_fromAddress = string.Empty;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void StartEdit(SmtpConfigurationEntity smtp)
|
||||
{
|
||||
_editingSmtp = smtp;
|
||||
_host = smtp.Host;
|
||||
_port = smtp.Port;
|
||||
_authType = smtp.AuthType;
|
||||
_credentials = smtp.Credentials;
|
||||
_fromAddress = smtp.FromAddress;
|
||||
_formError = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void CancelForm()
|
||||
{
|
||||
_showForm = false;
|
||||
_formError = null;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_host) || string.IsNullOrWhiteSpace(_fromAddress))
|
||||
{
|
||||
_formError = "Host and From Address are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingSmtp != null)
|
||||
{
|
||||
_editingSmtp.Host = _host.Trim();
|
||||
_editingSmtp.Port = _port;
|
||||
_editingSmtp.AuthType = _authType;
|
||||
_editingSmtp.Credentials = _credentials?.Trim();
|
||||
_editingSmtp.FromAddress = _fromAddress.Trim();
|
||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
||||
}
|
||||
else
|
||||
{
|
||||
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
|
||||
{
|
||||
Port = _port,
|
||||
Credentials = _credentials?.Trim()
|
||||
};
|
||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
||||
}
|
||||
await NotificationRepository.SaveChangesAsync();
|
||||
_showForm = false;
|
||||
_toast.ShowSuccess("SMTP configuration saved.");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,118 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
|
||||
<div class="container mt-4">
|
||||
<h3>Welcome to ScadaLink</h3>
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Welcome to ScadaLink</h4>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<span class="text-muted small">
|
||||
Signed in as <strong>@context.User.FindFirst("DisplayName")?.Value</strong>
|
||||
</span>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="card mt-3" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6>
|
||||
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
|
||||
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
|
||||
|
||||
@{
|
||||
var roles = context.User.FindAll("Role").Select(c => c.Value).ToList();
|
||||
}
|
||||
@if (roles.Count > 0)
|
||||
{
|
||||
<h6 class="card-subtitle mb-1 mt-3 text-muted">Roles</h6>
|
||||
<div>
|
||||
@foreach (var role in roles)
|
||||
{
|
||||
<span class="badge bg-secondary me-1">@role</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@* KPI row *@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold">@(_loaded ? _siteCount.ToString() : "—")</div>
|
||||
<div class="text-muted small">Sites configured</div>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold">@(_loaded ? _dataConnectionCount.ToString() : "—")</div>
|
||||
<div class="text-muted small">Data connections configured</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold">@(_loaded ? _templateCount.ToString() : "—")</div>
|
||||
<div class="text-muted small">Templates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="fs-2 fw-bold">@(_loaded ? _apiKeyCount.ToString() : "—")</div>
|
||||
<div class="text-muted small">API keys</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Quick actions *@
|
||||
<h6 class="text-muted text-uppercase small mb-2">Quick actions</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/health">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-1">Health Dashboard</h6>
|
||||
<span class="text-muted">→</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Live cluster, data connection, and queue health per site.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<a class="card h-100 text-decoration-none text-reset" href="/monitoring/audit-log">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-1">Recent Audit Log</h6>
|
||||
<span class="text-muted">→</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Browse changes to configuration and deployments.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<a class="card h-100 text-decoration-none text-reset" href="/design/templates">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="mb-1">Templates</h6>
|
||||
<span class="text-muted">→</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">Design templates, shared scripts, and external systems.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _loaded;
|
||||
private int _siteCount;
|
||||
private int _dataConnectionCount;
|
||||
private int _templateCount;
|
||||
private int _apiKeyCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
|
||||
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
|
||||
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
|
||||
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-fatal — leave counts at zero with the placeholder rendering.
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">New Area</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (RequireSitePicker)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_siteId">
|
||||
<option value="0">(Select a site)</option>
|
||||
@foreach (var opt in SiteOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Parent area</label>
|
||||
<select class="form-select form-select-sm" @bind="_parentAreaId">
|
||||
<option value="0">(Site root)</option>
|
||||
@foreach (var opt in ParentOptions.Where(o => SelectedSiteMatches(o)))
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small mb-2">
|
||||
@ContextLabel
|
||||
</div>
|
||||
}
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" placeholder="Area name" @bind="_name" />
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public bool RequireSitePicker { get; set; }
|
||||
[Parameter] public string ContextLabel { get; set; } = string.Empty;
|
||||
[Parameter] public int? SiteId { get; set; }
|
||||
[Parameter] public int? ParentAreaId { get; set; }
|
||||
[Parameter] public IEnumerable<(int Id, string Label)> SiteOptions { get; set; } = Array.Empty<(int, string)>();
|
||||
[Parameter] public IEnumerable<(int Id, string Label, int SiteId)> ParentOptions { get; set; } = Array.Empty<(int, string, int)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int SiteId, int? ParentAreaId, string Name)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private string _name = string.Empty;
|
||||
private int _siteId;
|
||||
private int _parentAreaId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_name = string.Empty;
|
||||
_siteId = SiteId ?? 0;
|
||||
_parentAreaId = ParentAreaId ?? 0;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private bool SelectedSiteMatches((int Id, string Label, int SiteId) opt) =>
|
||||
_siteId == 0 || opt.SiteId == _siteId;
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
var effectiveSite = RequireSitePicker ? _siteId : (SiteId ?? 0);
|
||||
var effectiveParent = RequireSitePicker
|
||||
? (_parentAreaId == 0 ? (int?)null : _parentAreaId)
|
||||
: ParentAreaId;
|
||||
await OnSubmit.InvokeAsync((effectiveSite, effectiveParent, _name.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Messages.DebugView
|
||||
@using ScadaLink.Commons.Messages.Streaming
|
||||
@using ScadaLink.Commons.Types
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@using ScadaLink.Communication
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@@ -25,10 +26,51 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Status strip — connection state, instance, last snapshot. *@
|
||||
<div class="alert alert-light py-2 mb-3 d-flex justify-content-between align-items-center small flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>
|
||||
@if (_connected)
|
||||
{
|
||||
var inst = _siteInstances.FirstOrDefault(i => i.Id == _selectedInstanceId);
|
||||
@(inst?.UniqueName ?? "Connected")
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not connected</span>
|
||||
}
|
||||
</strong>
|
||||
@if (_connected)
|
||||
{
|
||||
<span class="badge bg-success" aria-label="Connection state: Live">
|
||||
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;" aria-hidden="true"></span>
|
||||
Live
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary" aria-label="Connection state: Disconnected">Disconnected</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if (_snapshot != null)
|
||||
{
|
||||
<span class="text-muted">
|
||||
Last snapshot: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss")
|
||||
</span>
|
||||
}
|
||||
@if (_connected && _connectedFromStorage)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="StartFresh"
|
||||
aria-label="Clear persisted selection and disconnect">Start fresh</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite">
|
||||
<select class="form-select form-select-sm" @bind="_selectedSiteId" @bind:after="LoadInstancesForSite" disabled="@_connected">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
@@ -38,7 +80,7 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Instance</label>
|
||||
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged">
|
||||
<select class="form-select form-select-sm" @bind="_selectedInstanceId" @bind:after="OnInstanceSelectionChanged" disabled="@_connected">
|
||||
<option value="0">Select instance...</option>
|
||||
@foreach (var inst in _siteInstances)
|
||||
{
|
||||
@@ -51,17 +93,13 @@
|
||||
{
|
||||
<button class="btn btn-primary btn-sm" @onclick="Connect"
|
||||
disabled="@(_selectedInstanceId == 0 || _selectedSiteId == 0 || _connecting)">
|
||||
@if (_connecting) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
@if (_connecting) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-label="Connecting"></span> }
|
||||
Connect
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="Disconnect">Disconnect</button>
|
||||
<span class="badge bg-success align-self-center">
|
||||
<span class="spinner-grow spinner-grow-sm me-1" style="width: 0.5rem; height: 0.5rem;"></span>
|
||||
Live
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,9 +110,25 @@
|
||||
@* Attribute Values *@
|
||||
<div class="col-md-7">
|
||||
<div class="card">
|
||||
<div class="card-header py-2 d-flex justify-content-between">
|
||||
<strong>Attribute Values</strong>
|
||||
<small class="text-muted">@_attributeValues.Count values</small>
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>Attribute Values</strong>
|
||||
<small class="text-muted">@FilteredAttributeValues.Count latest (cap @MaxRows)</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
style="max-width: 240px;"
|
||||
placeholder="Filter by attribute…"
|
||||
@bind="_attrFilter" @bind:event="oninput" aria-label="Filter attributes" />
|
||||
<button class="btn btn-link btn-sm py-0" type="button"
|
||||
@onclick="() => _attrScrollLocked = !_attrScrollLocked"
|
||||
aria-pressed="@(_attrScrollLocked ? "true" : "false")"
|
||||
aria-label="@(_attrScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
|
||||
@(_attrScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button"
|
||||
@onclick="ClearAttributes" aria-label="Clear attribute table">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
@@ -86,16 +140,20 @@
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var av in _attributeValues.Values.OrderBy(a => a.AttributeName))
|
||||
<tbody aria-live="polite" aria-atomic="false">
|
||||
@foreach (var av in FilteredAttributeValues)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@av.AttributeName</td>
|
||||
<td class="small font-monospace"><strong>@av.Value</strong></td>
|
||||
<td class="small font-monospace"><strong>@ValueFormatter.FormatDisplayValue(av.Value)</strong></td>
|
||||
<td>
|
||||
<span class="badge @(av.Quality == "Good" ? "bg-success" : "bg-warning text-dark")">@av.Quality</span>
|
||||
<span class="badge @GetQualityBadge(av.Quality)"
|
||||
aria-label="@($"Quality: {av.Quality}")">@av.Quality</span>
|
||||
</td>
|
||||
<td class="small text-muted"
|
||||
title="@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
||||
@av.Timestamp.LocalDateTime.ToString("HH:mm:ss")
|
||||
</td>
|
||||
<td class="small text-muted">@av.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -107,9 +165,25 @@
|
||||
@* Alarm States *@
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-2 d-flex justify-content-between">
|
||||
<strong>Alarm States</strong>
|
||||
<small class="text-muted">@_alarmStates.Count alarms</small>
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>Alarm States</strong>
|
||||
<small class="text-muted">@FilteredAlarmStates.Count latest (cap @MaxRows)</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
style="max-width: 240px;"
|
||||
placeholder="Filter by alarm…"
|
||||
@bind="_alarmFilter" @bind:event="oninput" aria-label="Filter alarms" />
|
||||
<button class="btn btn-link btn-sm py-0" type="button"
|
||||
@onclick="() => _alarmScrollLocked = !_alarmScrollLocked"
|
||||
aria-pressed="@(_alarmScrollLocked ? "true" : "false")"
|
||||
aria-label="@(_alarmScrollLocked ? "Scroll locked" : "Auto-scroll enabled")">
|
||||
@(_alarmScrollLocked ? "🔒 Locked" : "🔓 Auto-scroll")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button"
|
||||
@onclick="ClearAlarms" aria-label="Clear alarm table">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
@@ -117,20 +191,43 @@
|
||||
<tr>
|
||||
<th>Alarm</th>
|
||||
<th>State</th>
|
||||
<th>Level</th>
|
||||
<th>Priority</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var alarm in _alarmStates.Values.OrderBy(a => a.AlarmName))
|
||||
<tbody aria-live="polite" aria-atomic="false">
|
||||
@foreach (var alarm in FilteredAlarmStates)
|
||||
{
|
||||
<tr class="@GetAlarmRowClass(alarm.State)">
|
||||
<td class="small">@alarm.AlarmName</td>
|
||||
<tr class="@GetAlarmRowClass(alarm.State)"
|
||||
title="@(string.IsNullOrEmpty(alarm.Message) ? null : alarm.Message)">
|
||||
<td class="small">
|
||||
@alarm.AlarmName
|
||||
@if (!string.IsNullOrEmpty(alarm.Message))
|
||||
{
|
||||
<span class="ms-1 text-info" aria-label="Has operator message">💬</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge @GetAlarmStateBadge(alarm.State)">@alarm.State</span>
|
||||
<span class="badge @GetAlarmStateBadge(alarm.State)"
|
||||
aria-label="@($"Alarm state: {alarm.State}")">@alarm.State</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (alarm.Level != AlarmLevel.None)
|
||||
{
|
||||
<span class="badge @GetAlarmLevelBadge(alarm.Level)"
|
||||
aria-label="@($"Alarm level: {alarm.Level}")">@FormatLevel(alarm.Level)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@alarm.Priority</td>
|
||||
<td class="small text-muted">@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")</td>
|
||||
<td class="small text-muted"
|
||||
title="@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss.fff")">
|
||||
@alarm.Timestamp.LocalDateTime.ToString("HH:mm:ss")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -139,11 +236,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
Snapshot received: @_snapshot.SnapshotTimestamp.LocalDateTime.ToString("HH:mm:ss") |
|
||||
@_attributeValues.Count attributes, @_alarmStates.Count alarms
|
||||
</div>
|
||||
}
|
||||
else if (_connected)
|
||||
{
|
||||
@@ -153,6 +245,11 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private const int MaxRows = 200;
|
||||
|
||||
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||
[SupplyParameterFromQuery] public int? InstanceId { get; set; }
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<Instance> _siteInstances = new();
|
||||
private int _selectedSiteId;
|
||||
@@ -160,14 +257,41 @@
|
||||
private bool _loading = true;
|
||||
private bool _connected;
|
||||
private bool _connecting;
|
||||
private bool _connectedFromStorage;
|
||||
|
||||
private DebugViewSnapshot? _snapshot;
|
||||
// Keyed dictionaries hold the latest value per attribute/alarm; insertion order
|
||||
// is preserved so we can trim the oldest when the count exceeds MaxRows.
|
||||
private Dictionary<string, AttributeValueChanged> _attributeValues = new();
|
||||
private Dictionary<string, AlarmStateChanged> _alarmStates = new();
|
||||
|
||||
// Filters and scroll-lock state per table.
|
||||
private string _attrFilter = string.Empty;
|
||||
private string _alarmFilter = string.Empty;
|
||||
private bool _attrScrollLocked;
|
||||
private bool _alarmScrollLocked;
|
||||
|
||||
private IReadOnlyList<AttributeValueChanged> FilteredAttributeValues =>
|
||||
string.IsNullOrWhiteSpace(_attrFilter)
|
||||
? _attributeValues.Values.OrderBy(a => a.AttributeName).ToList()
|
||||
: _attributeValues.Values
|
||||
.Where(a => a.AttributeName.Contains(_attrFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(a => a.AttributeName)
|
||||
.ToList();
|
||||
|
||||
private IReadOnlyList<AlarmStateChanged> FilteredAlarmStates =>
|
||||
string.IsNullOrWhiteSpace(_alarmFilter)
|
||||
? _alarmStates.Values.OrderBy(a => a.AlarmName).ToList()
|
||||
: _alarmStates.Values
|
||||
.Where(a => a.AlarmName.Contains(_alarmFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(a => a.AlarmName)
|
||||
.ToList();
|
||||
|
||||
private DebugStreamSession? _session;
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private string? _initError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
@@ -176,7 +300,7 @@
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Failed to load sites: {ex.Message}");
|
||||
_initError = $"Failed to load sites: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
@@ -185,6 +309,29 @@
|
||||
{
|
||||
if (!firstRender) return;
|
||||
|
||||
if (_initError != null)
|
||||
{
|
||||
_toast.ShowError(_initError);
|
||||
_initError = null;
|
||||
}
|
||||
|
||||
if (SiteId is > 0 && InstanceId is > 0)
|
||||
{
|
||||
_selectedSiteId = SiteId.Value;
|
||||
await LoadInstancesForSite();
|
||||
if (_siteInstances.Any(i => i.Id == InstanceId.Value))
|
||||
{
|
||||
_selectedInstanceId = InstanceId.Value;
|
||||
await Connect();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError("Requested instance is not available for debug streaming.");
|
||||
}
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
var storedSiteId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.siteId");
|
||||
var storedInstanceId = await JS.InvokeAsync<string>("localStorage.getItem", "debugView.instanceId");
|
||||
|
||||
@@ -194,8 +341,15 @@
|
||||
_selectedSiteId = siteId;
|
||||
await LoadInstancesForSite();
|
||||
_selectedInstanceId = instanceId;
|
||||
_connectedFromStorage = true;
|
||||
StateHasChanged();
|
||||
await Connect();
|
||||
|
||||
// Auto-reconnect notice — the user didn't initiate this connection.
|
||||
var inst = _siteInstances.FirstOrDefault(i => i.Id == instanceId);
|
||||
_toast.ShowInfo(
|
||||
$"Auto-reconnected to {inst?.UniqueName ?? "instance"} from previous session.",
|
||||
autoDismissMs: 8000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,11 +388,11 @@
|
||||
switch (evt)
|
||||
{
|
||||
case AttributeValueChanged av:
|
||||
_attributeValues[av.AttributeName] = av;
|
||||
UpsertWithCap(_attributeValues, av.AttributeName, av);
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
break;
|
||||
case AlarmStateChanged al:
|
||||
_alarmStates[al.AlarmName] = al;
|
||||
UpsertWithCap(_alarmStates, al.AlarmName, al);
|
||||
_ = InvokeAsync(StateHasChanged);
|
||||
break;
|
||||
}
|
||||
@@ -295,11 +449,59 @@
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "debugView.instanceId");
|
||||
|
||||
_connected = false;
|
||||
_connectedFromStorage = false;
|
||||
_snapshot = null;
|
||||
_attributeValues.Clear();
|
||||
_alarmStates.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect and forget the persisted selection. Surfaces in the status
|
||||
/// strip whenever the page auto-reconnects from localStorage so the user
|
||||
/// can opt out of the carry-over session.
|
||||
/// </summary>
|
||||
private async Task StartFresh()
|
||||
{
|
||||
await Disconnect();
|
||||
_selectedSiteId = 0;
|
||||
_selectedInstanceId = 0;
|
||||
_siteInstances.Clear();
|
||||
_toast.ShowInfo("Cleared previous session — select a site and instance to begin.", autoDismissMs: 5000);
|
||||
}
|
||||
|
||||
private void ClearAttributes()
|
||||
{
|
||||
_attributeValues.Clear();
|
||||
}
|
||||
|
||||
private void ClearAlarms()
|
||||
{
|
||||
_alarmStates.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace or insert a value keyed by name, then trim the oldest entries
|
||||
/// (queue-style) so the table size never exceeds MaxRows. Dictionary
|
||||
/// preserves insertion order, so the first key is always the oldest.
|
||||
/// </summary>
|
||||
private static void UpsertWithCap<T>(Dictionary<string, T> map, string key, T value)
|
||||
{
|
||||
map[key] = value;
|
||||
while (map.Count > MaxRows)
|
||||
{
|
||||
var oldest = map.Keys.First();
|
||||
map.Remove(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetQualityBadge(string quality) => quality switch
|
||||
{
|
||||
"Good" => "bg-success",
|
||||
"Bad" => "bg-danger",
|
||||
"Uncertain" => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string GetAlarmStateBadge(AlarmState state) => state switch
|
||||
{
|
||||
AlarmState.Active => "bg-danger",
|
||||
@@ -313,6 +515,26 @@
|
||||
_ => ""
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Severity-tinted badge class for HiLo alarm levels. The critical bands
|
||||
/// (HighHigh / LowLow) get the danger class; warning bands get amber.
|
||||
/// </summary>
|
||||
private static string GetAlarmLevelBadge(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh or AlarmLevel.LowLow => "bg-danger",
|
||||
AlarmLevel.High or AlarmLevel.Low => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string FormatLevel(AlarmLevel level) => level switch
|
||||
{
|
||||
AlarmLevel.HighHigh => "HiHi",
|
||||
AlarmLevel.High => "Hi",
|
||||
AlarmLevel.Low => "Lo",
|
||||
AlarmLevel.LowLow => "LoLo",
|
||||
_ => "—"
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_session != null)
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Deployment Status</h4>
|
||||
<div>
|
||||
<span class="text-muted small me-2">Auto-refresh: 10s</span>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
|
||||
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
|
||||
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +33,7 @@
|
||||
{
|
||||
@* Summary cards *@
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-warning">@_records.Count(r => r.Status == DeploymentStatus.Pending)</h4>
|
||||
@@ -38,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-info">@_records.Count(r => r.Status == DeploymentStatus.InProgress)</h4>
|
||||
@@ -46,7 +49,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-success">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-success">@_records.Count(r => r.Status == DeploymentStatus.Success)</h4>
|
||||
@@ -54,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-lg-3 col-md-6 col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center py-2">
|
||||
<h4 class="mb-0 text-danger">@_records.Count(r => r.Status == DeploymentStatus.Failed)</h4>
|
||||
@@ -64,37 +67,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
@if (_records.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<p class="mb-0">No deployments recorded.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-striped table-hover align-middle">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Deployment ID</th>
|
||||
<th>Deployment</th>
|
||||
<th>Instance</th>
|
||||
<th>Status</th>
|
||||
<th>Deployed By</th>
|
||||
<th>Started</th>
|
||||
<th>Completed</th>
|
||||
<th>Revision</th>
|
||||
<th>Error</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_records.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="8" class="text-muted text-center">No deployments recorded.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var record in _pagedRecords)
|
||||
{
|
||||
<tr class="@GetRowClass(record.Status)">
|
||||
<td><code class="small">@record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)]...</code></td>
|
||||
var rowId = $"deploy-row-{record.DeploymentId}";
|
||||
var errorCollapseId = $"deploy-err-{record.DeploymentId}";
|
||||
var isFailed = record.Status == DeploymentStatus.Failed;
|
||||
var idShort = record.DeploymentId[..Math.Min(12, record.DeploymentId.Length)];
|
||||
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
|
||||
<tr id="@rowId" class="@GetRowClass(record.Status)">
|
||||
<td>
|
||||
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
|
||||
</td>
|
||||
<td>@GetInstanceName(record.InstanceId)</td>
|
||||
<td>
|
||||
<span class="badge @GetStatusBadge(record.Status)">
|
||||
@if (isFailed)
|
||||
{
|
||||
<i class="bi bi-x-circle text-danger me-1" aria-hidden="true"></i>
|
||||
}
|
||||
<span class="badge @GetStatusBadge(record.Status)"
|
||||
aria-label="@($"Deployment status: {record.Status}")">
|
||||
@record.Status
|
||||
@if (record.Status == DeploymentStatus.InProgress)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"></span>
|
||||
<span class="spinner-border spinner-border-sm ms-1" style="width: 0.7rem; height: 0.7rem;"
|
||||
role="status" aria-label="Deployment in progress"></span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
@@ -112,12 +129,33 @@
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small"><code>@(record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)])</code></td>
|
||||
<td class="small text-danger">@(record.ErrorMessage ?? "")</td>
|
||||
<td class="small text-end">
|
||||
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
|
||||
{
|
||||
<button class="btn btn-link btn-sm p-0" type="button"
|
||||
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
|
||||
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
|
||||
aria-controls="@errorCollapseId">
|
||||
@(IsErrorExpanded(record.DeploymentId) ? "Hide error" : "View error")
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage) && IsErrorExpanded(record.DeploymentId))
|
||||
{
|
||||
<tr id="@errorCollapseId" class="table-danger">
|
||||
<td colspan="7">
|
||||
<div class="small">
|
||||
<strong>Error:</strong>
|
||||
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_totalPages > 1)
|
||||
{
|
||||
@@ -149,22 +187,56 @@
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private Timer? _refreshTimer;
|
||||
private bool _autoRefresh = true;
|
||||
private readonly HashSet<string> _expandedErrors = new();
|
||||
|
||||
private int _currentPage = 1;
|
||||
private int _totalPages;
|
||||
private const int PageSize = 25;
|
||||
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StartTimer();
|
||||
}
|
||||
|
||||
private void StartTimer()
|
||||
{
|
||||
_refreshTimer?.Dispose();
|
||||
_refreshTimer = new Timer(_ =>
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (!_autoRefresh) return;
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
}, null, RefreshInterval, RefreshInterval);
|
||||
}
|
||||
|
||||
private void ToggleAutoRefresh()
|
||||
{
|
||||
_autoRefresh = !_autoRefresh;
|
||||
if (_autoRefresh)
|
||||
{
|
||||
StartTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
_refreshTimer?.Dispose();
|
||||
_refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsErrorExpanded(string deploymentId) => _expandedErrors.Contains(deploymentId);
|
||||
|
||||
private void ToggleErrorExpansion(string deploymentId)
|
||||
{
|
||||
if (!_expandedErrors.Remove(deploymentId))
|
||||
{
|
||||
_expandedErrors.Add(deploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
@page "/deployment/instances/{Id:int}/configure"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Entities.Templates
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@using ScadaLink.TemplateEngine.Flattening
|
||||
@using ScadaLink.TemplateEngine.Services
|
||||
@using ScadaLink.DeploymentManager
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject InstanceService InstanceService
|
||||
@inject IFlatteningPipeline FlatteningPipeline
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back to Topology</button>
|
||||
<h4 class="mb-0">Configure Instance</h4>
|
||||
</div>
|
||||
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else if (_instance != null)
|
||||
{
|
||||
@* Instance Identity *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Instance</small>
|
||||
<div><strong>@_instance.UniqueName</strong></div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<small class="text-muted">Template</small>
|
||||
<div>@_templateName</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<small class="text-muted">Site</small>
|
||||
<div>@_siteName</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<small class="text-muted">Status</small>
|
||||
<div><span class="badge @GetStateBadge(_instance.State)">@_instance.State</span></div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<small class="text-muted">Area</small>
|
||||
<div>@(_instance.AreaId.HasValue ? _areaName : "—")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Connection Bindings *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<strong>Connection Bindings</strong>
|
||||
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<select class="form-select form-select-sm" style="width: auto;" @bind="_bulkConnectionId">
|
||||
<option value="0">Assign all to...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="ApplyBulkBinding"
|
||||
disabled="@(_bulkConnectionId == 0)">Apply</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_bindingDataSourceAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small p-3 mb-0">No data-sourced attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Tag Path</th>
|
||||
<th style="width: 280px;">Connection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetBindingConnectionId(attr.Name)"
|
||||
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-2">
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Attribute Overrides *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<strong>Attribute Overrides</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_overrideAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small p-3 mb-0">No overridable (non-locked) attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
<th>Type</th>
|
||||
<th>Template Value</th>
|
||||
<th style="width: 280px;">Override Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _overrideAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
|
||||
<td class="small text-muted">@(attr.Value ?? "—")</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
value="@GetOverrideValue(attr.Name)"
|
||||
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-2">
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveOverrides" disabled="@_saving">Save Overrides</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Alarm Overrides *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<strong>Alarm Overrides</strong>
|
||||
<small class="text-muted ms-2">
|
||||
Click <em>Edit</em> to override an alarm's trigger configuration or priority.
|
||||
HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_overridableAlarms.Count == 0)
|
||||
{
|
||||
<p class="text-muted small p-3 mb-0">No overridable (non-locked) alarms on this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Alarm</th>
|
||||
<th style="width: 110px;">Trigger</th>
|
||||
<th>Inherited Config</th>
|
||||
<th style="width: 280px;">Override</th>
|
||||
<th style="width: 140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var alarm in _overridableAlarms)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@alarm.Name</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">@alarm.TriggerType</span>
|
||||
</td>
|
||||
<td class="small text-muted text-truncate font-monospace" style="max-width: 280px;"
|
||||
title="@alarm.TriggerConfiguration">
|
||||
@(alarm.TriggerConfiguration ?? "—")
|
||||
</td>
|
||||
<td class="small">
|
||||
@if (HasOverride(alarm.Name))
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-1" title="Override is set">●</span>
|
||||
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted fst-italic">inherited</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm me-1"
|
||||
@onclick="() => BeginEditOverride(alarm)"
|
||||
disabled="@_saving">Edit</button>
|
||||
@if (HasOverride(alarm.Name))
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => ClearAlarmOverride(alarm.Name)"
|
||||
disabled="@_saving">Clear</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Override edit modal *@
|
||||
@if (_editingAlarm != null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">
|
||||
Edit override: @_editingAlarm.Name
|
||||
<span class="badge bg-light text-dark border ms-1">@_editingAlarm.TriggerType</span>
|
||||
</h6>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3 small">
|
||||
<div class="text-muted text-uppercase fw-semibold mb-1">Inherited from template</div>
|
||||
<code class="d-block bg-light p-2 rounded text-break">@(_editingAlarm.TriggerConfiguration ?? "(none)")</code>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-muted text-uppercase small fw-semibold mb-1">Configuration</div>
|
||||
<AlarmTriggerEditor TriggerType="@_editingAlarm.TriggerType"
|
||||
Value="@_editingOverrideValue"
|
||||
ValueChanged="@(v => _editingOverrideValue = v)"
|
||||
AvailableAttributes="@_editingAvailableAttributes"
|
||||
FallbackPriority="@_editingAlarm.PriorityLevel" />
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small text-uppercase text-muted fw-semibold mb-1">
|
||||
Priority override
|
||||
</label>
|
||||
<input type="number" min="0" max="1000" class="form-control form-control-sm"
|
||||
placeholder="@_editingAlarm.PriorityLevel"
|
||||
@bind="_editingPriorityText" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_editingError != null)
|
||||
{
|
||||
<div class="alert alert-danger small mt-2 mb-0">@_editingError</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer justify-content-between">
|
||||
<div>
|
||||
@if (HasOverride(_editingAlarm.Name))
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
@onclick="() => ClearFromModal()"
|
||||
disabled="@_saving">Clear Override</button>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEditOverride">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Area Assignment *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<strong>Area Assignment</strong>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" style="width: auto;" @bind="_reassignAreaId">
|
||||
<option value="0">No area</option>
|
||||
@foreach (var a in _siteAreas)
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="ReassignArea" disabled="@_saving">Set Area</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int Id { get; set; }
|
||||
|
||||
private Instance? _instance;
|
||||
private string _templateName = "";
|
||||
private string _siteName = "";
|
||||
private string _areaName = "";
|
||||
private bool _loading = true;
|
||||
private bool _saving;
|
||||
private string? _errorMessage;
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
// Bindings
|
||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private Dictionary<string, int> _bindingSelections = new();
|
||||
private int _bulkConnectionId;
|
||||
|
||||
// Overrides
|
||||
private List<TemplateAttribute> _overrideAttrs = new();
|
||||
private Dictionary<string, string?> _overrideValues = new();
|
||||
|
||||
// Alarm overrides — read-only state pulled from the repo. The edit modal
|
||||
// is the only mutation path (one alarm at a time).
|
||||
private List<TemplateAlarm> _overridableAlarms = new();
|
||||
private Dictionary<string, InstanceAlarmOverride> _existingAlarmOverrides = new();
|
||||
|
||||
// Override edit modal state — non-null while the modal is open.
|
||||
private TemplateAlarm? _editingAlarm;
|
||||
private string? _editingOverrideValue; // current Value parameter for AlarmTriggerEditor
|
||||
private string? _editingInheritedValue; // the inherited config snapshot we diff against on save
|
||||
private string? _editingPriorityText;
|
||||
private string? _editingError;
|
||||
private IReadOnlyList<AlarmAttributeChoice> _editingAvailableAttributes = Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
// Cached flattened attribute list (direct + inherited + composed members,
|
||||
// path-qualified canonical names). Populated once after the instance loads
|
||||
// and fed to the alarm trigger editor so composed-member paths like
|
||||
// "AlarmSensor.SensorReading" resolve in the picker.
|
||||
private IReadOnlyList<AlarmAttributeChoice> _flattenedAttributes = Array.Empty<AlarmAttributeChoice>();
|
||||
|
||||
// Area
|
||||
private List<Area> _siteAreas = new();
|
||||
private int _reassignAreaId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_instance = await TemplateEngineRepository.GetInstanceByIdAsync(Id);
|
||||
if (_instance == null)
|
||||
{
|
||||
_errorMessage = $"Instance #{Id} not found.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Identity
|
||||
var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId);
|
||||
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
|
||||
|
||||
var sites = await SiteRepository.GetAllSitesAsync();
|
||||
_siteName = sites.FirstOrDefault(s => s.Id == _instance.SiteId)?.Name ?? $"#{_instance.SiteId}";
|
||||
|
||||
// Areas
|
||||
_siteAreas = (await TemplateEngineRepository.GetAreasBySiteIdAsync(_instance.SiteId)).ToList();
|
||||
_reassignAreaId = _instance.AreaId ?? 0;
|
||||
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
|
||||
|
||||
// Bindings
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(_instance.TemplateId);
|
||||
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(_instance.SiteId)).ToList();
|
||||
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(Id);
|
||||
foreach (var b in existingBindings)
|
||||
_bindingSelections[b.AttributeName] = b.DataConnectionId;
|
||||
|
||||
// Overrides
|
||||
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
|
||||
var existingOverrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(Id);
|
||||
foreach (var o in existingOverrides)
|
||||
_overrideValues[o.AttributeName] = o.OverrideValue;
|
||||
|
||||
// Alarm overrides — load all non-locked template alarms and
|
||||
// existing override rows. Pre-seed the dirty maps from existing
|
||||
// values so the inputs render with what's currently saved.
|
||||
var alarms = await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(_instance.TemplateId);
|
||||
_overridableAlarms = alarms.Where(a => !a.IsLocked).ToList();
|
||||
var alarmOverrides = await TemplateEngineRepository.GetAlarmOverridesByInstanceIdAsync(Id);
|
||||
foreach (var o in alarmOverrides)
|
||||
{
|
||||
_existingAlarmOverrides[o.AlarmCanonicalName] = o;
|
||||
}
|
||||
|
||||
_flattenedAttributes = await BuildFlattenedAttributesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load instance: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
|
||||
// ── Bindings ────────────────────────────────────────────
|
||||
|
||||
private int GetBindingConnectionId(string attrName)
|
||||
=> _bindingSelections.GetValueOrDefault(attrName, 0);
|
||||
|
||||
private void OnBindingChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
|
||||
if (val == 0) _bindingSelections.Remove(attrName);
|
||||
else _bindingSelections[attrName] = val;
|
||||
}
|
||||
|
||||
private void ApplyBulkBinding()
|
||||
{
|
||||
if (_bulkConnectionId == 0) return;
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||
}
|
||||
|
||||
private async Task SaveBindings()
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var bindings = _bindingSelections.Select(kv => (kv.Key, kv.Value)).ToList();
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
|
||||
if (result.IsSuccess)
|
||||
_toast.ShowSuccess($"Saved {bindings.Count} connection binding(s).");
|
||||
else
|
||||
_toast.ShowError($"Save failed: {result.Error}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Save failed: {ex.Message}");
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
// ── Overrides ───────────────────────────────────────────
|
||||
|
||||
private string? GetOverrideValue(string attrName)
|
||||
=> _overrideValues.GetValueOrDefault(attrName);
|
||||
|
||||
private void OnOverrideChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = e.Value?.ToString();
|
||||
if (string.IsNullOrEmpty(val)) _overrideValues.Remove(attrName);
|
||||
else _overrideValues[attrName] = val;
|
||||
}
|
||||
|
||||
private async Task SaveOverrides()
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
foreach (var (attrName, value) in _overrideValues)
|
||||
await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
|
||||
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Save overrides failed: {ex.Message}");
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
// ── Alarm overrides ─────────────────────────────────────
|
||||
|
||||
private bool HasOverride(string alarmName) =>
|
||||
_existingAlarmOverrides.ContainsKey(alarmName);
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary of the currently-saved override. Lists the
|
||||
/// HiLo keys that differ from the inherited config plus a priority chip.
|
||||
/// Used by the row's "Override" column.
|
||||
/// </summary>
|
||||
private string OverrideSummary(string alarmName)
|
||||
{
|
||||
if (!_existingAlarmOverrides.TryGetValue(alarmName, out var ovr))
|
||||
return "";
|
||||
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(ovr.TriggerConfigurationOverride))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(ovr.TriggerConfigurationOverride);
|
||||
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
parts.AddRange(doc.RootElement.EnumerateObject().Select(p => p.Name));
|
||||
}
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
parts.Add("(invalid JSON)");
|
||||
}
|
||||
}
|
||||
if (ovr.PriorityLevelOverride.HasValue)
|
||||
parts.Add($"priority={ovr.PriorityLevelOverride.Value}");
|
||||
|
||||
return parts.Count == 0 ? "(empty)" : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the override editor modal pre-populated with the merged
|
||||
/// (inherited + existing override) config so the user sees the effective
|
||||
/// state — not just the override delta.
|
||||
/// </summary>
|
||||
private void BeginEditOverride(TemplateAlarm alarm)
|
||||
{
|
||||
_editingAlarm = alarm;
|
||||
_editingError = null;
|
||||
_editingInheritedValue = alarm.TriggerConfiguration;
|
||||
|
||||
var existing = _existingAlarmOverrides.GetValueOrDefault(alarm.Name);
|
||||
|
||||
// HiLo: merge inherited + override so the editor shows the effective
|
||||
// setpoints. Binary: pre-fill with the override if present, else the
|
||||
// inherited config — same idea.
|
||||
_editingOverrideValue = alarm.TriggerType == AlarmTriggerType.HiLo
|
||||
? FlatteningService.MergeHiLoConfig(alarm.TriggerConfiguration, existing?.TriggerConfigurationOverride)
|
||||
: (existing?.TriggerConfigurationOverride ?? alarm.TriggerConfiguration);
|
||||
|
||||
_editingPriorityText = existing?.PriorityLevelOverride?.ToString();
|
||||
_editingAvailableAttributes = _flattenedAttributes;
|
||||
}
|
||||
|
||||
private void CancelEditOverride()
|
||||
{
|
||||
_editingAlarm = null;
|
||||
_editingError = null;
|
||||
}
|
||||
|
||||
private async Task SaveOverrideFromModal()
|
||||
{
|
||||
if (_editingAlarm == null) return;
|
||||
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
int? priority = null;
|
||||
if (!string.IsNullOrWhiteSpace(_editingPriorityText))
|
||||
{
|
||||
if (!int.TryParse(_editingPriorityText, out var p))
|
||||
{
|
||||
_editingError = "Priority must be an integer.";
|
||||
_saving = false;
|
||||
return;
|
||||
}
|
||||
priority = p;
|
||||
}
|
||||
|
||||
// Compute the override JSON. For HiLo, diff against inherited so we
|
||||
// store only the changed keys (matches the merge-on-flatten flow).
|
||||
// For binary, whole-replace if the edited config differs from
|
||||
// inherited.
|
||||
string? overrideJson;
|
||||
if (_editingAlarm.TriggerType == AlarmTriggerType.HiLo)
|
||||
{
|
||||
overrideJson = FlatteningService.DiffHiLoConfig(_editingInheritedValue, _editingOverrideValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
overrideJson = _editingOverrideValue == _editingInheritedValue
|
||||
? null
|
||||
: _editingOverrideValue;
|
||||
}
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var alarmName = _editingAlarm.Name;
|
||||
|
||||
// No diff + no priority → clear any existing override and close.
|
||||
if (string.IsNullOrWhiteSpace(overrideJson) && !priority.HasValue)
|
||||
{
|
||||
if (_existingAlarmOverrides.ContainsKey(alarmName))
|
||||
{
|
||||
var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
|
||||
if (!del.IsSuccess)
|
||||
{
|
||||
_editingError = del.Error;
|
||||
_saving = false;
|
||||
return;
|
||||
}
|
||||
_existingAlarmOverrides.Remove(alarmName);
|
||||
_toast.ShowSuccess($"Cleared override on '{alarmName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowSuccess("No change.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await InstanceService.SetAlarmOverrideAsync(
|
||||
Id, alarmName, overrideJson, priority, user);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_editingError = result.Error;
|
||||
_saving = false;
|
||||
return;
|
||||
}
|
||||
_existingAlarmOverrides[alarmName] = result.Value!;
|
||||
_toast.ShowSuccess($"Saved override on '{alarmName}'.");
|
||||
}
|
||||
|
||||
_editingAlarm = null;
|
||||
_editingError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_editingError = ex.Message;
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
private async Task ClearFromModal()
|
||||
{
|
||||
if (_editingAlarm == null) return;
|
||||
var name = _editingAlarm.Name;
|
||||
await ClearAlarmOverride(name);
|
||||
_editingAlarm = null;
|
||||
}
|
||||
|
||||
private async Task ClearAlarmOverride(string alarmName)
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_existingAlarmOverrides.Remove(alarmName);
|
||||
_toast.ShowSuccess($"Cleared override on '{alarmName}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Clear failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Clear failed: {ex.Message}");
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors TemplateEdit.MapDataType — converts the persisted DataType enum
|
||||
/// to the canonical SCADA type string the AlarmTriggerEditor compares
|
||||
/// against (Boolean / Integer / Float / String / Object).
|
||||
/// </summary>
|
||||
private static string MapDataType(DataType dt) => dt switch
|
||||
{
|
||||
DataType.Boolean => "Boolean",
|
||||
DataType.Int32 => "Integer",
|
||||
DataType.Float => "Float",
|
||||
DataType.Double => "Float",
|
||||
DataType.String => "String",
|
||||
DataType.DateTime => "String",
|
||||
DataType.Binary => "Object",
|
||||
_ => "Object"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Same mapping for the string form emitted by <see cref="Commons.Types.Flattening.ResolvedAttribute.DataType"/>.
|
||||
/// </summary>
|
||||
private static string MapDataType(string dt) =>
|
||||
Enum.TryParse<DataType>(dt, out var parsed) ? MapDataType(parsed) : dt;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the alarm picker choice list from the flattened configuration so
|
||||
/// composed-member paths (e.g. <c>AlarmSensor.SensorReading</c>) and
|
||||
/// inherited attributes appear alongside direct ones. Falls back to the
|
||||
/// direct-only list if flattening fails for any reason.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<AlarmAttributeChoice>> BuildFlattenedAttributesAsync()
|
||||
{
|
||||
var fallback = (IReadOnlyList<AlarmAttributeChoice>)_overrideAttrs
|
||||
.Select(a => new AlarmAttributeChoice(a.Name, MapDataType(a.DataType), "Direct"))
|
||||
.ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var flat = await FlatteningPipeline.FlattenAndValidateAsync(Id);
|
||||
if (flat.IsFailure) return fallback;
|
||||
|
||||
return flat.Value.Configuration.Attributes
|
||||
.Select(a => new AlarmAttributeChoice(
|
||||
a.CanonicalName,
|
||||
MapDataType(a.DataType),
|
||||
a.Source switch
|
||||
{
|
||||
"Composed" => "Composed",
|
||||
"Inherited" => "Inherited",
|
||||
_ => "Direct" // Template / Override
|
||||
}))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Area ────────────────────────────────────────────────
|
||||
|
||||
private async Task ReassignArea()
|
||||
{
|
||||
_saving = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
|
||||
_toast.ShowSuccess("Area reassigned.");
|
||||
}
|
||||
else
|
||||
_toast.ShowError($"Reassign failed: {result.Error}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Reassign failed: {ex.Message}");
|
||||
}
|
||||
_saving = false;
|
||||
}
|
||||
|
||||
private static string GetStateBadge(InstanceState state) => state switch
|
||||
{
|
||||
InstanceState.Enabled => "bg-success",
|
||||
InstanceState.Disabled => "bg-secondary",
|
||||
InstanceState.NotDeployed => "bg-light text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<a href="/deployment/instances" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<a href="/deployment/topology" class="btn btn-outline-secondary btn-sm me-3">← Back</a>
|
||||
<h4 class="mb-0">Create Instance</h4>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,9 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||
[SupplyParameterFromQuery] public int? AreaId { get; set; }
|
||||
|
||||
private List<Site> _sites = new();
|
||||
private List<Template> _templates = new();
|
||||
private List<Area> _allAreas = new();
|
||||
@@ -98,6 +101,15 @@
|
||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||
_allAreas.AddRange(areas);
|
||||
}
|
||||
|
||||
if (SiteId is int sid && _sites.Any(s => s.Id == sid))
|
||||
{
|
||||
_createSiteId = sid;
|
||||
}
|
||||
if (AreaId is int aid && _allAreas.Any(a => a.Id == aid && a.SiteId == _createSiteId))
|
||||
{
|
||||
_createAreaId = aid;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -120,7 +132,7 @@
|
||||
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/deployment/instances");
|
||||
NavigationManager.NavigateTo("/deployment/topology");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -133,7 +145,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/instances");
|
||||
private void GoBack() => NavigationManager.NavigateTo("/deployment/topology");
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
|
||||
@@ -1,773 +0,0 @@
|
||||
@page "/deployment/instances"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Entities.Templates
|
||||
@using ScadaLink.Commons.Entities.Deployment
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@using ScadaLink.DeploymentManager
|
||||
@using ScadaLink.TemplateEngine.Services
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||
@inject DeploymentService DeploymentService
|
||||
@inject InstanceService InstanceService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Instances</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>Create Instance</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
|
||||
{
|
||||
@* Filters *@
|
||||
<div class="row mb-3 g-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Site</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterSiteId" @bind:after="ApplyFilters">
|
||||
<option value="0">All Sites</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Template</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterTemplateId" @bind:after="ApplyFilters">
|
||||
<option value="0">All Templates</option>
|
||||
@foreach (var tmpl in _templates)
|
||||
{
|
||||
<option value="@tmpl.Id">@tmpl.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Status</label>
|
||||
<select class="form-select form-select-sm" @bind="_filterStatus" @bind:after="ApplyFilters">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="NotDeployed">Not Deployed</option>
|
||||
<option value="Enabled">Enabled</option>
|
||||
<option value="Disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Search</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Instance name..."
|
||||
@bind="_filterSearch" @bind:event="oninput" @bind:after="ApplyFilters" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Instance Name</th>
|
||||
<th>Template</th>
|
||||
<th>Site</th>
|
||||
<th>Area</th>
|
||||
<th>Status</th>
|
||||
<th>Staleness</th>
|
||||
<th style="width: 240px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_filteredInstances.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted text-center">No instances match the current filters.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var inst in _pagedInstances)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@inst.UniqueName</strong></td>
|
||||
<td>@GetTemplateName(inst.TemplateId)</td>
|
||||
<td>@GetSiteName(inst.SiteId)</td>
|
||||
<td>@(inst.AreaId.HasValue ? GetAreaName(inst.AreaId.Value) : "—")</td>
|
||||
<td>
|
||||
<span class="badge @GetStateBadge(inst.State)">@inst.State</span>
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
var isStale = _stalenessMap.GetValueOrDefault(inst.Id);
|
||||
}
|
||||
@if (inst.State == InstanceState.NotDeployed)
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
else if (isStale)
|
||||
{
|
||||
<span class="badge bg-warning text-dark" title="Template changes pending">Stale</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-dark">Current</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => DeployInstance(inst)" disabled="@_actionInProgress"
|
||||
title="Flatten template and send config to site">@(isStale ? "Redeploy" : "Deploy")</button>
|
||||
@if (inst.State == InstanceState.Enabled)
|
||||
{
|
||||
<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => DisableInstance(inst)" disabled="@_actionInProgress">Disable</button>
|
||||
}
|
||||
else if (inst.State == InstanceState.Disabled)
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EnableInstance(inst)" disabled="@_actionInProgress">Enable</button>
|
||||
}
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleBindings(inst)">Bindings</button>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ToggleOverrides(inst)">Overrides</button>
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => ShowDiff(inst)" disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteInstance(inst)" disabled="@_actionInProgress">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (_overrideInstanceId == inst.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="bg-light p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Attribute Overrides for @inst.UniqueName</strong>
|
||||
<div>
|
||||
<label class="form-label small d-inline me-1">Reassign Area:</label>
|
||||
<select class="form-select form-select-sm d-inline-block me-1" style="width:auto;" @bind="_reassignAreaId">
|
||||
<option value="0">No area</option>
|
||||
@foreach (var a in _allAreas.Where(a => a.SiteId == inst.SiteId))
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => ReassignArea(inst)" disabled="@_actionInProgress">Set Area</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_overrideAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No overridable (non-locked) attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-2">
|
||||
<thead class="table-light">
|
||||
<tr><th>Attribute</th><th>Template Value</th><th>Override Value</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _overrideAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name <span class="badge bg-light text-dark">@attr.DataType</span></td>
|
||||
<td class="small text-muted">@(attr.Value ?? "—")</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
value="@GetOverrideValue(attr.Name)"
|
||||
@onchange="(e) => OnOverrideChanged(attr.Name, e)" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveOverrides" disabled="@_actionInProgress">Save Overrides</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (_bindingInstanceId == inst.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="bg-light p-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Connection Bindings for @inst.UniqueName</strong>
|
||||
@if (_bindingDataSourceAttrs.Count > 0 && _siteConnections.Count > 0)
|
||||
{
|
||||
<div>
|
||||
<select class="form-select form-select-sm d-inline-block me-1" style="width:auto;" @bind="_bulkConnectionId">
|
||||
<option value="0">Select connection...</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name (@c.Protocol)</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ApplyBulkBinding" disabled="@(_bulkConnectionId == 0)">Assign All</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (_bindingDataSourceAttrs.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No data-sourced attributes in this template.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-2">
|
||||
<thead class="table-light">
|
||||
<tr><th>Attribute</th><th>Tag Path</th><th>Connection</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" value="@GetBindingConnectionId(attr.Name)"
|
||||
@onchange="(e) => OnBindingChanged(attr.Name, e)">
|
||||
<option value="0">— none —</option>
|
||||
@foreach (var c in _siteConnections)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_actionInProgress">Save Bindings</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@* Pagination *@
|
||||
@if (_totalPages > 1)
|
||||
{
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm justify-content-end">
|
||||
<li class="page-item @(_currentPage <= 1 ? "disabled" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(_currentPage - 1)">Previous</button>
|
||||
</li>
|
||||
@for (int i = 1; i <= _totalPages; i++)
|
||||
{
|
||||
var page = i;
|
||||
<li class="page-item @(page == _currentPage ? "active" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(page)">@(page)</button>
|
||||
</li>
|
||||
}
|
||||
<li class="page-item @(_currentPage >= _totalPages ? "disabled" : "")">
|
||||
<button class="page-link" @onclick="() => GoToPage(_currentPage + 1)">Next</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
<div class="text-muted small">
|
||||
@_filteredInstances.Count instance(s) total
|
||||
</div>
|
||||
|
||||
@* Diff Modal *@
|
||||
@if (_showDiffModal)
|
||||
{
|
||||
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Deployment Diff — @_diffInstanceName</h5>
|
||||
<button type="button" class="btn-close" @onclick="() => _showDiffModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_diffLoading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_diffError != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_diffError</div>
|
||||
}
|
||||
else if (_diffResult != null)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge @(_diffResult.IsStale ? "bg-warning text-dark" : "bg-success")">
|
||||
@(_diffResult.IsStale ? "Stale — changes pending" : "Current")
|
||||
</span>
|
||||
<span class="text-muted small ms-2">
|
||||
Deployed: @_diffResult.DeployedRevisionHash[..8]
|
||||
| Current: @_diffResult.CurrentRevisionHash[..8]
|
||||
| Deployed at: @_diffResult.DeployedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")
|
||||
</span>
|
||||
</div>
|
||||
@if (!_diffResult.IsStale)
|
||||
{
|
||||
<p class="text-muted">No differences between deployed and current configuration.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small">The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes.</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => _showDiffModal = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
|
||||
private List<Instance> _allInstances = new();
|
||||
private List<Instance> _filteredInstances = new();
|
||||
private List<Instance> _pagedInstances = new();
|
||||
private List<Site> _sites = new();
|
||||
private List<Template> _templates = new();
|
||||
private List<Area> _allAreas = new();
|
||||
private Dictionary<int, bool> _stalenessMap = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private bool _actionInProgress;
|
||||
|
||||
private int _filterSiteId;
|
||||
private int _filterTemplateId;
|
||||
private string _filterStatus = string.Empty;
|
||||
private string _filterSearch = string.Empty;
|
||||
|
||||
private int _currentPage = 1;
|
||||
private int _totalPages;
|
||||
private const int PageSize = 25;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
|
||||
// Load areas for all sites
|
||||
_allAreas.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||
_allAreas.AddRange(areas);
|
||||
}
|
||||
|
||||
// Check staleness for deployed instances
|
||||
_stalenessMap.Clear();
|
||||
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
|
||||
{
|
||||
try
|
||||
{
|
||||
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_stalenessMap[inst.Id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyFilters();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load instances: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
_filteredInstances = _allInstances.Where(i =>
|
||||
{
|
||||
if (_filterSiteId > 0 && i.SiteId != _filterSiteId) return false;
|
||||
if (_filterTemplateId > 0 && i.TemplateId != _filterTemplateId) return false;
|
||||
if (!string.IsNullOrEmpty(_filterStatus) && i.State.ToString() != _filterStatus) return false;
|
||||
if (!string.IsNullOrWhiteSpace(_filterSearch) &&
|
||||
!i.UniqueName.Contains(_filterSearch, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return true;
|
||||
}).OrderBy(i => i.UniqueName).ToList();
|
||||
|
||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_filteredInstances.Count / (double)PageSize));
|
||||
if (_currentPage > _totalPages) _currentPage = 1;
|
||||
UpdatePage();
|
||||
}
|
||||
|
||||
private void GoToPage(int page)
|
||||
{
|
||||
if (page < 1 || page > _totalPages) return;
|
||||
_currentPage = page;
|
||||
UpdatePage();
|
||||
}
|
||||
|
||||
private void UpdatePage()
|
||||
{
|
||||
_pagedInstances = _filteredInstances
|
||||
.Skip((_currentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string GetTemplateName(int templateId) =>
|
||||
_templates.FirstOrDefault(t => t.Id == templateId)?.Name ?? $"#{templateId}";
|
||||
|
||||
private string GetSiteName(int siteId) =>
|
||||
_sites.FirstOrDefault(s => s.Id == siteId)?.Name ?? $"#{siteId}";
|
||||
|
||||
private string GetAreaName(int areaId) =>
|
||||
_allAreas.FirstOrDefault(a => a.Id == areaId)?.Name ?? $"#{areaId}";
|
||||
|
||||
private static string GetStateBadge(InstanceState state) => state switch
|
||||
{
|
||||
InstanceState.Enabled => "bg-success",
|
||||
InstanceState.Disabled => "bg-secondary",
|
||||
InstanceState.NotDeployed => "bg-light text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private async Task EnableInstance(Instance inst)
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Enable failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Enable failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DisableInstance(Instance inst)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||
"Disable Instance");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Disable failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Disable failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DeployInstance(Instance inst)
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Deploy failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Deploy failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DeleteInstance(Instance inst)
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
|
||||
"Delete Instance");
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
// Override state
|
||||
private int _overrideInstanceId;
|
||||
private List<TemplateAttribute> _overrideAttrs = new();
|
||||
private Dictionary<string, string?> _overrideValues = new();
|
||||
private int _reassignAreaId;
|
||||
|
||||
private async Task ToggleOverrides(Instance inst)
|
||||
{
|
||||
if (_overrideInstanceId == inst.Id) { _overrideInstanceId = 0; return; }
|
||||
_overrideInstanceId = inst.Id;
|
||||
_overrideValues.Clear();
|
||||
_reassignAreaId = inst.AreaId ?? 0;
|
||||
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
||||
_overrideAttrs = attrs.Where(a => !a.IsLocked).ToList();
|
||||
|
||||
var overrides = await TemplateEngineRepository.GetOverridesByInstanceIdAsync(inst.Id);
|
||||
foreach (var o in overrides)
|
||||
{
|
||||
_overrideValues[o.AttributeName] = o.OverrideValue;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetOverrideValue(string attrName) =>
|
||||
_overrideValues.GetValueOrDefault(attrName);
|
||||
|
||||
private void OnOverrideChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = e.Value?.ToString();
|
||||
if (string.IsNullOrEmpty(val))
|
||||
_overrideValues.Remove(attrName);
|
||||
else
|
||||
_overrideValues[attrName] = val;
|
||||
}
|
||||
|
||||
private async Task SaveOverrides()
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
foreach (var (attrName, value) in _overrideValues)
|
||||
{
|
||||
await InstanceService.SetAttributeOverrideAsync(_overrideInstanceId, attrName, value, user);
|
||||
}
|
||||
_toast.ShowSuccess($"Saved {_overrideValues.Count} override(s).");
|
||||
_overrideInstanceId = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Save overrides failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task ReassignArea(Instance inst)
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.AssignToAreaAsync(inst.Id, _reassignAreaId == 0 ? null : _reassignAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Area reassigned for '{inst.UniqueName}'.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Reassign failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Reassign failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
// Diff state
|
||||
private bool _showDiffModal;
|
||||
private bool _diffLoading;
|
||||
private string? _diffError;
|
||||
private string _diffInstanceName = string.Empty;
|
||||
private DeploymentComparisonResult? _diffResult;
|
||||
|
||||
private async Task ShowDiff(Instance inst)
|
||||
{
|
||||
_showDiffModal = true;
|
||||
_diffLoading = true;
|
||||
_diffError = null;
|
||||
_diffResult = null;
|
||||
_diffInstanceName = inst.UniqueName;
|
||||
try
|
||||
{
|
||||
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_diffResult = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_diffError = result.Error;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_diffError = $"Failed to load diff: {ex.Message}";
|
||||
}
|
||||
_diffLoading = false;
|
||||
}
|
||||
|
||||
// Connection binding state
|
||||
private int _bindingInstanceId;
|
||||
private List<TemplateAttribute> _bindingDataSourceAttrs = new();
|
||||
private List<DataConnection> _siteConnections = new();
|
||||
private Dictionary<string, int> _bindingSelections = new();
|
||||
private int _bulkConnectionId;
|
||||
|
||||
private async Task ToggleBindings(Instance inst)
|
||||
{
|
||||
if (_bindingInstanceId == inst.Id)
|
||||
{
|
||||
_bindingInstanceId = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_bindingInstanceId = inst.Id;
|
||||
_bindingSelections.Clear();
|
||||
_bulkConnectionId = 0;
|
||||
|
||||
// Load template attributes with DataSourceReference
|
||||
var attrs = await TemplateEngineRepository.GetAttributesByTemplateIdAsync(inst.TemplateId);
|
||||
_bindingDataSourceAttrs = attrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).ToList();
|
||||
|
||||
// Load data connections for this site
|
||||
_siteConnections = (await SiteRepository.GetDataConnectionsBySiteIdAsync(inst.SiteId)).ToList();
|
||||
if (_siteConnections.Count == 0)
|
||||
{
|
||||
// Also show unassigned connections (they may not be assigned to a site yet)
|
||||
_siteConnections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
||||
}
|
||||
|
||||
// Load existing bindings
|
||||
var existingBindings = await TemplateEngineRepository.GetBindingsByInstanceIdAsync(inst.Id);
|
||||
foreach (var b in existingBindings)
|
||||
{
|
||||
_bindingSelections[b.AttributeName] = b.DataConnectionId;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetBindingConnectionId(string attrName)
|
||||
{
|
||||
return _bindingSelections.GetValueOrDefault(attrName, 0);
|
||||
}
|
||||
|
||||
private void OnBindingChanged(string attrName, ChangeEventArgs e)
|
||||
{
|
||||
var val = int.TryParse(e.Value?.ToString(), out var id) ? id : 0;
|
||||
SetBinding(attrName, val);
|
||||
}
|
||||
|
||||
private void SetBinding(string attrName, int connectionId)
|
||||
{
|
||||
if (connectionId == 0)
|
||||
_bindingSelections.Remove(attrName);
|
||||
else
|
||||
_bindingSelections[attrName] = connectionId;
|
||||
}
|
||||
|
||||
private void ApplyBulkBinding()
|
||||
{
|
||||
if (_bulkConnectionId == 0) return;
|
||||
foreach (var attr in _bindingDataSourceAttrs)
|
||||
{
|
||||
_bindingSelections[attr.Name] = _bulkConnectionId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveBindings()
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var bindings = _bindingSelections
|
||||
.Select(kv => (kv.Key, kv.Value))
|
||||
.ToList();
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.SetConnectionBindingsAsync(
|
||||
_bindingInstanceId, bindings, user);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Saved {bindings.Count} connection bindings.");
|
||||
_bindingInstanceId = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Save failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Save failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move area '@AreaName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetParentId">
|
||||
@foreach (var opt in ParentOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int AreaId { get; set; }
|
||||
[Parameter] public string AreaName { get; set; } = string.Empty;
|
||||
[Parameter] public int? CurrentParentId { get; set; }
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int AreaId, int? NewParentId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetParentId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetParentId = CurrentParentId;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit() => await OnSubmit.InvokeAsync((AreaId, _targetParentId));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@if (IsVisible)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">Move '@InstanceName' to…</h6>
|
||||
<button type="button" class="btn-close" @onclick="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<select class="form-select form-select-sm" @bind="_targetAreaId">
|
||||
@foreach (var opt in AreaOptions)
|
||||
{
|
||||
<option value="@opt.Id">@opt.Label</option>
|
||||
}
|
||||
</select>
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
|
||||
[Parameter] public int InstanceId { get; set; }
|
||||
[Parameter] public string InstanceName { get; set; } = string.Empty;
|
||||
[Parameter] public int? CurrentAreaId { get; set; }
|
||||
[Parameter] public IEnumerable<(int? Id, string Label)> AreaOptions { get; set; } = Array.Empty<(int?, string)>();
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
[Parameter] public EventCallback<(int InstanceId, int? NewAreaId)> OnSubmit { get; set; }
|
||||
|
||||
private bool _wasVisible;
|
||||
private int? _targetAreaId;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (IsVisible && !_wasVisible)
|
||||
{
|
||||
_targetAreaId = CurrentAreaId;
|
||||
}
|
||||
_wasVisible = IsVisible;
|
||||
}
|
||||
|
||||
private async Task Close() => await IsVisibleChanged.InvokeAsync(false);
|
||||
|
||||
private async Task Submit() => await OnSubmit.InvokeAsync((InstanceId, _targetAreaId));
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
@page "/deployment/topology"
|
||||
@page "/deployment/instances"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Instances
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Entities.Templates
|
||||
@using ScadaLink.Commons.Entities.Deployment
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@using ScadaLink.DeploymentManager
|
||||
@using ScadaLink.TemplateEngine.Services
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||
@inject DeploymentService DeploymentService
|
||||
@inject AreaService AreaService
|
||||
@inject InstanceService InstanceService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IDialogService Dialog
|
||||
@implements IDisposable
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<CreateAreaDialog @bind-IsVisible="_showCreateAreaDialog"
|
||||
RequireSitePicker="_createAreaRequireSitePicker"
|
||||
ContextLabel="@_createAreaContextLabel"
|
||||
SiteId="_createAreaSiteId"
|
||||
ParentAreaId="_createAreaParentId"
|
||||
SiteOptions="EnumerateSiteOptions()"
|
||||
ParentOptions="EnumerateAreaOptionsForCreate()"
|
||||
ErrorMessage="@_createAreaError"
|
||||
OnSubmit="SubmitCreateArea" />
|
||||
|
||||
<MoveAreaDialog @bind-IsVisible="_showMoveAreaDialog"
|
||||
AreaId="_moveAreaId"
|
||||
AreaName="@_moveAreaName"
|
||||
CurrentParentId="_moveAreaCurrentParentId"
|
||||
ParentOptions="EnumerateAreaParentOptionsExcluding(_moveAreaId, _moveAreaSiteId)"
|
||||
ErrorMessage="@_moveAreaError"
|
||||
OnSubmit="SubmitMoveArea" />
|
||||
|
||||
<MoveInstanceDialog @bind-IsVisible="_showMoveInstanceDialog"
|
||||
InstanceId="_moveInstanceId"
|
||||
InstanceName="@_moveInstanceName"
|
||||
CurrentAreaId="_moveInstanceCurrentAreaId"
|
||||
AreaOptions="EnumerateAreaOptionsForSite(_moveInstanceSiteId)"
|
||||
ErrorMessage="@_moveInstanceError"
|
||||
OnSubmit="SubmitMoveInstance" />
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Topology</h4>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-2 gap-2 flex-wrap">
|
||||
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
|
||||
placeholder="Search sites, areas, instances..."
|
||||
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
|
||||
<button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
|
||||
<button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
|
||||
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
||||
</div>
|
||||
<div class="form-check form-switch ms-2 mb-0">
|
||||
<input type="checkbox" class="form-check-input" id="live-updates"
|
||||
checked="@_liveUpdates" @onchange="OnLiveUpdatesToggled" />
|
||||
<label class="form-check-label small" for="live-updates">Live updates</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light py-2 mb-3 small">
|
||||
@_allAreas.Count area(s) · @_allInstances.Count instance(s) across @_sites.Count site(s).
|
||||
</div>
|
||||
|
||||
<div style="max-height: calc(100vh - 240px); overflow-y: auto; padding: 4px;">
|
||||
<TreeView @ref="_tree" TItem="TopoNode" Items="_treeRoots"
|
||||
ChildrenSelector="n => n.Children"
|
||||
HasChildrenSelector="n => n.Children.Count > 0"
|
||||
KeySelector="n => (object)n.Key"
|
||||
StorageKey="topology-tree"
|
||||
Selectable="true"
|
||||
SelectedKey="_selectedKey"
|
||||
SelectedKeyChanged="OnTreeNodeSelected">
|
||||
<NodeContent Context="node">
|
||||
@RenderNodeLabel(node)
|
||||
</NodeContent>
|
||||
<ContextMenu Context="node">
|
||||
@RenderNodeContextMenu(node)
|
||||
</ContextMenu>
|
||||
<EmptyContent>
|
||||
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
|
||||
</EmptyContent>
|
||||
</TreeView>
|
||||
</div>
|
||||
|
||||
<DiffDialog @ref="_diffDialog" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ---- Data ----
|
||||
private List<Instance> _allInstances = new();
|
||||
private List<Site> _sites = new();
|
||||
private List<Template> _templates = new();
|
||||
private List<Area> _allAreas = new();
|
||||
private Dictionary<int, bool> _stalenessMap = new();
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private bool _actionInProgress;
|
||||
|
||||
private string _searchText = string.Empty;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
private DiffDialog _diffDialog = default!;
|
||||
|
||||
// ---- Live updates ----
|
||||
private bool _liveUpdates = true;
|
||||
private Timer? _liveUpdatesTimer;
|
||||
private static readonly TimeSpan LiveUpdatesInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private TreeView<TopoNode> _tree = default!;
|
||||
private object? _selectedKey;
|
||||
private const string SelectedKeyStorage = "topology-tree-selected";
|
||||
|
||||
// ---- Tree model ----
|
||||
private enum TopoNodeKind { Site, Area, Instance }
|
||||
|
||||
private record TopoNode(
|
||||
string Key,
|
||||
TopoNodeKind Kind,
|
||||
int EntityId,
|
||||
int SiteId,
|
||||
string Label,
|
||||
Site? Site,
|
||||
Area? Area,
|
||||
Instance? Instance,
|
||||
bool IsStale,
|
||||
bool MatchesSearch,
|
||||
List<TopoNode> Children);
|
||||
|
||||
private List<TopoNode> _treeRoots = new();
|
||||
|
||||
// ---- Inline rename ----
|
||||
private string? _renamingKey;
|
||||
private string _renameBuffer = string.Empty;
|
||||
private string? _renameError;
|
||||
|
||||
// ---- Lifecycle ----
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StartLiveUpdatesTimer();
|
||||
}
|
||||
|
||||
private void StartLiveUpdatesTimer()
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
if (!_liveUpdates) return;
|
||||
_liveUpdatesTimer = new Timer(_ =>
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (!_liveUpdates) return;
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, LiveUpdatesInterval, LiveUpdatesInterval);
|
||||
}
|
||||
|
||||
private void OnLiveUpdatesToggled(ChangeEventArgs e)
|
||||
{
|
||||
_liveUpdates = e.Value is bool b && b;
|
||||
if (_liveUpdates)
|
||||
{
|
||||
StartLiveUpdatesTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
_liveUpdatesTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_liveUpdatesTimer?.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stored = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", SelectedKeyStorage);
|
||||
if (!string.IsNullOrEmpty(stored))
|
||||
{
|
||||
_selectedKey = stored;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
catch { /* no JS interop available (prerender, tests) — ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
|
||||
_allAreas.Clear();
|
||||
foreach (var site in _sites)
|
||||
{
|
||||
var areas = await TemplateEngineRepository.GetAreasBySiteIdAsync(site.Id);
|
||||
_allAreas.AddRange(areas);
|
||||
}
|
||||
|
||||
_stalenessMap.Clear();
|
||||
foreach (var inst in _allInstances.Where(i => i.State != InstanceState.NotDeployed))
|
||||
{
|
||||
try
|
||||
{
|
||||
var comparison = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||
_stalenessMap[inst.Id] = comparison.IsSuccess && comparison.Value.IsStale;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_stalenessMap[inst.Id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
BuildTree();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errorMessage = $"Failed to load topology: {ex.Message}";
|
||||
}
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void OnSearchChanged()
|
||||
{
|
||||
BuildTree();
|
||||
}
|
||||
|
||||
private bool NodeMatchesSearch(string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchText)) return true;
|
||||
return label.Contains(_searchText, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void BuildTree()
|
||||
{
|
||||
_treeRoots = _sites
|
||||
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(site =>
|
||||
{
|
||||
var siteAreas = _allAreas.Where(a => a.SiteId == site.Id).ToList();
|
||||
var siteInstances = _allInstances.Where(i => i.SiteId == site.Id).ToList();
|
||||
|
||||
var areaChildren = BuildAreaNodes(siteAreas, siteInstances, parentId: null);
|
||||
var rootInstances = siteInstances
|
||||
.Where(i => i.AreaId == null)
|
||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(MakeInstanceNode)
|
||||
.ToList();
|
||||
|
||||
var children = areaChildren.Concat(rootInstances).ToList();
|
||||
|
||||
return new TopoNode(
|
||||
Key: $"s:{site.Id}",
|
||||
Kind: TopoNodeKind.Site,
|
||||
EntityId: site.Id,
|
||||
SiteId: site.Id,
|
||||
Label: site.Name,
|
||||
Site: site,
|
||||
Area: null,
|
||||
Instance: null,
|
||||
IsStale: false,
|
||||
MatchesSearch: NodeMatchesSearch(site.Name),
|
||||
Children: children);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<TopoNode> BuildAreaNodes(List<Area> allAreas, List<Instance> instances, int? parentId)
|
||||
{
|
||||
return allAreas
|
||||
.Where(a => a.ParentAreaId == parentId)
|
||||
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(area =>
|
||||
{
|
||||
var childAreas = BuildAreaNodes(allAreas, instances, area.Id);
|
||||
var areaInstances = instances
|
||||
.Where(i => i.AreaId == area.Id)
|
||||
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(MakeInstanceNode)
|
||||
.ToList();
|
||||
var children = childAreas.Concat(areaInstances).ToList();
|
||||
return new TopoNode(
|
||||
Key: $"a:{area.Id}",
|
||||
Kind: TopoNodeKind.Area,
|
||||
EntityId: area.Id,
|
||||
SiteId: area.SiteId,
|
||||
Label: area.Name,
|
||||
Site: null,
|
||||
Area: area,
|
||||
Instance: null,
|
||||
IsStale: false,
|
||||
MatchesSearch: NodeMatchesSearch(area.Name),
|
||||
Children: children);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private TopoNode MakeInstanceNode(Instance inst) => new(
|
||||
Key: $"i:{inst.Id}",
|
||||
Kind: TopoNodeKind.Instance,
|
||||
EntityId: inst.Id,
|
||||
SiteId: inst.SiteId,
|
||||
Label: inst.UniqueName,
|
||||
Site: null,
|
||||
Area: null,
|
||||
Instance: inst,
|
||||
IsStale: _stalenessMap.GetValueOrDefault(inst.Id),
|
||||
MatchesSearch: NodeMatchesSearch(inst.UniqueName),
|
||||
Children: new List<TopoNode>());
|
||||
|
||||
// ---- Rendering ----
|
||||
private RenderFragment RenderNodeLabel(TopoNode node) => __builder =>
|
||||
{
|
||||
var dim = !string.IsNullOrWhiteSpace(_searchText) && !SubtreeContainsMatch(node);
|
||||
var labelStyle = dim ? "opacity: 0.4;" : null;
|
||||
|
||||
switch (node.Kind)
|
||||
{
|
||||
case TopoNodeKind.Site:
|
||||
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-building"></i></span>
|
||||
<span class="tv-label fw-semibold" style="@labelStyle" title="@node.Label">@node.Label</span>
|
||||
break;
|
||||
|
||||
case TopoNodeKind.Area:
|
||||
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-diagram-3"></i></span>
|
||||
@if (_renamingKey == node.Key)
|
||||
{
|
||||
<input class="form-control form-control-sm d-inline-block" style="width: auto; max-width: 220px;"
|
||||
aria-label="@($"Rename {node.Label}")"
|
||||
@ref="_renameInput"
|
||||
@bind="_renameBuffer"
|
||||
@onkeydown="(e) => OnRenameKeyDown(e, node)"
|
||||
@onblur="() => CancelRename()" />
|
||||
@if (!string.IsNullOrEmpty(_renameError))
|
||||
{
|
||||
<span class="text-danger small ms-2">@_renameError</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tv-label" style="@labelStyle" title="@node.Label"
|
||||
@ondblclick="() => BeginRename(node)">@node.Label</span>
|
||||
}
|
||||
break;
|
||||
|
||||
case TopoNodeKind.Instance:
|
||||
<span class="tv-glyph" style="@labelStyle"><i class="bi bi-box"></i></span>
|
||||
<span class="tv-label" style="@labelStyle" title="@node.Label">@node.Label</span>
|
||||
<span class="badge @GetStateBadge(node.Instance!.State) ms-1" style="@labelStyle">@node.Instance!.State</span>
|
||||
@if (node.Instance!.State != InstanceState.NotDeployed)
|
||||
{
|
||||
@if (node.IsStale)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1" style="@labelStyle" aria-label="State: Stale">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Stale
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-dark ms-1" style="@labelStyle" aria-label="State: Current">Current</span>
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private static bool SubtreeContainsMatch(TopoNode node)
|
||||
{
|
||||
if (node.MatchesSearch) return true;
|
||||
return node.Children.Any(SubtreeContainsMatch);
|
||||
}
|
||||
|
||||
private RenderFragment RenderNodeContextMenu(TopoNode node) => __builder =>
|
||||
{
|
||||
switch (node.Kind)
|
||||
{
|
||||
case TopoNodeKind.Site:
|
||||
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForSite(node.EntityId)">Add Area</button>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
|
||||
Create Instance here
|
||||
</button>
|
||||
break;
|
||||
|
||||
case TopoNodeKind.Area:
|
||||
<button class="dropdown-item" @onclick="() => OpenCreateAreaDialogForArea(node.SiteId, node.EntityId, node.Label)">Add Sub-area</button>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.SiteId}&areaId={node.EntityId}")'>
|
||||
Create Instance here
|
||||
</button>
|
||||
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
|
||||
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
|
||||
break;
|
||||
|
||||
case TopoNodeKind.Instance:
|
||||
var inst = node.Instance!;
|
||||
var isStale = node.IsStale;
|
||||
<button class="dropdown-item" @onclick="() => DeployInstance(inst)"
|
||||
disabled="@_actionInProgress">@(isStale ? "Redeploy" : "Deploy")</button>
|
||||
@if (inst.State == InstanceState.Enabled)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => DisableInstance(inst)"
|
||||
disabled="@_actionInProgress">Disable</button>
|
||||
}
|
||||
else if (inst.State == InstanceState.Disabled)
|
||||
{
|
||||
<button class="dropdown-item" @onclick="() => EnableInstance(inst)"
|
||||
disabled="@_actionInProgress">Enable</button>
|
||||
}
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/{inst.Id}/configure")'>
|
||||
Configure
|
||||
</button>
|
||||
<button class="dropdown-item"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/deployment/debug-view?siteId={node.SiteId}&instanceId={inst.Id}")'
|
||||
disabled="@(inst.State != InstanceState.Enabled)">
|
||||
Debug View
|
||||
</button>
|
||||
<button class="dropdown-item" @onclick="() => ShowDiff(inst)"
|
||||
disabled="@(_actionInProgress || inst.State == InstanceState.NotDeployed)">Diff</button>
|
||||
<button class="dropdown-item" @onclick="() => OpenMoveInstanceDialog(node)">Move to Area…</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @onclick="() => DeleteInstance(inst)"
|
||||
disabled="@_actionInProgress">Delete</button>
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private static string GetStateBadge(InstanceState state) => state switch
|
||||
{
|
||||
InstanceState.Enabled => "bg-success",
|
||||
InstanceState.Disabled => "bg-secondary",
|
||||
InstanceState.NotDeployed => "bg-light text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
// ---- Selection ----
|
||||
private async Task OnTreeNodeSelected(object? key)
|
||||
{
|
||||
_selectedKey = key;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("treeviewStorage.save", SelectedKeyStorage,
|
||||
key?.ToString() ?? string.Empty);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ---- Inline rename ----
|
||||
private ElementReference _renameInput;
|
||||
|
||||
private void BeginRename(TopoNode node)
|
||||
{
|
||||
if (node.Kind != TopoNodeKind.Area) return;
|
||||
_renamingKey = node.Key;
|
||||
_renameBuffer = node.Label;
|
||||
_renameError = null;
|
||||
}
|
||||
|
||||
private void CancelRename()
|
||||
{
|
||||
_renamingKey = null;
|
||||
_renameBuffer = string.Empty;
|
||||
_renameError = null;
|
||||
}
|
||||
|
||||
private async Task OnRenameKeyDown(KeyboardEventArgs e, TopoNode node)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
CancelRename();
|
||||
}
|
||||
else if (e.Key == "Enter")
|
||||
{
|
||||
await CommitRename(node);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CommitRename(TopoNode node)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_renameBuffer) || _renameBuffer.Trim() == node.Label)
|
||||
{
|
||||
CancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
CancelRename();
|
||||
_toast.ShowSuccess($"Area renamed to '{result.Value.Name}'.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_renameError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Create-area dialog ----
|
||||
private bool _showCreateAreaDialog;
|
||||
private bool _createAreaRequireSitePicker;
|
||||
private string _createAreaContextLabel = string.Empty;
|
||||
private int? _createAreaSiteId;
|
||||
private int? _createAreaParentId;
|
||||
private string? _createAreaError;
|
||||
|
||||
private void OpenCreateAreaDialogRoot()
|
||||
{
|
||||
_createAreaRequireSitePicker = true;
|
||||
_createAreaContextLabel = string.Empty;
|
||||
_createAreaSiteId = null;
|
||||
_createAreaParentId = null;
|
||||
_createAreaError = null;
|
||||
_showCreateAreaDialog = true;
|
||||
}
|
||||
|
||||
private void OpenCreateAreaDialogForSite(int siteId)
|
||||
{
|
||||
var site = _sites.FirstOrDefault(s => s.Id == siteId);
|
||||
_createAreaRequireSitePicker = false;
|
||||
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} (root)";
|
||||
_createAreaSiteId = siteId;
|
||||
_createAreaParentId = null;
|
||||
_createAreaError = null;
|
||||
_showCreateAreaDialog = true;
|
||||
}
|
||||
|
||||
private void OpenCreateAreaDialogForArea(int siteId, int parentAreaId, string parentLabel)
|
||||
{
|
||||
var site = _sites.FirstOrDefault(s => s.Id == siteId);
|
||||
_createAreaRequireSitePicker = false;
|
||||
_createAreaContextLabel = $"Site: {site?.Name ?? $"#{siteId}"} → Parent: {parentLabel}";
|
||||
_createAreaSiteId = siteId;
|
||||
_createAreaParentId = parentAreaId;
|
||||
_createAreaError = null;
|
||||
_showCreateAreaDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitCreateArea((int SiteId, int? ParentAreaId, string Name) req)
|
||||
{
|
||||
_createAreaError = null;
|
||||
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
|
||||
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
|
||||
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showCreateAreaDialog = false;
|
||||
_toast.ShowSuccess($"Area '{result.Value.Name}' created.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_createAreaError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Move-area dialog ----
|
||||
private bool _showMoveAreaDialog;
|
||||
private int _moveAreaId;
|
||||
private string _moveAreaName = string.Empty;
|
||||
private int _moveAreaSiteId;
|
||||
private int? _moveAreaCurrentParentId;
|
||||
private string? _moveAreaError;
|
||||
|
||||
private void OpenMoveAreaDialog(TopoNode node)
|
||||
{
|
||||
_moveAreaId = node.EntityId;
|
||||
_moveAreaName = node.Label;
|
||||
_moveAreaSiteId = node.SiteId;
|
||||
_moveAreaCurrentParentId = node.Area?.ParentAreaId;
|
||||
_moveAreaError = null;
|
||||
_showMoveAreaDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
|
||||
{
|
||||
_moveAreaError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showMoveAreaDialog = false;
|
||||
_toast.ShowSuccess($"Area '{_moveAreaName}' moved.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_moveAreaError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Move-instance dialog ----
|
||||
private bool _showMoveInstanceDialog;
|
||||
private int _moveInstanceId;
|
||||
private string _moveInstanceName = string.Empty;
|
||||
private int _moveInstanceSiteId;
|
||||
private int? _moveInstanceCurrentAreaId;
|
||||
private string? _moveInstanceError;
|
||||
|
||||
private void OpenMoveInstanceDialog(TopoNode node)
|
||||
{
|
||||
var inst = node.Instance!;
|
||||
_moveInstanceId = inst.Id;
|
||||
_moveInstanceName = inst.UniqueName;
|
||||
_moveInstanceSiteId = inst.SiteId;
|
||||
_moveInstanceCurrentAreaId = inst.AreaId;
|
||||
_moveInstanceError = null;
|
||||
_showMoveInstanceDialog = true;
|
||||
}
|
||||
|
||||
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
|
||||
{
|
||||
_moveInstanceError = null;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_showMoveInstanceDialog = false;
|
||||
_toast.ShowSuccess($"Instance '{_moveInstanceName}' moved.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_moveInstanceError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Area & instance deletion ----
|
||||
private async Task DeleteArea(TopoNode node)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Area",
|
||||
$"Delete area '{node.Label}'? This will fail if it has sub-areas or assigned instances.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError(result.Error);
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DeleteInstance(Instance inst)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete Instance",
|
||||
$"Delete instance '{inst.UniqueName}'? This will remove it from the site. Store-and-forward messages will NOT be cleared.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Delete failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
// ---- Lifecycle actions ----
|
||||
private async Task EnableInstance(Instance inst)
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' enabled.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Enable failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Enable failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DisableInstance(Instance inst)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Disable Instance",
|
||||
$"Disable instance '{inst.UniqueName}'? The instance actor will be stopped.",
|
||||
danger: true);
|
||||
if (!confirmed) return;
|
||||
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' disabled.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Disable failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Disable failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
private async Task DeployInstance(Instance inst)
|
||||
{
|
||||
_actionInProgress = true;
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUserAsync();
|
||||
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast.ShowError($"Deploy failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_toast.ShowError($"Deploy failed: {ex.Message}");
|
||||
}
|
||||
_actionInProgress = false;
|
||||
}
|
||||
|
||||
// ---- Diff modal ----
|
||||
private async Task ShowDiff(Instance inst)
|
||||
{
|
||||
DeploymentComparisonResult? diffResult = null;
|
||||
string? diffError = null;
|
||||
try
|
||||
{
|
||||
var result = await DeploymentService.GetDeploymentComparisonAsync(inst.Id);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
diffResult = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
diffError = result.Error;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
diffError = $"Failed to load diff: {ex.Message}";
|
||||
}
|
||||
|
||||
RenderFragment body = builder =>
|
||||
{
|
||||
if (diffError != null)
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "alert alert-danger");
|
||||
builder.AddContent(2, diffError);
|
||||
builder.CloseElement();
|
||||
}
|
||||
else if (diffResult != null)
|
||||
{
|
||||
var stale = diffResult.IsStale;
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "mb-2");
|
||||
builder.OpenElement(2, "span");
|
||||
builder.AddAttribute(3, "class", stale ? "badge bg-warning text-dark" : "badge bg-success");
|
||||
builder.AddContent(4, stale ? "Stale — changes pending" : "Current");
|
||||
builder.CloseElement();
|
||||
builder.OpenElement(5, "span");
|
||||
builder.AddAttribute(6, "class", "text-muted small ms-2");
|
||||
builder.AddContent(7,
|
||||
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
|
||||
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
|
||||
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "p");
|
||||
builder.AddAttribute(9, "class", "text-muted small mb-0");
|
||||
builder.AddContent(10, stale
|
||||
? "The deployed revision hash differs from the current template-derived hash. Redeploy to apply changes."
|
||||
: "No differences between deployed and current configuration.");
|
||||
builder.CloseElement();
|
||||
}
|
||||
};
|
||||
|
||||
await _diffDialog.ShowAsync($"Deployment Diff — {inst.UniqueName}", body);
|
||||
}
|
||||
|
||||
// ---- Dropdown option helpers ----
|
||||
private IEnumerable<(int Id, string Label)> EnumerateSiteOptions()
|
||||
{
|
||||
foreach (var s in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
yield return (s.Id, s.Name);
|
||||
}
|
||||
|
||||
private IEnumerable<(int Id, string Label, int SiteId)> EnumerateAreaOptionsForCreate()
|
||||
{
|
||||
foreach (var a in WalkAllSiteAreas())
|
||||
yield return a;
|
||||
}
|
||||
|
||||
private IEnumerable<(int Id, string Label, int SiteId)> WalkAllSiteAreas()
|
||||
{
|
||||
foreach (var site in _sites.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var entry in WalkSiteHierarchy(site.Id, parentId: null, depth: 0, excludeAreaId: null))
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(int? Id, string Label)> EnumerateAreaOptionsForSite(int siteId)
|
||||
{
|
||||
yield return ((int?)null, "(No area — site root)");
|
||||
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: null))
|
||||
yield return ((int?)entry.Id, entry.Label);
|
||||
}
|
||||
|
||||
private IEnumerable<(int? Id, string Label)> EnumerateAreaParentOptionsExcluding(int areaId, int siteId)
|
||||
{
|
||||
yield return ((int?)null, "(Site root)");
|
||||
foreach (var entry in WalkSiteHierarchy(siteId, parentId: null, depth: 0, excludeAreaId: areaId))
|
||||
yield return ((int?)entry.Id, entry.Label);
|
||||
}
|
||||
|
||||
private IEnumerable<(int Id, string Label, int SiteId)> WalkSiteHierarchy(int siteId, int? parentId, int depth, int? excludeAreaId)
|
||||
{
|
||||
var levelAreas = _allAreas
|
||||
.Where(a => a.SiteId == siteId && a.ParentAreaId == parentId)
|
||||
.OrderBy(a => a.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var a in levelAreas)
|
||||
{
|
||||
if (excludeAreaId.HasValue && a.Id == excludeAreaId.Value) continue;
|
||||
yield return (a.Id, new string(' ', depth * 2) + a.Name, siteId);
|
||||
foreach (var sub in WalkSiteHierarchy(siteId, a.Id, depth + 1, excludeAreaId))
|
||||
yield return sub;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetCurrentUserAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
return authState.User.FindFirst("Username")?.Value ?? "unknown";
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.InboundApi
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@@ -29,16 +31,60 @@
|
||||
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Params (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_params" />
|
||||
<label class="form-label">Approved API Keys</label>
|
||||
@if (_allKeys.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API keys configured.
|
||||
<a href="/admin/api-keys">Create one</a> to authorize callers for this method.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers must present a checked key in the <code>X-API-Key</code> header to invoke this method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Returns (JSON)</label>
|
||||
<input type="text" class="form-control" @bind="_returns" />
|
||||
<label class="form-label">Parameters</label>
|
||||
<SchemaBuilder Mode="object"
|
||||
Value="@_params"
|
||||
ValueChanged="@(v => _params = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Return value</label>
|
||||
<SchemaBuilder Mode="value"
|
||||
Value="@_returns"
|
||||
ValueChanged="@(v => _returns = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
<textarea class="form-control font-monospace" rows="5" @bind="_script" style="font-size: 0.85rem;"></textarea>
|
||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
Language="csharp" Height="320px"
|
||||
ScriptKind="ScadaLink.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_params)"
|
||||
DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)"
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
@@ -48,10 +94,92 @@
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_showTestRun)
|
||||
{
|
||||
<div class="card mt-3" id="test-run-panel">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run</span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
|
||||
routing needs a deployed site reachable over the cluster transport.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_params"
|
||||
Values="_paramValues"
|
||||
ValuesChanged="@(v => _paramValues = v)" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center mb-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RunInSandboxAsync" disabled="@_running">
|
||||
@if (_running)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
<span>Running…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Run</span>
|
||||
}
|
||||
</button>
|
||||
@if (_runResult != null)
|
||||
{
|
||||
<span class="text-muted small">@_runResult.DurationMs ms</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_runResult != null)
|
||||
{
|
||||
@if (_runResult.Success)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-success mb-1">
|
||||
Return value <span class="badge bg-light text-dark ms-1">@_runResult.ReturnTypeName</span>
|
||||
</label>
|
||||
<pre class="bg-light border rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ReturnValueJson</pre>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-danger mb-1">
|
||||
<span class="badge bg-danger me-1">@ErrorKindLabel(_runResult.ErrorKind)</span>
|
||||
</label>
|
||||
<pre class="border border-danger-subtle rounded p-2 small mb-0 font-monospace text-danger" style="white-space: pre-wrap;">@_runResult.Error</pre>
|
||||
@if (_runResult.Markers is { Count: > 0 })
|
||||
{
|
||||
<ul class="small text-danger mt-2 mb-0">
|
||||
@foreach (var m in _runResult.Markers)
|
||||
{
|
||||
<li>Line @m.StartLineNumber, col @m.StartColumn: @m.Message <code class="ms-1">@m.Code</code></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_runResult.ConsoleOutput))
|
||||
{
|
||||
<div class="mb-0">
|
||||
<label class="form-label small mb-1">Console output</label>
|
||||
<pre class="bg-dark text-light rounded p-2 small mb-0 font-monospace" style="white-space: pre-wrap;">@_runResult.ConsoleOutput</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -63,11 +191,28 @@
|
||||
private int _timeoutSeconds = 30;
|
||||
private string? _params, _returns;
|
||||
private string? _formError;
|
||||
private MonacoEditor? _editor;
|
||||
private IReadOnlyList<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ScadaLink.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
private Dictionary<string, object?> _paramValues = new();
|
||||
private ScriptAnalysis.SandboxRunResult? _runResult;
|
||||
private CancellationTokenSource? _runCts;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
try
|
||||
@@ -80,6 +225,7 @@
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -87,6 +233,25 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
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 void ToggleKey(int keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
@@ -98,12 +263,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
}
|
||||
else
|
||||
@@ -112,7 +279,8 @@
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim()
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
}
|
||||
@@ -123,4 +291,53 @@
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
private async Task RunInSandboxAsync()
|
||||
{
|
||||
_runCts?.Cancel();
|
||||
_runCts = new CancellationTokenSource();
|
||||
_running = true;
|
||||
_runResult = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var jsonParams = _paramValues.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => System.Text.Json.JsonSerializer.SerializeToElement(kv.Value));
|
||||
var request = new ScriptAnalysis.SandboxRunRequest(
|
||||
_script, jsonParams, TimeoutSeconds: _timeoutSeconds,
|
||||
Kind: ScriptAnalysis.ScriptKind.InboundApi);
|
||||
_runResult = await AnalysisService.RunInSandboxAsync(request, _runCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { /* superseded by next Run click */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_runResult = new ScriptAnalysis.SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: $"Unexpected: {ex.GetType().Name}: {ex.Message}",
|
||||
ErrorKind: ScriptAnalysis.SandboxErrorKind.RuntimeError,
|
||||
DurationMs: 0,
|
||||
Markers: null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ErrorKindLabel(ScriptAnalysis.SandboxErrorKind kind) => kind switch
|
||||
{
|
||||
ScriptAnalysis.SandboxErrorKind.CompileError => "Compile error",
|
||||
ScriptAnalysis.SandboxErrorKind.SandboxLimitation => "Sandbox limitation",
|
||||
ScriptAnalysis.SandboxErrorKind.RuntimeError => "Runtime error",
|
||||
ScriptAnalysis.SandboxErrorKind.Timeout => "Timeout",
|
||||
_ => "Error"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
@page "/design/connections/create"
|
||||
@page "/design/connections/{Id:int}/edit"
|
||||
@page "/design/data-connections/create"
|
||||
@page "/design/data-connections/{Id:int}/edit"
|
||||
@using ScadaLink.Security
|
||||
@using ScadaLink.Commons.Entities.Sites
|
||||
@using ScadaLink.Commons.Interfaces.Repositories
|
||||
@using ScadaLink.Commons.Types.DataConnections
|
||||
@using ScadaLink.Commons.Types.Flattening
|
||||
@using ScadaLink.Commons.Serialization
|
||||
@using ScadaLink.Commons.Validators
|
||||
@using ScadaLink.CentralUI.Components.Forms
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3" @onclick="GoBack">← Back</button>
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit Data Connection" : "Add Data Connection")</h4>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
@if (_siteLocked)
|
||||
{
|
||||
<input type="text"
|
||||
class="form-control form-control-plaintext form-control-sm"
|
||||
readonly
|
||||
value="@_siteName" />
|
||||
<div class="form-text">Site is locked after creation.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="_formSiteId">
|
||||
<option value="0">Select site...</option>
|
||||
@foreach (var site in _sites)
|
||||
{
|
||||
<option value="@site.Id">@site.Name</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted mt-3">Primary endpoint</h6>
|
||||
<OpcUaEndpointEditor Title="Primary Endpoint"
|
||||
IdPrefix="primary"
|
||||
Config="_primaryConfig"
|
||||
IsLegacy="_primaryIsLegacy"
|
||||
Errors="_primaryErrors" />
|
||||
|
||||
<h6 class="text-muted mt-3">
|
||||
Backup endpoint
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<span class="badge bg-light text-muted border ms-2">Optional</span>
|
||||
}
|
||||
</h6>
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="EnableBackup">Add Backup Endpoint</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<OpcUaEndpointEditor Title="Backup Endpoint"
|
||||
IdPrefix="backup"
|
||||
Config="_backupConfig"
|
||||
IsLegacy="_backupIsLegacy"
|
||||
Errors="_backupErrors" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Failover Retry Count</label>
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 120px;"
|
||||
min="1" max="20" @bind="_formFailoverRetryCount" />
|
||||
<div class="form-text">Retries before failing over to backup endpoint.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="RemoveBackup">Remove Backup</button>
|
||||
</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="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
[SupplyParameterFromQuery] public int? SiteId { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private DataConnection? _editingConnection;
|
||||
private List<Site> _sites = new();
|
||||
private int _formSiteId;
|
||||
private string _siteName = string.Empty;
|
||||
private bool _siteLocked;
|
||||
private string _formName = string.Empty;
|
||||
private OpcUaEndpointConfig _primaryConfig = new();
|
||||
private OpcUaEndpointConfig _backupConfig = new();
|
||||
private bool _primaryIsLegacy;
|
||||
private bool _backupIsLegacy;
|
||||
private bool _showBackup;
|
||||
private int _formFailoverRetryCount = 3;
|
||||
private ValidationResult? _primaryErrors;
|
||||
private ValidationResult? _backupErrors;
|
||||
private string? _formError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
|
||||
if (Id.HasValue)
|
||||
{
|
||||
_editingConnection = await SiteRepository.GetDataConnectionByIdAsync(Id.Value);
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_formSiteId = _editingConnection.SiteId;
|
||||
_siteName = _sites.FirstOrDefault(s => s.Id == _formSiteId)?.Name ?? $"Site {_formSiteId}";
|
||||
_siteLocked = true;
|
||||
_formName = _editingConnection.Name;
|
||||
|
||||
(_primaryConfig, _primaryIsLegacy) =
|
||||
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.PrimaryConfiguration);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_editingConnection.BackupConfiguration))
|
||||
{
|
||||
(_backupConfig, _backupIsLegacy) =
|
||||
OpcUaEndpointConfigSerializer.Deserialize(_editingConnection.BackupConfiguration);
|
||||
_showBackup = true;
|
||||
_formFailoverRetryCount = _editingConnection.FailoverRetryCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (SiteId.HasValue)
|
||||
{
|
||||
var site = _sites.FirstOrDefault(s => s.Id == SiteId.Value);
|
||||
if (site != null)
|
||||
{
|
||||
_formSiteId = site.Id;
|
||||
_siteName = site.Name;
|
||||
_siteLocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveConnection()
|
||||
{
|
||||
_formError = null;
|
||||
if (_formSiteId == 0) { _formError = "Site is required."; return; }
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
_primaryErrors = OpcUaEndpointConfigValidator.Validate(_primaryConfig, "Primary.");
|
||||
_backupErrors = _showBackup
|
||||
? OpcUaEndpointConfigValidator.Validate(_backupConfig, "Backup.")
|
||||
: null;
|
||||
|
||||
if (!_primaryErrors.IsValid || (_backupErrors is { IsValid: false }))
|
||||
{
|
||||
_formError = "Fix the errors below before saving.";
|
||||
return;
|
||||
}
|
||||
|
||||
var primaryJson = OpcUaEndpointConfigSerializer.Serialize(_primaryConfig);
|
||||
var backupJson = _showBackup ? OpcUaEndpointConfigSerializer.Serialize(_backupConfig) : null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingConnection != null)
|
||||
{
|
||||
_editingConnection.Name = _formName.Trim();
|
||||
_editingConnection.Protocol = "OpcUa";
|
||||
_editingConnection.PrimaryConfiguration = primaryJson;
|
||||
_editingConnection.BackupConfiguration = backupJson;
|
||||
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
|
||||
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
|
||||
}
|
||||
else
|
||||
{
|
||||
var conn = new DataConnection(_formName.Trim(), "OpcUa", _formSiteId)
|
||||
{
|
||||
PrimaryConfiguration = primaryJson,
|
||||
BackupConfiguration = backupJson,
|
||||
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
|
||||
};
|
||||
await SiteRepository.AddDataConnectionAsync(conn);
|
||||
}
|
||||
await SiteRepository.SaveChangesAsync();
|
||||
NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_formError = $"Save failed: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableBackup() => _showBackup = true;
|
||||
|
||||
private void RemoveBackup()
|
||||
{
|
||||
_showBackup = false;
|
||||
_backupConfig = new OpcUaEndpointConfig();
|
||||
_backupIsLegacy = false;
|
||||
_formFailoverRetryCount = 3;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/connections");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user