Compare commits
14 Commits
phase-0-re
...
phase-2-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3d16a28f1 | ||
|
|
50f81a156d | ||
|
|
7403b92b72 | ||
|
|
a7126ba953 | ||
|
|
549cd36662 | ||
|
|
32eeeb9e04 | ||
|
|
a1e9ed40fb | ||
|
|
18f93d72bb | ||
|
|
7a5b535cd6 | ||
|
|
01fd90c178 | ||
|
|
fc0ce36308 | ||
|
|
bf6741ba7f | ||
|
|
980ea5190c | ||
|
|
45ffa3e7d4 |
@@ -1,5 +1,13 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
@@ -7,7 +15,16 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/ZB.MOM.WW.OtOpcUa.Tests.v1Archive.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||||
|
|||||||
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||||
|
|
||||||
|
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||||
|
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||||
|
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||||
|
on its own merits while the v1 surface stays as parity reference.
|
||||||
|
|
||||||
|
## Archived projects
|
||||||
|
|
||||||
|
| Path | Status | Replaced by | Build behavior |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||||
|
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||||
|
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||||
|
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||||
|
|
||||||
|
## How to run the archived suites explicitly
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# v1 unit tests (494):
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||||
|
|
||||||
|
# v1 integration tests (6):
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## Deletion plan (Phase 2 PR 3)
|
||||||
|
|
||||||
|
Pre-conditions:
|
||||||
|
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||||
|
at minimum (currently 7 tests; expand as needed)
|
||||||
|
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||||
|
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||||
|
- [ ] Operator review on a separate PR — destructive change
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||||
|
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||||
|
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||||
|
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||||
|
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||||
|
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||||
|
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||||
|
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||||
|
current count is plus any new E2E coverage)
|
||||||
|
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||||
|
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||||
|
10. One reviewer signoff
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||||
|
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||||
@@ -22,6 +22,102 @@ Per decision #99:
|
|||||||
|
|
||||||
The tier split keeps developer onboarding fast (no Docker required for first build) while concentrating the heavy simulator setup on one machine the team maintains.
|
The tier split keeps developer onboarding fast (no Docker required for first build) while concentrating the heavy simulator setup on one machine the team maintains.
|
||||||
|
|
||||||
|
## Installed Inventory — This Machine
|
||||||
|
|
||||||
|
Running record of every v2 dev service stood up on this developer machine. Updated on every install / config change. Credentials here are **dev-only** per decision #137 — production uses Integrated Security / gMSA per decision #46 and never any value in this table.
|
||||||
|
|
||||||
|
**Last updated**: 2026-04-17
|
||||||
|
|
||||||
|
### Host
|
||||||
|
|
||||||
|
| Attribute | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Machine name | `DESKTOP-6JL3KKO` |
|
||||||
|
| User | `dohertj2` (member of local Administrators + `docker-users`) |
|
||||||
|
| VM platform | VMware (`VMware20,1`), nested virtualization enabled |
|
||||||
|
| CPU | Intel Xeon E5-2697 v4 @ 2.30GHz (3 vCPUs) |
|
||||||
|
| OS | Windows (WSL2 + Hyper-V Platform features installed) |
|
||||||
|
|
||||||
|
### Toolchain
|
||||||
|
|
||||||
|
| Tool | Version | Location | Install method |
|
||||||
|
|------|---------|----------|----------------|
|
||||||
|
| .NET SDK | 10.0.201 | `C:\Program Files\dotnet\sdk\` | Pre-installed |
|
||||||
|
| .NET AspNetCore runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\` | Pre-installed |
|
||||||
|
| .NET NETCore runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.NETCore.App\` | Pre-installed |
|
||||||
|
| .NET WindowsDesktop runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App\` | Pre-installed |
|
||||||
|
| .NET Framework 4.8 SDK | — | Pending (needed for Phase 2 Galaxy.Host; not yet required) | — |
|
||||||
|
| Git | Pre-installed | Standard | — |
|
||||||
|
| PowerShell 7 | Pre-installed | Standard | — |
|
||||||
|
| winget | v1.28.220 | Standard Windows feature | — |
|
||||||
|
| WSL | Default v2, distro `docker-desktop` `STATE Running` | — | `wsl --install --no-launch` (2026-04-17) |
|
||||||
|
| Docker Desktop | 29.3.1 (engine) / Docker Desktop 4.68.0 (app) | Standard | `winget install --id Docker.DockerDesktop` (2026-04-17) |
|
||||||
|
| `dotnet-ef` CLI | 10.0.6 | `%USERPROFILE%\.dotnet\tools\dotnet-ef.exe` | `dotnet tool install --global dotnet-ef --version 10.0.*` (2026-04-17) |
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | Container / Process | Version | Host:Port | Credentials (dev-only) | Data location | Status |
|
||||||
|
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
|
||||||
|
| **Central config DB** | Docker container `otopcua-mssql` (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `localhost:14330` (host) → `1433` (container) — remapped from 1433 to avoid collision with the native MSSQL14 instance that hosts the Galaxy `ZB` DB (both bind 0.0.0.0:1433; whichever wins the race gets connections) | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` (mounted at `/var/opt/mssql` inside container) | ✅ Running — `InitialSchema` migration applied, 16 entity tables live |
|
||||||
|
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
|
||||||
|
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
|
||||||
|
| OPC Foundation reference server | Not yet built | — | `localhost:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
|
||||||
|
| FOCAS TCP stub | Not yet built | — | `localhost:8193` (target) | n/a | — | Pending (built in Phase 5) |
|
||||||
|
| Modbus simulator (`oitc/modbus-server`) | — | — | `localhost:502` (target) | n/a | — | Pending (needed for Phase 3 Modbus driver; moves to integration host per two-tier model) |
|
||||||
|
| libplctag `ab_server` | — | — | `localhost:44818` (target) | n/a | — | Pending (Phase 3/4 AB CIP and AB Legacy drivers) |
|
||||||
|
| Snap7 Server | — | — | `localhost:102` (target) | n/a | — | Pending (Phase 4 S7 driver) |
|
||||||
|
| TwinCAT XAR VM | — | — | `localhost:48898` (ADS) (target) | TwinCAT default route creds | — | Pending — runs in Hyper-V VM, not on this dev box (per decision #135) |
|
||||||
|
|
||||||
|
### Connection strings for `appsettings.Development.json`
|
||||||
|
|
||||||
|
Copy-paste-ready. **Never commit these to the repo** — they go in `appsettings.Development.json` (gitignored per the standard .NET convention) or in user-scoped dotnet secrets.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"ConfigDatabase": {
|
||||||
|
"ConnectionString": "Server=localhost,14330;Database=OtOpcUaConfig_Dev;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=true;Encrypt=false;"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Ldap": {
|
||||||
|
"Host": "localhost",
|
||||||
|
"Port": 3893,
|
||||||
|
"UseLdaps": false,
|
||||||
|
"BindDn": "cn=admin,dc=otopcua,dc=local",
|
||||||
|
"BindPassword": "<see glauth-otopcua.cfg — pending seeding>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For xUnit test fixtures that need a throwaway DB per test run, build connection strings with `Database=OtOpcUaConfig_Test_{timestamp}` to avoid cross-run pollution.
|
||||||
|
|
||||||
|
### Container management quick reference
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Start / stop the SQL Server container (survives reboots via Docker Desktop auto-start)
|
||||||
|
docker stop otopcua-mssql
|
||||||
|
docker start otopcua-mssql
|
||||||
|
|
||||||
|
# Logs (useful for diagnosing startup failures or login issues)
|
||||||
|
docker logs otopcua-mssql --tail 50
|
||||||
|
|
||||||
|
# Shell into the container (rarely needed; sqlcmd is the usual tool)
|
||||||
|
docker exec -it otopcua-mssql bash
|
||||||
|
|
||||||
|
# Query via sqlcmd inside the container (Git Bash needs MSYS_NO_PATHCONV=1 to avoid path mangling)
|
||||||
|
MSYS_NO_PATHCONV=1 docker exec otopcua-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "OtOpcUaDev_2026!" -C -Q "SELECT @@VERSION"
|
||||||
|
|
||||||
|
# Nuclear reset: drop the container + volume (destroys all DB data)
|
||||||
|
docker stop otopcua-mssql
|
||||||
|
docker rm otopcua-mssql
|
||||||
|
docker volume rm otopcua-mssql-data
|
||||||
|
# …then re-run the docker run command from Bootstrap Step 6
|
||||||
|
```
|
||||||
|
|
||||||
|
### Credential rotation
|
||||||
|
|
||||||
|
Dev credentials in this inventory are convenience defaults, not secrets. Change them at will per developer — just update this doc + each developer's `appsettings.Development.json`. There is no shared secret store for dev.
|
||||||
|
|
||||||
## Resource Inventory
|
## Resource Inventory
|
||||||
|
|
||||||
### A. Always-required (every developer + integration host)
|
### A. Always-required (every developer + integration host)
|
||||||
@@ -39,7 +135,7 @@ The tier split keeps developer onboarding fast (no Docker required for first bui
|
|||||||
|
|
||||||
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
||||||
|----------|---------|------|--------------|---------------------|-------|
|
|----------|---------|------|--------------|---------------------|-------|
|
||||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 default, or 14330 when a native MSSQL instance (e.g. the Galaxy `ZB` host) already occupies 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||||
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Local binary at `C:\publish\glauth\` per existing CLAUDE.md | 3893 (LDAP) / 3894 (LDAPS) | Service principal: `cn=admin,dc=otopcua,dc=local` / `OtOpcUaDev_2026!`; test users defined in GLAuth config | Developer (per machine) |
|
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Local binary at `C:\publish\glauth\` per existing CLAUDE.md | 3893 (LDAP) / 3894 (LDAPS) | Service principal: `cn=admin,dc=otopcua,dc=local` / `OtOpcUaDev_2026!`; test users defined in GLAuth config | Developer (per machine) |
|
||||||
| **Local dev Galaxy** (Aveva System Platform) | Galaxy driver tests; v1 IntegrationTests parity | Existing on dev box per CLAUDE.md | n/a (local COM) | Windows Auth | Developer (already present per project setup) |
|
| **Local dev Galaxy** (Aveva System Platform) | Galaxy driver tests; v1 IntegrationTests parity | Existing on dev box per CLAUDE.md | n/a (local COM) | Windows Auth | Developer (already present per project setup) |
|
||||||
|
|
||||||
@@ -108,25 +204,104 @@ The tier split keeps developer onboarding fast (no Docker required for first bui
|
|||||||
|
|
||||||
## Bootstrap Order — Inner-loop Developer Machine
|
## Bootstrap Order — Inner-loop Developer Machine
|
||||||
|
|
||||||
Order matters because some installs have prerequisites. ~30–60 min total on a fresh machine.
|
Order matters because some installs have prerequisites and several need admin elevation (UAC). ~60–90 min total on a fresh Windows machine, including reboots.
|
||||||
|
|
||||||
|
**Admin elevation appears at**: WSL2 install (step 4a), Docker Desktop install (step 4b), and any `wsl --install -d` call. winget will prompt UAC interactively when these run; accept it. There is no fully-silent admin-free install path on Windows for Docker Desktop's prerequisites.
|
||||||
|
|
||||||
1. **Install .NET 10 SDK** (https://dotnet.microsoft.com/) — required to build anything
|
1. **Install .NET 10 SDK** (https://dotnet.microsoft.com/) — required to build anything
|
||||||
|
```powershell
|
||||||
|
winget install --id Microsoft.DotNet.SDK.10 --accept-package-agreements --accept-source-agreements
|
||||||
|
```
|
||||||
|
|
||||||
2. **Install .NET Framework 4.8 SDK + targeting pack** — only needed when starting Phase 2 (Galaxy.Host); skip for Phase 0–1 if not yet there
|
2. **Install .NET Framework 4.8 SDK + targeting pack** — only needed when starting Phase 2 (Galaxy.Host); skip for Phase 0–1 if not yet there
|
||||||
|
```powershell
|
||||||
|
winget install --id Microsoft.DotNet.Framework.DeveloperPack_4 --accept-package-agreements --accept-source-agreements
|
||||||
|
```
|
||||||
|
|
||||||
3. **Install Git + PowerShell 7.4+**
|
3. **Install Git + PowerShell 7.4+**
|
||||||
4. **Clone repos**:
|
```powershell
|
||||||
|
winget install --id Git.Git --accept-package-agreements --accept-source-agreements
|
||||||
|
winget install --id Microsoft.PowerShell --accept-package-agreements --accept-source-agreements
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Install Docker Desktop** (with WSL2 backend per decision #134, leaves Hyper-V free for the future TwinCAT XAR VM):
|
||||||
|
|
||||||
|
**4a. Enable WSL2** — UAC required:
|
||||||
|
```powershell
|
||||||
|
wsl --install
|
||||||
|
```
|
||||||
|
Reboot when prompted. After reboot, the default Ubuntu distro launches and asks for a username/password — set them (these are WSL-internal, not used for Docker auth).
|
||||||
|
|
||||||
|
Verify after reboot:
|
||||||
|
```powershell
|
||||||
|
wsl --status
|
||||||
|
wsl --list --verbose
|
||||||
|
```
|
||||||
|
Expected: `Default Version: 2`, at least one distro (typically `Ubuntu`) with `STATE Running` or `Stopped`.
|
||||||
|
|
||||||
|
**4b. Install Docker Desktop** — UAC required:
|
||||||
|
```powershell
|
||||||
|
winget install --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements
|
||||||
|
```
|
||||||
|
The installer adds you to the `docker-users` Windows group. **Sign out and back in** (or reboot) so the group membership takes effect.
|
||||||
|
|
||||||
|
**4c. Configure Docker Desktop** — open it once after sign-in:
|
||||||
|
- **Settings → General**: confirm "Use the WSL 2 based engine" is **checked** (decision #134 — coexists with future Hyper-V VMs)
|
||||||
|
- **Settings → General**: confirm "Use Windows containers" is **NOT checked** (we use Linux containers for `mcr.microsoft.com/mssql/server`, `oitc/modbus-server`, etc.)
|
||||||
|
- **Settings → Resources → WSL Integration**: enable for the default Ubuntu distro
|
||||||
|
- (Optional, large fleets) **Settings → Resources → Advanced**: bump CPU / RAM allocation if you have headroom
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```powershell
|
||||||
|
docker --version
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
Expected: version reported, `docker ps` returns an empty table (no containers running yet, but the daemon is reachable).
|
||||||
|
|
||||||
|
5. **Clone repos**:
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://gitea.dohertylan.com/dohertj2/lmxopcua.git
|
git clone https://gitea.dohertylan.com/dohertj2/lmxopcua.git
|
||||||
git clone https://gitea.dohertylan.com/dohertj2/scadalink-design.git
|
git clone https://gitea.dohertylan.com/dohertj2/scadalink-design.git
|
||||||
git clone https://gitea.dohertylan.com/dohertj2/3yearplan.git
|
git clone https://gitea.dohertylan.com/dohertj2/3yearplan.git
|
||||||
```
|
```
|
||||||
5. **Install SQL Server 2022 dev edition** (local install) OR start the Docker container (see Resource B):
|
|
||||||
|
6. **Start SQL Server** (Linux container; runs in the WSL2 backend):
|
||||||
```powershell
|
```powershell
|
||||||
docker run --name otopcua-mssql -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=OtOpcUaDev_2026!" `
|
docker run --name otopcua-mssql `
|
||||||
-p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
|
-e "ACCEPT_EULA=Y" `
|
||||||
|
-e "MSSQL_SA_PASSWORD=OtOpcUaDev_2026!" `
|
||||||
|
-p 14330:1433 `
|
||||||
|
-v otopcua-mssql-data:/var/opt/mssql `
|
||||||
|
-d mcr.microsoft.com/mssql/server:2022-latest
|
||||||
```
|
```
|
||||||
6. **Install GLAuth** at `C:\publish\glauth\` per existing CLAUDE.md instructions; populate `glauth-otopcua.cfg` with the test users + groups (template in `docs/v2/dev-environment-glauth-config.md` — to be added in the setup task)
|
|
||||||
7. **Run `dotnet restore`** in the `lmxopcua` repo
|
The host port is **14330**, not 1433, to coexist with the native MSSQL14 instance that hosts the Galaxy `ZB` DB on port 1433. Both the native instance and Docker's port-proxy will happily bind `0.0.0.0:1433`, but only one of them catches any given connection — which is effectively non-deterministic and produces confusing "Login failed for user 'sa'" errors when the native instance wins. Using 14330 eliminates the race entirely.
|
||||||
8. **Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx`** (post-Phase-0) or `ZB.MOM.WW.LmxOpcUa.slnx` (pre-Phase-0) — verifies the toolchain
|
|
||||||
|
The `-v otopcua-mssql-data:/var/opt/mssql` named volume preserves database files across container restarts and `docker rm` — drop it only if you want a strictly throwaway instance.
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
```powershell
|
||||||
|
docker ps --filter name=otopcua-mssql
|
||||||
|
docker exec -it otopcua-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "OtOpcUaDev_2026!" -C -Q "SELECT @@VERSION"
|
||||||
|
```
|
||||||
|
Expected: container `STATUS Up`, `SELECT @@VERSION` returns `Microsoft SQL Server 2022 (...)`.
|
||||||
|
|
||||||
|
To stop / start later:
|
||||||
|
```powershell
|
||||||
|
docker stop otopcua-mssql
|
||||||
|
docker start otopcua-mssql
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Install GLAuth** at `C:\publish\glauth\` per existing CLAUDE.md instructions; populate `glauth-otopcua.cfg` with the test users + groups (template in `docs/v2/dev-environment-glauth-config.md` — to be added in the setup task)
|
||||||
|
|
||||||
|
8. **Install EF Core CLI** (used to apply migrations against the SQL Server container starting in Phase 1 Stream B):
|
||||||
|
```powershell
|
||||||
|
dotnet tool install --global dotnet-ef --version 10.0.*
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **Run `dotnet restore`** in the `lmxopcua` repo
|
||||||
|
|
||||||
|
10. **Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx`** (post-Phase-0) or `ZB.MOM.WW.LmxOpcUa.slnx` (pre-Phase-0) — verifies the toolchain
|
||||||
9. **Run `dotnet test`** with the inner-loop filter — should pass on a fresh machine
|
9. **Run `dotnet test`** with the inner-loop filter — should pass on a fresh machine
|
||||||
|
|
||||||
## Bootstrap Order — Integration Host
|
## Bootstrap Order — Integration Host
|
||||||
@@ -213,11 +388,22 @@ Seeds are idempotent (re-runnable) and gitignored where they contain credentials
|
|||||||
### Step 1 — Inner-loop dev environment (each developer, ~1 day with documentation)
|
### Step 1 — Inner-loop dev environment (each developer, ~1 day with documentation)
|
||||||
|
|
||||||
**Owner**: developer
|
**Owner**: developer
|
||||||
**Prerequisite**: Bootstrap order steps 1–9 above
|
**Prerequisite**: Bootstrap order steps 1–10 above (note: steps 4a, 4b, and any later `wsl --install -d` call require admin elevation / UAC interaction — there is no fully-silent admin-free install path on Windows for Docker Desktop's prerequisites)
|
||||||
**Acceptance**:
|
**Acceptance**:
|
||||||
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes
|
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes
|
||||||
- A test that touches the central config DB succeeds (proves SQL Server reachable)
|
- A test that touches the central config DB succeeds (proves SQL Server reachable)
|
||||||
- A test that authenticates against GLAuth succeeds (proves LDAP reachable)
|
- A test that authenticates against GLAuth succeeds (proves LDAP reachable)
|
||||||
|
- `docker ps --filter name=otopcua-mssql` shows the SQL Server container `STATUS Up`
|
||||||
|
|
||||||
|
### Troubleshooting (common Windows install snags)
|
||||||
|
|
||||||
|
- **`wsl --install` says "Windows Subsystem for Linux has no installed distributions"** after first reboot — open a fresh PowerShell and run `wsl --install -d Ubuntu` (the `-d` form forces a distro install if the prereq-only install ran first).
|
||||||
|
- **Docker Desktop install completes but `docker --version` reports "command not found"** — `PATH` doesn't pick up the new Docker shims until a new shell is opened. Open a fresh PowerShell, or sign out/in, and retry.
|
||||||
|
- **`docker ps` reports "permission denied" or "Cannot connect to the Docker daemon"** — your user account isn't in the `docker-users` group yet. Sign out and back in (group membership is loaded at login). Verify with `whoami /groups | findstr docker-users`.
|
||||||
|
- **Docker Desktop refuses to start with "WSL 2 installation is incomplete"** — open the WSL2 kernel update from https://aka.ms/wsl2kernel, install, then restart Docker Desktop. (Modern `wsl --install` ships the kernel automatically; this is mostly a legacy problem.)
|
||||||
|
- **SQL Server container starts but immediately exits** — SA password complexity. The default `OtOpcUaDev_2026!` meets the requirement (≥8 chars, upper + lower + digit + symbol); if you change it, keep complexity. Check `docker logs otopcua-mssql` for the exact failure.
|
||||||
|
- **`docker run` fails with "image platform does not match host platform"** — your Docker is configured for Windows containers. Switch to Linux containers in Docker Desktop tray menu ("Switch to Linux containers"), or recheck Settings → General per step 4c.
|
||||||
|
- **Hyper-V conflict when later setting up TwinCAT XAR VM** — confirm Docker Desktop is on the **WSL 2 backend**, not Hyper-V backend. The two coexist only when Docker uses WSL 2.
|
||||||
|
|
||||||
### Step 2 — Integration host (one-time, ~1 week)
|
### Step 2 — Integration host (one-time, ~1 week)
|
||||||
|
|
||||||
|
|||||||
56
docs/v2/implementation/entry-gate-phase-1.md
Normal file
56
docs/v2/implementation/entry-gate-phase-1.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Phase 1 — Entry Gate Record
|
||||||
|
|
||||||
|
**Phase**: 1 — Configuration project + Core.Abstractions + Admin scaffold
|
||||||
|
**Branch**: `phase-1-configuration`
|
||||||
|
**Date**: 2026-04-17
|
||||||
|
**Implementation lead**: Claude (executing on behalf of dohertj2)
|
||||||
|
|
||||||
|
## Entry conditions
|
||||||
|
|
||||||
|
| Check | Required | Actual | Pass |
|
||||||
|
|-------|----------|--------|------|
|
||||||
|
| Phase 0 exit gate cleared | Rename complete, all v1 tests pass under OtOpcUa names | Phase 0 merged to `v2` at commit `45ffa3e` | ✅ |
|
||||||
|
| `v2` branch is clean | Clean | Clean post-merge | ✅ |
|
||||||
|
| Phase 0 PR merged | — | Merged via `--no-ff` to v2 | ✅ |
|
||||||
|
| SQL Server 2019+ instance available | For development | NOT YET AVAILABLE — see deviation below | ⚠️ |
|
||||||
|
| LDAP/GLAuth dev instance available | For Admin auth integration testing | Existing v1 GLAuth at `C:\publish\glauth\` | ✅ |
|
||||||
|
| ScadaLink CentralUI source accessible | For parity reference | `C:\Users\dohertj2\Desktop\scadalink-design\` per memory | ✅ |
|
||||||
|
| Phase 1-relevant design docs reviewed | All read by impl lead | ✅ Read in preceding sessions | ✅ |
|
||||||
|
| Decisions read | #1–142 covered cumulatively | ✅ | ✅ |
|
||||||
|
|
||||||
|
## Deviation: SQL Server dev instance not yet stood up
|
||||||
|
|
||||||
|
The Phase 1 entry gate requires a SQL Server 2019+ dev instance for the `Configuration` project's EF Core migrations + tests. This is per `dev-environment.md` Step 1, which is currently TODO.
|
||||||
|
|
||||||
|
**Decision**: proceed with **Stream A only** (Core.Abstractions) in this continuation. Stream A has zero infrastructure dependencies — it's a `.NET 10` project with BCL-only references defining capability interfaces and DTOs. Streams B (Configuration), C (Core), D (Server), and E (Admin) all have infrastructure dependencies (SQL Server, GLAuth, Galaxy) and require the dev environment standup to be productive.
|
||||||
|
|
||||||
|
The SQL Server standup is a one-line `docker run` per `dev-environment.md` §"Bootstrap Order — Inner-loop Developer Machine" step 5. It can happen in parallel with subsequent Stream A work but is not a blocker for Stream A itself.
|
||||||
|
|
||||||
|
**This continuation will execute only Stream A.** Streams B–E require their own continuations after the dev environment is stood up.
|
||||||
|
|
||||||
|
## Phase 1 work scope (for reference)
|
||||||
|
|
||||||
|
Per `phase-1-configuration-and-admin-scaffold.md`:
|
||||||
|
|
||||||
|
| Stream | Scope | Status this continuation |
|
||||||
|
|--------|-------|--------------------------|
|
||||||
|
| **A. Core.Abstractions** | 11 capability interfaces + DTOs + DriverTypeRegistry | ▶ EXECUTING |
|
||||||
|
| B. Configuration | EF Core schema, stored procs, LiteDB cache, generation-diff applier | DEFERRED — needs SQL Server |
|
||||||
|
| C. Core | `LmxNodeManager → GenericDriverNodeManager` rename, `IAddressSpaceBuilder`, driver hosting | DEFERRED — depends on Stream A + needs Galaxy |
|
||||||
|
| D. Server | `Microsoft.Extensions.Hosting` host, credential-bound bootstrap | DEFERRED — depends on Stream B |
|
||||||
|
| E. Admin | Blazor Server scaffold mirroring ScadaLink | DEFERRED — depends on Stream B |
|
||||||
|
|
||||||
|
## Baseline metrics (carried from Phase 0 exit)
|
||||||
|
|
||||||
|
- **Total tests**: 822 (pass + fail)
|
||||||
|
- **Pass count**: 821 (improved from baseline 820 — one flaky test happened to pass at Phase 0 exit)
|
||||||
|
- **Fail count**: 1 (the second pre-existing failure may flap; either 1 or 2 failures is consistent with baseline)
|
||||||
|
- **Build warnings**: 30 (lower than original baseline 167)
|
||||||
|
- **Build errors**: 0
|
||||||
|
|
||||||
|
Phase 1 must not introduce new failures or new errors against this baseline.
|
||||||
|
|
||||||
|
## Signoff
|
||||||
|
|
||||||
|
Implementation lead: Claude (Opus 4.7) — 2026-04-17
|
||||||
|
Reviewer: pending — Stream A PR will require a second reviewer per overview.md exit-gate rules
|
||||||
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Phase 2 Final Exit Gate (2026-04-18)
|
||||||
|
|
||||||
|
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||||
|
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||||
|
|
||||||
|
## Status: **All five Phase 2 streams addressed. Stream D split across PR 2 (archive) + PR 3 (delete) per safety protocol.**
|
||||||
|
|
||||||
|
## Stream-by-stream status
|
||||||
|
|
||||||
|
| Stream | Plan §reference | Status | PR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 (merged or pending) |
|
||||||
|
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Real Win32 pump, all Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / **MxAccess** with live COM) | PR 1 |
|
||||||
|
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ All 9 capability interfaces + supervisor (Backoff + CircuitBreaker + HeartbeatMonitor) | PR 1 |
|
||||||
|
| D — Retire legacy Host | §D.1–D.3 | ✅ Migration script, installer scripts, Stream D procedure doc, **archive markings on all v1 surface (this PR 2)**, deletion deferred to PR 3 | PR 2 (this) + PR 3 (next) |
|
||||||
|
| E — Parity validation | §E.1–E.4 | ✅ E2E test scaffold + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 (this) |
|
||||||
|
|
||||||
|
## What changed in PR 2 (this branch `phase-2-stream-d`)
|
||||||
|
|
||||||
|
1. **`tests/ZB.MOM.WW.OtOpcUa.Tests/`** renamed to `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`,
|
||||||
|
`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so the v1 Host's `InternalsVisibleTo`
|
||||||
|
still matches, `<IsTestProject>false</IsTestProject>` so `dotnet test slnx` excludes it.
|
||||||
|
2. **Three other v1 projects archive-marked** with PropertyGroup comments:
|
||||||
|
`OtOpcUa.Host`, `Historian.Aveva`, `IntegrationTests`. `IntegrationTests` also gets
|
||||||
|
`<IsTestProject>false</IsTestProject>`.
|
||||||
|
3. **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** project (.NET 10):
|
||||||
|
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as subprocess via
|
||||||
|
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||||
|
Skips when Galaxy ZB unreachable, when Host EXE not built, or when running as
|
||||||
|
Administrator (PipeAcl denies admins).
|
||||||
|
- `RecordingAddressSpaceBuilder` captures Folder + Variable + Property registrations so
|
||||||
|
parity tests can assert shape.
|
||||||
|
- `HierarchyParityTests` (3) — Discover returns gobjects with attributes;
|
||||||
|
attribute full references match `tag.attribute` shape; HistoryExtension flag flows
|
||||||
|
through.
|
||||||
|
- `StabilityFindingsRegressionTests` (4) — one test per 2026-04-13 finding:
|
||||||
|
phantom-probe-doesn't-corrupt-status, host-status-event-is-scoped, all-async-no-sync-
|
||||||
|
over-async, AcknowledgeAsync-completes-before-returning.
|
||||||
|
4. **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||||
|
5. **`docs/v2/implementation/exit-gate-phase-2-final.md`** (this doc) — supersedes the two
|
||||||
|
partial-exit docs.
|
||||||
|
|
||||||
|
## Test counts
|
||||||
|
|
||||||
|
**Solution-level `dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 baseline failure**.
|
||||||
|
|
||||||
|
| Project | Pass | Skip |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Core.Abstractions.Tests | 24 | 0 |
|
||||||
|
| Configuration.Tests | 42 | 0 |
|
||||||
|
| Core.Tests | 4 | 0 |
|
||||||
|
| Server.Tests | 2 | 0 |
|
||||||
|
| Admin.Tests | 21 | 0 |
|
||||||
|
| Driver.Galaxy.Shared.Tests | 6 | 0 |
|
||||||
|
| Driver.Galaxy.Host.Tests | 30 | 0 |
|
||||||
|
| Driver.Galaxy.Proxy.Tests | 10 | 0 |
|
||||||
|
| **Driver.Galaxy.E2E (NEW)** | **0** | **7** (all skip with documented reason — admin shell) |
|
||||||
|
| Client.Shared.Tests | 131 | 0 |
|
||||||
|
| Client.UI.Tests | 98 | 0 |
|
||||||
|
| Client.CLI.Tests | 51 / 1 fail | 0 |
|
||||||
|
| Historian.Aveva.Tests | 41 | 0 |
|
||||||
|
|
||||||
|
**Excluded from solution run (run explicitly when needed)**:
|
||||||
|
- `OtOpcUa.Tests.v1Archive` — 494 pass (v1 unit tests, kept as parity reference)
|
||||||
|
- `OtOpcUa.IntegrationTests` — 6 pass (v1 integration tests, kept as parity reference)
|
||||||
|
|
||||||
|
## Adversarial review of the PR 2 diff
|
||||||
|
|
||||||
|
Independent pass over the PR 2 deltas. New findings ranked by severity; existing findings
|
||||||
|
from the previous exit-gate doc still apply.
|
||||||
|
|
||||||
|
### New findings
|
||||||
|
|
||||||
|
**Medium 1 — `IsTestProject=false` on `OtOpcUa.IntegrationTests` removes the safety net.**
|
||||||
|
The 6 v1 integration tests no longer run on solution test. *Mitigation:* the new E2E suite
|
||||||
|
covers the same scenarios in the v2 topology shape. *Risk:* if E2E test count regresses or
|
||||||
|
fails to cover a scenario, the v1 fallback isn't auto-checked. **Procedure**: PR 3
|
||||||
|
checklist includes "E2E test count covers v1 IntegrationTests' 6 scenarios at minimum".
|
||||||
|
|
||||||
|
**Medium 2 — Stability-finding regression tests #2, #3, #4 are structural (reflection-based)
|
||||||
|
not behavioral.** Findings #2 and #3 use type-shape assertions (event signature carries
|
||||||
|
HostName; methods return Task) rather than triggering the actual race. *Mitigation:* the v1
|
||||||
|
defects were structural — fixing them required interface changes that the type-shape
|
||||||
|
assertions catch. *Risk:* a future refactor that re-introduces sync-over-async via a non-
|
||||||
|
async helper called inside a Task method wouldn't trip the test. **Filed as v2.1**: add a
|
||||||
|
runtime async-call-stack analyzer (Roslyn or post-build).
|
||||||
|
|
||||||
|
**Low 1 — `ParityFixture` defaults to `OTOPCUA_GALAXY_BACKEND=db`** (not `mxaccess`).
|
||||||
|
Discover works against ZB without needing live MXAccess. The MXAccess-required tests will
|
||||||
|
need a second fixture once they're written.
|
||||||
|
|
||||||
|
**Low 2 — `Process.Start(EnvironmentVariables)` doesn't always inherit clean state.** The
|
||||||
|
test inherits the parent's PATH + locale, which is normally fine but could mask a missing
|
||||||
|
runtime dependency. *Mitigation:* in CI, pin a clean environment block.
|
||||||
|
|
||||||
|
### Existing findings (carried forward from `exit-gate-phase-2.md`)
|
||||||
|
|
||||||
|
All 8 still apply unchanged. Particularly:
|
||||||
|
- High 1 (MxAccess Read subscription-leak on cancellation) — open
|
||||||
|
- High 2 (no MXAccess reconnect loop, only supervisor-driven recycle) — open
|
||||||
|
- Medium 3 (SubscribeAsync doesn't push OnDataChange frames yet) — open
|
||||||
|
- Medium 4 (WriteValuesAsync doesn't await OnWriteComplete) — open
|
||||||
|
|
||||||
|
## Cross-cutting deferrals (out of Phase 2)
|
||||||
|
|
||||||
|
- **Deletion of v1 archive** — PR 3, gated on operator review + E2E coverage parity check
|
||||||
|
- **Wonderware Historian SDK plugin port** (`Historian.Aveva` → `Driver.Galaxy.Host/Backend/Historian/`) — Task B.1.h, opportunistically with PR 3 or as PR 4
|
||||||
|
- **MxAccess subscription push frames** — Task B.1.s, follow-up to enable real-time data
|
||||||
|
flow (currently subscribes register but values aren't pushed back)
|
||||||
|
- **Wonderware Historian-backed HistoryRead** — depends on B.1.h
|
||||||
|
- **Alarm subsystem wire-up** — `MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op
|
||||||
|
- **Reconnect-without-recycle** in MxAccessClient — v2.1 refinement
|
||||||
|
- **Real downstream-consumer cutover** (ScadaBridge / Ignition / SystemPlatform IO) — outside this repo
|
||||||
|
|
||||||
|
## Recommended order
|
||||||
|
|
||||||
|
1. **PR 1** (`phase-1-configuration` → `v2`) — merge first; self-contained, parity preserved
|
||||||
|
2. **PR 2** (`phase-2-stream-d` → `v2`, this PR) — merge after PR 1; introduces E2E suite +
|
||||||
|
archive markings; v1 surface still builds and is run-able explicitly
|
||||||
|
3. **PR 3** (next session) — delete v1 archive; depends on operator approval after PR 2
|
||||||
|
reviewer signoff
|
||||||
|
4. **PR 4** (Phase 2 follow-up) — Historian port + MxAccess subscription push frames + the
|
||||||
|
open high/medium findings
|
||||||
181
docs/v2/implementation/exit-gate-phase-2.md
Normal file
181
docs/v2/implementation/exit-gate-phase-2.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Phase 2 Exit Gate Record (2026-04-18)
|
||||||
|
|
||||||
|
> Supersedes `phase-2-partial-exit-evidence.md`. Captures the as-built state of Phase 2 after
|
||||||
|
> the MXAccess COM client port + DB-backed and MXAccess-backed Galaxy backends + adversarial
|
||||||
|
> review.
|
||||||
|
|
||||||
|
## Status: **Streams A, B, C complete. Stream D + E gated only on legacy-Host removal + parity-test rewrite.**
|
||||||
|
|
||||||
|
The Phase 2 plan exit criterion ("v1 IntegrationTests pass against v2 Galaxy.Proxy + Galaxy.Host
|
||||||
|
topology byte-for-byte") still cannot be auto-validated in a single session. The blocker is no
|
||||||
|
longer "the Galaxy code lift" — that's done in this session — but the structural fact that the
|
||||||
|
494 v1 IntegrationTests instantiate v1 `OtOpcUa.Host` classes directly. They have to be rewritten
|
||||||
|
to use the IPC-fronted Proxy topology before legacy `OtOpcUa.Host` can be deleted, and the plan
|
||||||
|
budgets that work as a multi-day debug-cycle (Task E.1).
|
||||||
|
|
||||||
|
What changed today: the MXAccess COM client now exists in Galaxy.Host with a real
|
||||||
|
`ArchestrA.MxAccess.dll` reference, runs end-to-end against live `LMXProxyServer`, and 3 live
|
||||||
|
COM smoke tests pass on this dev box. `MxAccessGalaxyBackend` (the third
|
||||||
|
`IGalaxyBackend` implementation, alongside `StubGalaxyBackend` and `DbBackedGalaxyBackend`)
|
||||||
|
combines the ported `GalaxyRepository` with the ported `MxAccessClient` so Discover / Read /
|
||||||
|
Write / Subscribe all flow through one production-shape backend. `Program.cs` selects between
|
||||||
|
the three backends via the `OTOPCUA_GALAXY_BACKEND` env var (default = `mxaccess`).
|
||||||
|
|
||||||
|
## Delivered in Phase 2 (full scope, not just scaffolds)
|
||||||
|
|
||||||
|
### Stream A — Driver.Galaxy.Shared (✅ complete)
|
||||||
|
- 9 contract files: Hello/HelloAck (version negotiation), OpenSession/CloseSession/Heartbeat,
|
||||||
|
Discover + GalaxyObjectInfo + GalaxyAttributeInfo, Read/Write + GalaxyDataValue,
|
||||||
|
Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus,
|
||||||
|
Recycle.
|
||||||
|
- Length-prefixed framing (4-byte BE length + 1-byte kind + MessagePack body) with a
|
||||||
|
16 MiB cap.
|
||||||
|
- Thread-safe `FrameWriter` (semaphore-gated) and single-consumer `FrameReader`.
|
||||||
|
- 6 round-trip tests + reflection-scan that asserts contracts only reference BCL + MessagePack.
|
||||||
|
|
||||||
|
### Stream B — Driver.Galaxy.Host (✅ complete, exceeded original scope)
|
||||||
|
- Real Win32 message pump in `StaPump` — `GetMessage`/`PostThreadMessage`/`PeekMessage`/
|
||||||
|
`PostQuitMessage` P/Invoke, dedicated STA thread, `WM_APP=0x8000` work dispatch, `WM_APP+1`
|
||||||
|
graceful-drain → `PostQuitMessage`, 5s join-on-dispose, responsiveness probe.
|
||||||
|
- Strict `PipeAcl` (allow configured server SID only, deny LocalSystem + Administrators),
|
||||||
|
`PipeServer` with caller-SID verification + per-process shared-secret `Hello` handshake.
|
||||||
|
- Galaxy-specific `MemoryWatchdog` (warn `max(1.5×baseline, +200 MB)`, soft-recycle
|
||||||
|
`max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min window).
|
||||||
|
- `RecyclePolicy` (1/hr cap + 03:00 daily scheduled), `PostMortemMmf` (1000-entry ring
|
||||||
|
buffer, hard-crash survivable, cross-process readable), `MxAccessHandle : SafeHandle`.
|
||||||
|
- `IGalaxyBackend` interface + 3 implementations:
|
||||||
|
- **`StubGalaxyBackend`** — keeps IPC end-to-end testable without Galaxy.
|
||||||
|
- **`DbBackedGalaxyBackend`** — real Discover via the ported `GalaxyRepository` against ZB.
|
||||||
|
- **`MxAccessGalaxyBackend`** — Discover via DB + Read/Write/Subscribe via the ported
|
||||||
|
`MxAccessClient` over the StaPump.
|
||||||
|
- `GalaxyRepository` ported from v1 (HierarchySql + AttributesSql byte-for-byte identical).
|
||||||
|
- `MxAccessClient` ported from v1 (Connect/Read/Write/Subscribe/Unsubscribe + ConcurrentDict
|
||||||
|
handle tracking + OnDataChange / OnWriteComplete event marshalling). The reconnect loop +
|
||||||
|
Historian plugin loader + extended-attribute query are explicit follow-ups.
|
||||||
|
- `MxProxyAdapter` + `IMxProxy` for COM-isolation testability.
|
||||||
|
- `Program.cs` env-driven backend selection (`OTOPCUA_GALAXY_BACKEND=stub|db|mxaccess`,
|
||||||
|
`OTOPCUA_GALAXY_ZB_CONN`, `OTOPCUA_GALAXY_CLIENT_NAME`, plus the Phase 2 baseline
|
||||||
|
`OTOPCUA_GALAXY_PIPE` / `OTOPCUA_ALLOWED_SID` / `OTOPCUA_GALAXY_SECRET`).
|
||||||
|
- ArchestrA.MxAccess.dll referenced via HintPath at `lib/ArchestrA.MxAccess.dll`. Project
|
||||||
|
flipped to **x86 platform target** (the COM interop requires it).
|
||||||
|
|
||||||
|
### Stream C — Driver.Galaxy.Proxy (✅ complete)
|
||||||
|
- `GalaxyProxyDriver` implements **all 9** capability interfaces — `IDriver`, `ITagDiscovery`,
|
||||||
|
`IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`,
|
||||||
|
`IRediscoverable`, `IHostConnectivityProbe` — each forwarding through the matching IPC
|
||||||
|
contract.
|
||||||
|
- `GalaxyIpcClient` with `CallAsync` (request/response gated through a semaphore so concurrent
|
||||||
|
callers don't interleave frames) + `SendOneWayAsync` for fire-and-forget calls
|
||||||
|
(Unsubscribe / AlarmAck / CloseSession).
|
||||||
|
- `Backoff` (5s → 15s → 60s, capped, reset-on-stable-run), `CircuitBreaker` (3 crashes per
|
||||||
|
5 min opens; 1h → 4h → manual escalation; sticky alert), `HeartbeatMonitor` (2s cadence,
|
||||||
|
3 misses = host dead).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- **963 pass / 1 pre-existing baseline** across the full solution.
|
||||||
|
- New in this session:
|
||||||
|
- `StaPumpTests` — pump still passes 3/3 against the real Win32 implementation
|
||||||
|
- `EndToEndIpcTests` (5) — every IPC operation through Pipe + dispatcher + StubBackend
|
||||||
|
- `IpcHandshakeIntegrationTests` (2) — Hello + heartbeat + secret rejection
|
||||||
|
- `GalaxyRepositoryLiveSmokeTests` (5) — live SQL against ZB, skip when ZB unreachable
|
||||||
|
- `MxAccessLiveSmokeTests` (3) — live COM against running `aaBootstrap` + `LMXProxyServer`
|
||||||
|
- All net48 x86 to match Galaxy.Host
|
||||||
|
|
||||||
|
## Adversarial review findings
|
||||||
|
|
||||||
|
Independent pass over the Phase 2 deltas. Findings ranked by severity; **all open items are
|
||||||
|
explicitly deferred to Stream D/E or v2.1 with rationale.**
|
||||||
|
|
||||||
|
### Critical — none.
|
||||||
|
|
||||||
|
### High
|
||||||
|
|
||||||
|
1. **MxAccess `ReadAsync` has a subscription-leak window on cancellation.** The one-shot read
|
||||||
|
uses subscribe → first-OnDataChange → unsubscribe. If the caller cancels between the
|
||||||
|
`SubscribeOnPumpAsync` await and the `tcs.Task` await, the subscription stays installed.
|
||||||
|
*Mitigation:* the StaPump's idempotent unsubscribe path drops orphan subs at disconnect, but
|
||||||
|
a long-running session leaks them. **Fix scoped to Phase 2 follow-up** alongside the proper
|
||||||
|
subscription registry that v1 had.
|
||||||
|
|
||||||
|
2. **No reconnect loop on the MXAccess COM connection.** v1's `MxAccessClient.Monitor` polled
|
||||||
|
a probe tag and triggered reconnect-with-replay on disconnection. The ported client's
|
||||||
|
`ConnectAsync` is one-shot and there's no health monitor. *Mitigation:* the Tier C
|
||||||
|
supervisor on the Proxy side (CircuitBreaker + HeartbeatMonitor) restarts the whole Host
|
||||||
|
process on liveness failure, so connection loss surfaces as a process recycle rather than
|
||||||
|
silent data loss. **Reconnect-without-recycle is a v2.1 refinement** per `driver-stability.md`.
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
|
||||||
|
3. **`MxAccessGalaxyBackend.SubscribeAsync` doesn't push OnDataChange frames back to the
|
||||||
|
Proxy.** The wire frame `MessageKind.OnDataChangeNotification` is defined and `GalaxyProxyDriver`
|
||||||
|
has the `RaiseDataChange` internal entry point, but the Host-side push pipeline isn't wired —
|
||||||
|
the subscribe registers on the COM side but the value just gets discarded. *Mitigation:* the
|
||||||
|
SubscribeAsync handle is still useful for the ack flow, and one-shot reads work. **Push
|
||||||
|
plumbing is the next-session item.**
|
||||||
|
|
||||||
|
4. **`WriteValuesAsync` doesn't await the OnWriteComplete callback.** v1's implementation
|
||||||
|
awaited a TCS keyed on the item handle; the port fires the write and returns success without
|
||||||
|
confirming the runtime accepted it. *Mitigation:* the StatusCode in the response will be 0
|
||||||
|
(Good) for a fire-and-forget — false positive if the runtime rejects post-callback. **Fix
|
||||||
|
needs the same TCS-by-handle pattern as v1; queued.**
|
||||||
|
|
||||||
|
5. **`MxAccessGalaxyBackend.Discover` re-queries SQL on every call.** v1 cached the tree and
|
||||||
|
only refreshed on the deploy-watermark change. *Mitigation:* AttributesSql is the slow one
|
||||||
|
(~30s for a large Galaxy); first-call latency is the symptom, not data loss. **Caching +
|
||||||
|
`IRediscoverable` push is a v2.1 follow-up.**
|
||||||
|
|
||||||
|
### Low
|
||||||
|
|
||||||
|
6. **Live MXAccess test `Backend_ReadValues_against_discovered_attribute_returns_a_response_shape`
|
||||||
|
silently passes if no readable attribute is found.** Documented; the test asserts the *shape*
|
||||||
|
not the *value* because some Galaxy installs are configuration-only.
|
||||||
|
|
||||||
|
7. **`FrameWriter` allocates the length-prefix as a 4-byte heap array per call.** Could be
|
||||||
|
stackalloc. Microbenchmark not done — currently irrelevant.
|
||||||
|
|
||||||
|
8. **`MxProxyAdapter.Unregister` swallows exceptions during `Unregister(handle)`.** v1 did the
|
||||||
|
same; documented as best-effort during teardown. Consider logging the swallow.
|
||||||
|
|
||||||
|
### Out of scope (correctly deferred)
|
||||||
|
|
||||||
|
- Stream D.1 — delete legacy `OtOpcUa.Host`. **Cannot be done in any single session** because
|
||||||
|
the 494 v1 IntegrationTests reference Host classes directly. Requires the test rewrite cycle
|
||||||
|
in Stream E.
|
||||||
|
- Stream E.1 — run v1 IntegrationTests against v2 topology. Requires (a) test rewrite to use
|
||||||
|
Proxy/Host instead of in-process Host classes, then (b) the parity-debug iteration that the
|
||||||
|
plan budgets 3-4 weeks for.
|
||||||
|
- Stream E.2 — Client.CLI walkthrough diff. Requires the v1 baseline capture.
|
||||||
|
- Stream E.3 — four 2026-04-13 stability findings regression tests. Requires the parity test
|
||||||
|
harness from Stream E.1.
|
||||||
|
- Wonderware Historian SDK plugin loader (Task B.1.h). HistoryRead returns a recognisable
|
||||||
|
error until the plugin loader is wired.
|
||||||
|
- Alarm subsystem wire-up (`MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op today).
|
||||||
|
v1's alarm tracking is its own subtree; queued as Phase 2 follow-up.
|
||||||
|
|
||||||
|
## Stream-D removal checklist (next session)
|
||||||
|
|
||||||
|
1. Decide policy on the 494 v1 tests:
|
||||||
|
- **Option A**: rewrite to use `Driver.Galaxy.Proxy` + `Driver.Galaxy.Host` topology
|
||||||
|
(multi-day; full parity validation as a side effect)
|
||||||
|
- **Option B**: archive them as `OtOpcUa.Tests.v1Archive` and write a smaller v2 parity suite
|
||||||
|
against the new topology (faster; less coverage initially)
|
||||||
|
2. Execute the chosen option.
|
||||||
|
3. Delete `src/ZB.MOM.WW.OtOpcUa.Host/`, remove from `.slnx`.
|
||||||
|
4. Update Windows service installer to register two services
|
||||||
|
(`OtOpcUa` + `OtOpcUaGalaxyHost`) with the correct service-account SIDs.
|
||||||
|
5. Migration script for `appsettings.json` Galaxy sections → `DriverInstance.DriverConfig` JSON.
|
||||||
|
6. PR + adversarial review + `exit-gate-phase-2-final.md`.
|
||||||
|
|
||||||
|
## What ships from this session
|
||||||
|
|
||||||
|
Eight commits on `phase-1-configuration` since the previous push:
|
||||||
|
|
||||||
|
- `01fd90c` Phase 1 finish + Phase 2 scaffold
|
||||||
|
- `7a5b535` Admin UI core
|
||||||
|
- `18f93d7` LDAP + SignalR
|
||||||
|
- `a1e9ed4` AVEVA-stack inventory doc
|
||||||
|
- `32eeeb9` Phase 2 A+B+C feature-complete
|
||||||
|
- `549cd36` GalaxyRepository ported + DbBackedBackend + live ZB smoke
|
||||||
|
- `(this commit)` MXAccess COM port + MxAccessGalaxyBackend + live MXAccess smoke + adversarial review
|
||||||
|
|
||||||
|
`494/494` v1 tests still pass. No regressions.
|
||||||
209
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
209
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# Phase 2 — Partial Exit Evidence (2026-04-17)
|
||||||
|
|
||||||
|
> This records what Phase 2 of v2 completed in the current session and what was explicitly
|
||||||
|
> deferred. See `phase-2-galaxy-out-of-process.md` for the full task plan; this is the as-built
|
||||||
|
> delta.
|
||||||
|
|
||||||
|
## Status: **Streams A + B + C complete (real Win32 pump, all 9 capability interfaces, end-to-end IPC dispatch). Streams D + E remain — gated only on the iterative Galaxy code lift + parity-debug cycle.**
|
||||||
|
|
||||||
|
The goal per the plan is "parity, not regression" — the phase exit gate requires v1
|
||||||
|
IntegrationTests to pass against the v2 Galaxy.Proxy + Galaxy.Host topology byte-for-byte.
|
||||||
|
Achieving that requires live MXAccess runtime plus the Galaxy code lift out of the legacy
|
||||||
|
`OtOpcUa.Host`. Without that cycle, deleting the legacy Host would break the 494 passing v1
|
||||||
|
tests that are the parity baseline.
|
||||||
|
|
||||||
|
> **Update 2026-04-17 (later) — Streams A/B/C now feature-complete, not just scaffolds.**
|
||||||
|
> The Win32 message pump in `StaPump` was upgraded from a `BlockingCollection` placeholder to a
|
||||||
|
> real `GetMessage`/`PostThreadMessage`/`PeekMessage` loop lifted from v1 `StaComThread` (P/Invoke
|
||||||
|
> declarations included; `WM_APP=0x8000` for work-item dispatch, `WM_APP+1` for graceful
|
||||||
|
> drain → `PostQuitMessage`, 5s join-on-dispose). `GalaxyProxyDriver` now implements every
|
||||||
|
> capability interface declared in Phase 2 Stream C — `IDriver`, `ITagDiscovery`, `IReadable`,
|
||||||
|
> `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IRediscoverable`,
|
||||||
|
> `IHostConnectivityProbe` — each forwarding through the matching IPC contract. `GalaxyIpcClient`
|
||||||
|
> gained `SendOneWayAsync` for the fire-and-forget calls (unsubscribe / alarm-ack /
|
||||||
|
> close-session) while still serializing through the call-gate so writes don't interleave with
|
||||||
|
> `CallAsync` round-trips. Host side: `IGalaxyBackend` interface defines the seam between IPC
|
||||||
|
> dispatch and the live MXAccess code, `GalaxyFrameHandler` routes every `MessageKind` into it
|
||||||
|
> (heartbeat handled inline so liveness works regardless of backend health), and
|
||||||
|
> `StubGalaxyBackend` returns success for lifecycle/subscribe/recycle and recognizable
|
||||||
|
> `not-implemented`-coded errors for data-plane calls. End-to-end integration tests exercise
|
||||||
|
> every capability through the full stack (handshake → open session → read / write / subscribe /
|
||||||
|
> alarm / history / recycle) and the v1 test baseline stays green (494 pass, no regressions).
|
||||||
|
>
|
||||||
|
> **What's left for the Phase 2 exit gate:** the actual Galaxy code lift (Task B.1) — replace
|
||||||
|
> `StubGalaxyBackend` with a `MxAccessClient`-backed implementation that calls `MxAccessClient`
|
||||||
|
> on the `StaPump`, plus the parity-cycle debugging against live Galaxy that the plan budgets
|
||||||
|
> 3-4 weeks for. Removing the legacy `OtOpcUa.Host` (Task D.1) follows once the parity tests
|
||||||
|
> are green against the v2 topology.
|
||||||
|
|
||||||
|
> **Update 2026-04-17 — runtime confirmed local.** The dev box has the full AVEVA stack required
|
||||||
|
> for the LmxOpcUa breakout: 27 ArchestrA / Wonderware / AVEVA services running including
|
||||||
|
> `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`,
|
||||||
|
> `ArchestrADataStore`, `AsbServiceManager`; the full Historian set
|
||||||
|
> (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `InSQLStorage`,
|
||||||
|
> `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`,
|
||||||
|
> `HistorianSearch-x64`); SuiteLink (`slssvc`); MXAccess COM at
|
||||||
|
> `C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`; and the OI-Gateway
|
||||||
|
> install at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (so the
|
||||||
|
> AppServer-via-OI-Gateway smoke test from decision #142 is *also* runnable here, not blocked
|
||||||
|
> on a dedicated AVEVA test box).
|
||||||
|
>
|
||||||
|
> The "needs a dev Galaxy" prerequisite is therefore satisfied. Stream D + E can start whenever
|
||||||
|
> the team is ready to take the parity-cycle hit on the 494 v1 tests; no environmental blocker
|
||||||
|
> remains.
|
||||||
|
|
||||||
|
What *is* done: all scaffolding, IPC contracts, supervisor logic, and stability protections
|
||||||
|
needed to hang the real MXAccess code onto. Every piece has unit-level or IPC-level test
|
||||||
|
coverage.
|
||||||
|
|
||||||
|
## Delivered
|
||||||
|
|
||||||
|
### Stream A — `Driver.Galaxy.Shared` (1 week estimate, **complete**)
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/` (.NET Standard 2.0, MessagePack-only
|
||||||
|
dependency)
|
||||||
|
- **Contracts**: `Hello`/`HelloAck` (version negotiation per Task A.3), `OpenSessionRequest`/
|
||||||
|
`OpenSessionResponse`/`CloseSessionRequest`, `Heartbeat`/`HeartbeatAck`, `ErrorResponse`,
|
||||||
|
`DiscoverHierarchyRequest`/`Response` + `GalaxyObjectInfo` + `GalaxyAttributeInfo`,
|
||||||
|
`ReadValuesRequest`/`Response`, `WriteValuesRequest`/`Response`, `SubscribeRequest`/
|
||||||
|
`Response`/`UnsubscribeRequest`/`OnDataChangeNotification`, `AlarmSubscribeRequest`/
|
||||||
|
`GalaxyAlarmEvent`/`AlarmAckRequest`, `HistoryReadRequest`/`Response`+`HistoryTagValues`,
|
||||||
|
`HostConnectivityStatus`+`RuntimeStatusChangeNotification`, `RecycleHostRequest`/
|
||||||
|
`RecycleStatusResponse`
|
||||||
|
- **Framing**: length-prefixed (decision #28) + 1-byte kind tag + MessagePack body. 16 MiB
|
||||||
|
body cap. `FrameWriter`/`FrameReader` with thread-safe write gate.
|
||||||
|
- **Tests (6)**: reflection-scan round-trip for every `[MessagePackObject]`, referenced-
|
||||||
|
assemblies guard (only MessagePack allowed outside BCL), Hello version defaults,
|
||||||
|
`FrameWriter`↔`FrameReader` interop, oversize-frame rejection.
|
||||||
|
|
||||||
|
### Stream B — `Driver.Galaxy.Host` (3–4 week estimate, **scaffold complete; MXAccess lift deferred**)
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/` (.NET Framework 4.8 AnyCPU — flips to x86 when
|
||||||
|
the Galaxy code lift happens per Task B.1 scope)
|
||||||
|
- **`Ipc/PipeAcl`**: builds the strict `PipeSecurity` — allow configured server-principal SID,
|
||||||
|
explicit deny on LocalSystem + Administrators, owner = allowed SID (decision #76).
|
||||||
|
- **`Ipc/PipeServer`**: named-pipe server that (1) enforces the ACL, (2) verifies caller SID
|
||||||
|
via `pipe.RunAsClient` + `WindowsIdentity.GetCurrent`, (3) requires the per-process shared
|
||||||
|
secret in the Hello frame before any other RPC, (4) rejects major-version mismatches.
|
||||||
|
- **`Stability/MemoryWatchdog`**: Galaxy thresholds — warn at `max(1.5×baseline, +200 MB)`,
|
||||||
|
soft-recycle at `max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30 min.
|
||||||
|
Pluggable RSS source for unit testability.
|
||||||
|
- **`Stability/RecyclePolicy`**: 1-recycle/hr cap; 03:00 local daily scheduled recycle.
|
||||||
|
- **`Stability/PostMortemMmf`**: ring buffer of 1000 × 256-byte entries in `%ProgramData%\
|
||||||
|
OtOpcUa\driver-postmortem\galaxy.mmf`. Single-writer / multi-reader. Survives hard crash;
|
||||||
|
supervisor reads the MMF via a second process.
|
||||||
|
- **`Sta/MxAccessHandle`**: `SafeHandle` subclass — `ReleaseHandle` calls `Marshal.ReleaseComObject`
|
||||||
|
in a loop until refcount = 0 then invokes the optional `unregister` callback. Finalizer-safe.
|
||||||
|
Wraps any RCW via `object` so we can unit-test against a mock; the real wiring to
|
||||||
|
`ArchestrA.MxAccess.LMXProxyServer` lands with the deferred code move.
|
||||||
|
- **`Sta/StaPump`**: dedicated STA thread with `BlockingCollection` work queue + `InvokeAsync`
|
||||||
|
dispatch. Responsiveness probe (`IsResponsiveAsync`) returns false on wedge. The real
|
||||||
|
Win32 `GetMessage/DispatchMessage` pump from v1 `LmxProxy.Host` slots in here with the same
|
||||||
|
dispatch semantics.
|
||||||
|
- **`IsExternalInit` shim**: required for `init` setters on .NET 4.8.
|
||||||
|
- **`Program.cs`**: reads `OTOPCUA_GALAXY_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_GALAXY_SECRET`
|
||||||
|
from env (supervisor sets at spawn), runs the pipe server, logs via Serilog to
|
||||||
|
`%ProgramData%\OtOpcUa\galaxy-host-YYYY-MM-DD.log`.
|
||||||
|
- **`Ipc/StubFrameHandler`**: placeholder that heartbeat-acks and returns `not-implemented`
|
||||||
|
errors. Swapped for the real Galaxy-backed handler when the MXAccess code move completes.
|
||||||
|
- **Tests (15)**: `MemoryWatchdog` thresholds + slope detection; `RecyclePolicy` cap + daily
|
||||||
|
schedule; `PostMortemMmf` round-trip + ring-wrap + truncation-safety; `StaPump`
|
||||||
|
apartment-state + responsiveness-probe wedge detection.
|
||||||
|
|
||||||
|
### Stream C — `Driver.Galaxy.Proxy` (1.5 week estimate, **complete as IPC-forwarder**)
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/` (.NET 10)
|
||||||
|
- **`Ipc/GalaxyIpcClient`**: Hello handshake + shared-secret authentication + single-call
|
||||||
|
request/response over the data-plane pipe. Serializes concurrent callers via
|
||||||
|
`SemaphoreSlim`. Lifts `ErrorResponse` to `GalaxyIpcException` with the error code.
|
||||||
|
- **`GalaxyProxyDriver`**: implements `IDriver` + `ITagDiscovery`. Forwards lifecycle and
|
||||||
|
discovery over IPC; maps Galaxy MX data types → `DriverDataType` and security classifications
|
||||||
|
→ `SecurityClassification`. Stream C-plan capability interfaces for `IReadable`, `IWritable`,
|
||||||
|
`ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`,
|
||||||
|
`IRediscoverable` are structured identically — wire them in when the Host's MXAccess backend
|
||||||
|
exists so the round-trips can actually serve data.
|
||||||
|
- **`Supervisor/Backoff`**: 5s → 15s → 60s capped; `RecordStableRun` resets after 2-min
|
||||||
|
successful run.
|
||||||
|
- **`Supervisor/CircuitBreaker`**: 3 crashes per 5 min opens; cooldown escalates
|
||||||
|
1h → 4h → manual (`TimeSpan.MaxValue`). Sticky alert doesn't auto-clear when cooldown
|
||||||
|
elapses; `ManualReset` only.
|
||||||
|
- **`Supervisor/HeartbeatMonitor`**: 2s cadence, 3 consecutive misses = host dead.
|
||||||
|
- **Tests (11)**: `Backoff` sequence + reset; `CircuitBreaker` full 1h/4h/manual escalation
|
||||||
|
path; `HeartbeatMonitor` miss-count + ack-reset; full IPC handshake round-trip
|
||||||
|
(Host + Proxy over a real named pipe, heartbeat ack verified; shared-secret mismatch
|
||||||
|
rejected with `UnauthorizedAccessException`).
|
||||||
|
|
||||||
|
## Deferred (explicitly noted as TODO)
|
||||||
|
|
||||||
|
### Stream D — Retire legacy `OtOpcUa.Host`
|
||||||
|
|
||||||
|
**Not executable until Stream E parity passes.** Deleting the legacy project now would break
|
||||||
|
the 494 v1 IntegrationTests that are the parity baseline. Recovery requires:
|
||||||
|
|
||||||
|
1. Host MXAccess code lift (Task B.1 "move Galaxy code") from `OtOpcUa.Host/` into
|
||||||
|
`OtOpcUa.Driver.Galaxy.Host/` — STA pump wiring, `MxAccessHandle` backing the real
|
||||||
|
`LMXProxyServer`, `GalaxyRepository` and its SQL queries, `GalaxyRuntimeProbeManager`,
|
||||||
|
Historian loader, the Ipc stub handler replaced with a real `IFrameHandler` that invokes
|
||||||
|
the handle.
|
||||||
|
2. Address-space build via `IAddressSpaceBuilder` produces byte-equivalent OPC UA browse
|
||||||
|
output to v1 (Task C.4).
|
||||||
|
3. Windows service installer registers two services (`OtOpcUa` + `OtOpcUaGalaxyHost`) with
|
||||||
|
the correct service-account SIDs and per-process secret provisioning. Galaxy.Host starts
|
||||||
|
before OtOpcUa.
|
||||||
|
4. `appsettings.json` Galaxy config (MxAccess / Galaxy / Historian sections) migrated into
|
||||||
|
`DriverInstance.DriverConfig` JSON in the Configuration DB via an idempotent migration
|
||||||
|
script. Post-migration, the local `appsettings.json` keeps only `Cluster.NodeId`,
|
||||||
|
`ClusterId`, and the DB conn string per decision #18.
|
||||||
|
|
||||||
|
### Stream E — Parity validation
|
||||||
|
|
||||||
|
Requires live MXAccess + Galaxy runtime and the above lift complete. Work items:
|
||||||
|
|
||||||
|
- Run v1 IntegrationTests against the v2 Galaxy.Proxy + Galaxy.Host topology. Pass count =
|
||||||
|
v1 baseline; failures = 0. Per-test duration regression report flags any test >2× baseline.
|
||||||
|
- Scripted Client.CLI walkthrough recorded at Phase 2 entry gate against v1, replayed
|
||||||
|
against v2; diff must show only timestamp/latency differences.
|
||||||
|
- Regression tests for the four 2026-04-13 stability findings (phantom probe, cross-host
|
||||||
|
quality clear, sync-over-async guard, fire-and-forget alarm drain).
|
||||||
|
- `/codex:adversarial-review --base v2` on the merged Phase 2 diff — findings closed or
|
||||||
|
deferred with rationale.
|
||||||
|
|
||||||
|
## Also deferred from Stream B
|
||||||
|
|
||||||
|
- **Task B.10 FaultShim** (test-only `ArchestrA.MxAccess` substitute for fault injection).
|
||||||
|
Needs the production `ArchestrA.MxAccess` reference in place first; flagged as part of the
|
||||||
|
plan's "mid-gate review" fallback (Risk row 7).
|
||||||
|
- **Task B.8 WM_QUIT hard-exit escalation** — wired in when the real Win32 pump replaces the
|
||||||
|
`BlockingCollection` dispatcher. The `StaPump.IsResponsiveAsync` probe already exists; the
|
||||||
|
supervisor escalation-to-`Environment.Exit(2)` belongs to the Program main loop after the
|
||||||
|
pump integration.
|
||||||
|
|
||||||
|
## Cross-session impact on the build
|
||||||
|
|
||||||
|
- **Full solution**: 926 tests pass, 1 fails (pre-existing Phase 0 baseline
|
||||||
|
`Client.CLI.Tests.SubscribeCommandTests.Execute_PrintsSubscriptionMessage` — not a Phase 2
|
||||||
|
regression; was red before Phase 1 and stays red through Phase 2).
|
||||||
|
- **New projects added to `.slnx`**: `Driver.Galaxy.Shared`, `Driver.Galaxy.Host`,
|
||||||
|
`Driver.Galaxy.Proxy`, plus the three matching test projects.
|
||||||
|
- **No existing tests broke.** The 494 v1 `OtOpcUa.Tests` (net48) and 6 `IntegrationTests`
|
||||||
|
(net48) still pass because the legacy `OtOpcUa.Host` is untouched.
|
||||||
|
|
||||||
|
## Next-session checklist for Stream D + E
|
||||||
|
|
||||||
|
1. Verify the local AVEVA stack is still green (`Get-Service aaGR, aaBootstrap, slssvc` →
|
||||||
|
Running) and the Galaxy `ZB` repository is reachable from `sqlcmd -S localhost -d ZB -E`.
|
||||||
|
The runtime is already on this machine — no install step needed.
|
||||||
|
2. Capture Client.CLI walkthrough baseline against v1 (the parity reference).
|
||||||
|
3. Move Galaxy-specific files from `OtOpcUa.Host` into `Driver.Galaxy.Host`, renaming
|
||||||
|
namespaces. Replace `StubFrameHandler` with the real one.
|
||||||
|
4. Wire up the real Win32 pump inside `StaPump` (lift from scadalink-design's
|
||||||
|
`LmxProxy.Host` reference per CLAUDE.md).
|
||||||
|
5. Run v1 IntegrationTests against the v2 topology — iterate on parity defects until green.
|
||||||
|
6. Run Client.CLI walkthrough and diff.
|
||||||
|
7. Regression tests for the four 2026-04-13 stability findings.
|
||||||
|
8. Delete legacy `OtOpcUa.Host`; update `.slnx`; update installer scripts.
|
||||||
|
9. Optional but valuable now that the runtime is local: AppServer-via-OI-Gateway smoke test
|
||||||
|
(decision #142 / Phase 1 Task E.10) — the OI-Gateway install at
|
||||||
|
`C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` is in place; the test was deferred
|
||||||
|
for "needs live AVEVA runtime" reasons that no longer apply on this dev box.
|
||||||
|
10. Adversarial review; `exit-gate-phase-2.md` recorded; PR merged.
|
||||||
80
docs/v2/implementation/pr-1-body.md
Normal file
80
docs/v2/implementation/pr-1-body.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# PR 1 — Phase 1 + Phase 2 A/B/C → v2
|
||||||
|
|
||||||
|
**Source**: `phase-1-configuration` (commits `980ea51..7403b92`, 11 commits)
|
||||||
|
**Target**: `v2`
|
||||||
|
**URL**: https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-1-configuration
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- **Phase 1 complete** — Configuration project with 16 entities + 3 EF migrations
|
||||||
|
(InitialSchema + 8 stored procs + AuthorizationGrants), Core + Server + full Admin UI
|
||||||
|
(Blazor Server with cluster CRUD, draft → diff → publish → rollback, equipment with
|
||||||
|
OPC 40010, UNS, namespaces, drivers, ACLs, reservations, audit), LDAP via GLAuth
|
||||||
|
(`localhost:3893`), SignalR real-time fleet status + alerts.
|
||||||
|
- **Phase 2 Streams A + B + C feature-complete** — full IPC contract surface
|
||||||
|
(Galaxy.Shared, netstandard2.0, MessagePack), Galaxy.Host with real Win32 STA pump,
|
||||||
|
ACL + caller-SID + per-process-secret IPC, Galaxy-specific MemoryWatchdog +
|
||||||
|
RecyclePolicy + PostMortemMmf + MxAccessHandle, three `IGalaxyBackend`
|
||||||
|
implementations (Stub / DbBacked / **MxAccess** — real ArchestrA.MxAccess.dll
|
||||||
|
reference, x86, smoke-tested live against `LMXProxyServer`), Galaxy.Proxy with all
|
||||||
|
9 capability interfaces (`IDriver` / `ITagDiscovery` / `IReadable` / `IWritable` /
|
||||||
|
`ISubscribable` / `IAlarmSource` / `IHistoryProvider` / `IRediscoverable` /
|
||||||
|
`IHostConnectivityProbe`) + supervisor (Backoff + CircuitBreaker +
|
||||||
|
HeartbeatMonitor).
|
||||||
|
- **Phase 2 Stream D non-destructive deliverables** — appsettings.json → DriverConfig
|
||||||
|
migration script, two-service Windows installer scripts, process-spawn cross-FX
|
||||||
|
parity test, Stream D removal procedure doc with both Option A (rewrite 494 v1
|
||||||
|
tests) and Option B (archive + new v2 E2E suite) spelled out step-by-step.
|
||||||
|
|
||||||
|
## What's NOT in this PR
|
||||||
|
|
||||||
|
- Legacy `OtOpcUa.Host` deletion (Stream D.1) — reserved for a follow-up PR after
|
||||||
|
Option B's E2E suite is green. The 494 v1 tests still pass against the unchanged
|
||||||
|
legacy Host.
|
||||||
|
- Live-Galaxy parity validation (Stream E) — needs the iterative debug cycle the
|
||||||
|
removal-procedure doc describes.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**964 pass / 1 pre-existing Phase 0 baseline failure**, across 14 test projects:
|
||||||
|
|
||||||
|
| Project | Pass | Notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| Core.Abstractions.Tests | 24 | |
|
||||||
|
| Configuration.Tests | 42 | incl. 7 schema compliance, 8 stored-proc, 3 SQL-role auth, 13 validator, 6 LiteDB cache, 5 generation-applier |
|
||||||
|
| Core.Tests | 4 | DriverHost lifecycle |
|
||||||
|
| Server.Tests | 2 | NodeBootstrap + LiteDB cache fallback |
|
||||||
|
| Admin.Tests | 21 | incl. 5 RoleMapper, 6 LdapAuth, 3 LiveLdap, 2 FleetStatusPoller, 2 services-integration |
|
||||||
|
| Driver.Galaxy.Shared.Tests | 6 | Round-trip + framing |
|
||||||
|
| Driver.Galaxy.Host.Tests | 30 | incl. 5 GalaxyRepository live ZB, 3 live MXAccess COM, 5 EndToEndIpc, 2 IpcHandshake, 4 MemoryWatchdog, 3 RecyclePolicy, 3 PostMortemMmf, 3 StaPump, 2 service-installer dry-run |
|
||||||
|
| Driver.Galaxy.Proxy.Tests | 10 | 9 unit + 1 process-spawn parity |
|
||||||
|
| Client.Shared.Tests | 131 | unchanged |
|
||||||
|
| Client.UI.Tests | 98 | unchanged |
|
||||||
|
| Client.CLI.Tests | 51 / 1 fail | pre-existing baseline failure |
|
||||||
|
| Historian.Aveva.Tests | 41 | unchanged |
|
||||||
|
| IntegrationTests (net48) | 6 | unchanged — v1 parity baseline |
|
||||||
|
| **OtOpcUa.Tests (net48)** | **494** | **unchanged — v1 parity baseline** |
|
||||||
|
|
||||||
|
## Test plan for reviewers
|
||||||
|
|
||||||
|
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the
|
||||||
|
known NuGetAuditSuppress + xUnit1051 warnings
|
||||||
|
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the same 964/1 result
|
||||||
|
- [ ] `Get-Service aaGR, aaBootstrap` reports Running on the merger's box
|
||||||
|
- [ ] `docker ps --filter name=otopcua-mssql` shows the SQL container Up
|
||||||
|
- [ ] Admin UI boots (`dotnet run --project src/ZB.MOM.WW.OtOpcUa.Admin`); home page
|
||||||
|
renders at http://localhost:5123/; LDAP sign-in with GLAuth `readonly` /
|
||||||
|
`readonly123` succeeds
|
||||||
|
- [ ] Migration script dry-run: `powershell -File
|
||||||
|
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1 -DryRun` produces
|
||||||
|
a well-formed DriverConfig JSON
|
||||||
|
- [ ] Spot-read three commit messages to confirm the deferred-with-rationale items
|
||||||
|
are explicitly documented (`549cd36`, `a7126ba`, `7403b92` are the most
|
||||||
|
recent and most detailed)
|
||||||
|
|
||||||
|
## Follow-up tracking
|
||||||
|
|
||||||
|
PR 2 (next session) will execute Stream D Option B — archive `OtOpcUa.Tests` as
|
||||||
|
`OtOpcUa.Tests.v1Archive`, build the new `OtOpcUa.Driver.Galaxy.E2E` test project,
|
||||||
|
delete legacy `OtOpcUa.Host`, and run the parity-validation cycle. See
|
||||||
|
`docs/v2/implementation/stream-d-removal-procedure.md`.
|
||||||
69
docs/v2/implementation/pr-2-body.md
Normal file
69
docs/v2/implementation/pr-2-body.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# PR 2 — Phase 2 Stream D Option B (archive v1 + E2E suite) → v2
|
||||||
|
|
||||||
|
**Source**: `phase-2-stream-d` (branched from `phase-1-configuration`)
|
||||||
|
**Target**: `v2`
|
||||||
|
**URL** (after push): https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-2-stream-d
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 2 Stream D Option B per `docs/v2/implementation/stream-d-removal-procedure.md`:
|
||||||
|
|
||||||
|
- **Archived the v1 surface** without deleting:
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||||
|
(`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so v1 Host's `InternalsVisibleTo`
|
||||||
|
still matches; `<IsTestProject>false</IsTestProject>` so solution test runs skip it).
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` — `<IsTestProject>false</IsTestProject>`
|
||||||
|
+ archive comment.
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Host/` + `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` — archive
|
||||||
|
PropertyGroup comments. Both still build (Historian plugin + 41 historian tests still
|
||||||
|
pass) so Phase 2 PR 3 can delete them in a focused, reviewable destructive change.
|
||||||
|
- **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** test project (.NET 10):
|
||||||
|
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as a subprocess via
|
||||||
|
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||||
|
Skips when Galaxy ZB unreachable / Host EXE not built / Administrator shell.
|
||||||
|
- `HierarchyParityTests` (3) and `StabilityFindingsRegressionTests` (4) — one test per
|
||||||
|
2026-04-13 stability finding (phantom probe, cross-host quality clear, sync-over-async,
|
||||||
|
fire-and-forget alarm shutdown race).
|
||||||
|
- **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||||
|
- **`docs/v2/implementation/exit-gate-phase-2-final.md`** — supersedes the two partial-exit
|
||||||
|
docs with the as-built state, adversarial review of PR 2 deltas (4 new findings), and the
|
||||||
|
recommended PR sequence (1 → 2 → 3 → 4).
|
||||||
|
|
||||||
|
## What's NOT in this PR
|
||||||
|
|
||||||
|
- Deletion of the v1 archive — saved for PR 3 with explicit operator review (destructive change).
|
||||||
|
- Wonderware Historian SDK plugin port — Task B.1.h, follow-up to enable real `HistoryRead`.
|
||||||
|
- MxAccess subscription push-frames — Task B.1.s, follow-up to enable real-time
|
||||||
|
data-change push from Host → Proxy.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 pre-existing baseline**.
|
||||||
|
|
||||||
|
The 7 skips are the new E2E tests, all skipping with the documented reason
|
||||||
|
"PipeAcl denies Administrators on dev shells" — the production install runs as a non-admin
|
||||||
|
service account and these tests will execute there.
|
||||||
|
|
||||||
|
Run the archived v1 suites explicitly:
|
||||||
|
```powershell
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive # → 494 pass
|
||||||
|
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests # → 6 pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test plan for reviewers
|
||||||
|
|
||||||
|
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the known
|
||||||
|
NuGetAuditSuppress + NU1702 cross-FX
|
||||||
|
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the 470/7-skip/1-baseline result
|
||||||
|
- [ ] Both archived suites pass when run explicitly
|
||||||
|
- [ ] Build the Galaxy.Host EXE (`dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`),
|
||||||
|
then run E2E tests on a non-admin shell — they should actually execute and pass
|
||||||
|
against live Galaxy ZB
|
||||||
|
- [ ] Spot-read `docs/v2/V1_ARCHIVE_STATUS.md` and confirm the deletion plan is acceptable
|
||||||
|
|
||||||
|
## Follow-up tracking
|
||||||
|
|
||||||
|
- **PR 3** (next session, when ready): execute the deletion plan in `V1_ARCHIVE_STATUS.md`.
|
||||||
|
4 projects removed, .slnx updated, full solution test confirms parity.
|
||||||
|
- **PR 4** (Phase 2 follow-up): port Historian plugin + wire MxAccess subscription pushes +
|
||||||
|
close the high/medium open findings from `exit-gate-phase-2-final.md`.
|
||||||
103
docs/v2/implementation/stream-d-removal-procedure.md
Normal file
103
docs/v2/implementation/stream-d-removal-procedure.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Stream D — Legacy `OtOpcUa.Host` Removal Procedure
|
||||||
|
|
||||||
|
> Sequenced playbook for the next session that takes Phase 2 to its full exit gate.
|
||||||
|
> All Stream A/B/C work is committed. The blocker is structural: the 494 v1
|
||||||
|
> `OtOpcUa.Tests` instantiate v1 `Host` classes directly, so they must be
|
||||||
|
> retargeted (or archived) before the Host project can be deleted.
|
||||||
|
|
||||||
|
## Decision: Option A or Option B
|
||||||
|
|
||||||
|
### Option A — Rewrite the 494 v1 tests to use v2 topology
|
||||||
|
|
||||||
|
**Effort**: 3-5 days. Highest fidelity (full v1 test coverage carries forward).
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Build a `ProxyMxAccessClientAdapter` in a new `OtOpcUa.LegacyTestCompat/` project that
|
||||||
|
implements v1's `IMxAccessClient` by forwarding to `Driver.Galaxy.Proxy.GalaxyProxyDriver`.
|
||||||
|
Maps v1 `Vtq` ↔ v2 `DataValueSnapshot`, v1 `Quality` enum ↔ v2 `StatusCode` u32, the v1
|
||||||
|
`OnTagValueChanged` event ↔ v2 `ISubscribable.OnDataChange`.
|
||||||
|
2. Same idea for `IGalaxyRepository` — adapter that wraps v2's `Backend.Galaxy.GalaxyRepository`.
|
||||||
|
3. Replace `MxAccessClient` constructions in `OtOpcUa.Tests` test fixtures with the adapter.
|
||||||
|
Most tests use a single fixture so the change-set is concentrated.
|
||||||
|
4. For each test class: run; iterate on parity defects until green. Expected defect families:
|
||||||
|
timing-sensitive assertions (IPC adds ~5ms latency; widen tolerances), Quality enum vs
|
||||||
|
StatusCode mismatches, value-byte-encoding differences.
|
||||||
|
5. Once all 494 pass: proceed to deletion checklist below.
|
||||||
|
|
||||||
|
**When to pick A**: regulatory environments that need the full historical test suite green,
|
||||||
|
or when the v2 parity gate is itself a release-blocking artifact downstream consumers will
|
||||||
|
look for.
|
||||||
|
|
||||||
|
### Option B — Archive the 494 v1 tests, build a smaller v2 parity suite
|
||||||
|
|
||||||
|
**Effort**: 1-2 days. Faster to green; less coverage initially, accreted over time.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. Rename `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`.
|
||||||
|
Add `<IsTestProject>false</IsTestProject>` so CI doesn't run them; mark every class with
|
||||||
|
`[Trait("Category", "v1Archive")]` so a future operator can opt in via `--filter`.
|
||||||
|
2. New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/` project (.NET 10):
|
||||||
|
- `ParityFixture` spawns Galaxy.Host EXE per test class with `OTOPCUA_GALAXY_BACKEND=mxaccess`
|
||||||
|
pointing at the dev box's live Galaxy. Pattern from `HostSubprocessParityTests`.
|
||||||
|
- 10-20 representative tests covering the core paths: hierarchy shape, attribute count,
|
||||||
|
read-Manufacturer-Boolean, write-Operate-Float roundtrip, subscribe-receives-OnDataChange,
|
||||||
|
Bad-quality on disconnect, alarm-event-shape.
|
||||||
|
3. The four 2026-04-13 stability findings get individual regression tests in this project.
|
||||||
|
4. Once green: proceed to deletion checklist below.
|
||||||
|
|
||||||
|
**When to pick B**: typical dev velocity case. The v1 archive is reference, the new suite is
|
||||||
|
the live parity bar.
|
||||||
|
|
||||||
|
## Deletion checklist (after Option A or B is green)
|
||||||
|
|
||||||
|
Pre-conditions:
|
||||||
|
- [ ] Chosen-option test suite green (494 retargeted OR new E2E suite passing on this box)
|
||||||
|
- [ ] `phase-2-compliance.ps1` runs and exits 0
|
||||||
|
- [ ] `Get-Service aaGR, aaBootstrap` → Running
|
||||||
|
- [ ] `Driver.Galaxy.Host` x86 publish output verified at
|
||||||
|
`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/bin/Release/net48/`
|
||||||
|
- [ ] Migration script tested: `scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
|
||||||
|
-AppSettingsPath src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json -DryRun` produces a
|
||||||
|
well-formed DriverConfig
|
||||||
|
- [ ] Service installer scripts dry-run on a test box: `scripts/install/Install-Services.ps1
|
||||||
|
-InstallRoot C:\OtOpcUa -ServiceAccount LOCALHOST\testuser` registers both services
|
||||||
|
and they start
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Delete `src/ZB.MOM.WW.OtOpcUa.Host/` (the legacy in-process Host project).
|
||||||
|
2. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the legacy Host `<Project>` line; keep all v2
|
||||||
|
project lines.
|
||||||
|
3. Migrate the dev `appsettings.json` Galaxy sections to `DriverConfig` JSON via the
|
||||||
|
migration script; insert into the Configuration DB for the dev cluster's Galaxy driver
|
||||||
|
instance.
|
||||||
|
4. Run the chosen test suite once more — confirm zero regressions from the deletion.
|
||||||
|
5. Build full solution (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) — confirm clean build with
|
||||||
|
no references to the deleted project.
|
||||||
|
6. Commit:
|
||||||
|
`git rm -r src/ZB.MOM.WW.OtOpcUa.Host` followed by the slnx + cleanup edits in one
|
||||||
|
atomic commit titled "Phase 2 Stream D — retire legacy OtOpcUa.Host".
|
||||||
|
7. Run `/codex:adversarial-review --base v2` on the merged Phase 2 diff.
|
||||||
|
8. Record `exit-gate-phase-2-final.md` with: Option chosen, deletion-commit SHA, parity
|
||||||
|
test count + duration, adversarial-review findings (each closed or deferred with link).
|
||||||
|
9. Open PR against `v2`, link the exit-gate doc + compliance script output + parity report.
|
||||||
|
10. Merge after one reviewer signoff.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If Stream D causes downstream consumer failures (ScadaBridge / Ignition / SystemPlatform IO
|
||||||
|
clients seeing different OPC UA behavior), the rollback is `git revert` of the deletion
|
||||||
|
commit — the whole v2 codebase keeps Galaxy.Proxy + Galaxy.Host installed alongside the
|
||||||
|
restored legacy Host. Production can run either topology. `OtOpcUa.Driver.Galaxy.Proxy`
|
||||||
|
becomes dormant until the next attempt.
|
||||||
|
|
||||||
|
## Why this can't one-shot in an autonomous session
|
||||||
|
|
||||||
|
- The parity-defect debug cycle is intrinsically interactive: each iteration requires running
|
||||||
|
the test suite against live Galaxy, inspecting the diff, deciding if the difference is a
|
||||||
|
legitimate v2 improvement or a regression, then either widening the assertion or fixing the
|
||||||
|
v2 code. That decision-making is the bottleneck, not the typing.
|
||||||
|
- The legacy-Host deletion is destructive — needs explicit operator authorization on a real
|
||||||
|
PR review, not unattended automation.
|
||||||
|
- The downstream consumer cutover (ScadaBridge, Ignition, AppServer) lives outside this repo
|
||||||
|
and on an integration-team track; "Phase 2 done" inside this repo is a precondition, not
|
||||||
|
the full release.
|
||||||
@@ -234,6 +234,8 @@ All of these stay in the Galaxy Host process (.NET 4.8 x86). The `GalaxyProxy` i
|
|||||||
- Refactor is **incremental**: extract `IDriver` / `ISubscribable` / `ITagDiscovery` etc. against the existing `LmxNodeManager` first (still in-process on v2 branch), validate the system still runs, *then* move the implementation behind the IPC boundary into Galaxy.Host. Keeps the system runnable at each step and de-risks the out-of-process move.
|
- Refactor is **incremental**: extract `IDriver` / `ISubscribable` / `ITagDiscovery` etc. against the existing `LmxNodeManager` first (still in-process on v2 branch), validate the system still runs, *then* move the implementation behind the IPC boundary into Galaxy.Host. Keeps the system runnable at each step and de-risks the out-of-process move.
|
||||||
- **Parity test**: run the existing v1 IntegrationTests suite against the v2 Galaxy driver (same Galaxy, same expectations) **plus** a scripted Client.CLI walkthrough (connect / browse / read / write / subscribe / history / alarms) on a dev Galaxy. Automated regression + human-observable behavior.
|
- **Parity test**: run the existing v1 IntegrationTests suite against the v2 Galaxy driver (same Galaxy, same expectations) **plus** a scripted Client.CLI walkthrough (connect / browse / read / write / subscribe / history / alarms) on a dev Galaxy. Automated regression + human-observable behavior.
|
||||||
|
|
||||||
|
**Dev environment for the LmxOpcUa breakout:** the Phase 0/1 dev box (`DESKTOP-6JL3KKO`) hosts the full AVEVA stack required to execute Phase 2 Streams D + E — 27 ArchestrA / Wonderware / AVEVA services running including `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`; the full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `HistorianSearch-x64`); SuiteLink (`slssvc`); MXAccess COM at `C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`; and OI-Gateway at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` — so the Phase 1 Task E.10 AppServer-via-OI-Gateway smoke test (decision #142) is also runnable on the same box, no separate AVEVA test machine required. Inventory captured in `dev-environment.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Configuration Model — Centralized MSSQL + Local Cache
|
### 4. Configuration Model — Centralized MSSQL + Local Cache
|
||||||
|
|||||||
102
scripts/install/Install-Services.ps1
Normal file
102
scripts/install/Install-Services.ps1
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Registers the two v2 Windows services on a node: OtOpcUa (main server, net10) and
|
||||||
|
OtOpcUaGalaxyHost (out-of-process Galaxy COM host, net48 x86).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Phase 2 Stream D.2 — replaces the v1 single-service install (TopShelf-based OtOpcUa.Host).
|
||||||
|
Installs both services with the correct service-account SID + per-process shared secret
|
||||||
|
provisioning per `driver-stability.md §"IPC Security"`. Galaxy.Host depends on OtOpcUa
|
||||||
|
(Galaxy.Host must be reachable when OtOpcUa starts; service dependency wiring + retry
|
||||||
|
handled by OtOpcUa.Server NodeBootstrap).
|
||||||
|
|
||||||
|
.PARAMETER InstallRoot
|
||||||
|
Where the binaries live (typically C:\Program Files\OtOpcUa).
|
||||||
|
|
||||||
|
.PARAMETER ServiceAccount
|
||||||
|
Service account SID or DOMAIN\name. Both services run under this account; the
|
||||||
|
Galaxy.Host pipe ACL only allows this SID to connect (decision #76).
|
||||||
|
|
||||||
|
.PARAMETER GalaxySharedSecret
|
||||||
|
Per-process secret passed to Galaxy.Host via env var. Generated freshly per install.
|
||||||
|
|
||||||
|
.PARAMETER ZbConnection
|
||||||
|
Galaxy ZB SQL connection string (passed to Galaxy.Host via env var).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua'
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||||
|
[Parameter(Mandatory)] [string]$ServiceAccount,
|
||||||
|
[string]$GalaxySharedSecret,
|
||||||
|
[string]$ZbConnection = 'Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;',
|
||||||
|
[string]$GalaxyClientName = 'OtOpcUa-Galaxy.Host',
|
||||||
|
[string]$GalaxyPipeName = 'OtOpcUaGalaxy'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Test-Path "$InstallRoot\OtOpcUa.Server.exe")) {
|
||||||
|
Write-Error "OtOpcUa.Server.exe not found at $InstallRoot — copy the publish output first"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe")) {
|
||||||
|
Write-Error "OtOpcUa.Driver.Galaxy.Host.exe not found at $InstallRoot\Galaxy — copy the publish output first"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate a fresh shared secret per install if not supplied. Stored in DPAPI-protected file
|
||||||
|
# rather than the registry so the service account can read it but other local users cannot.
|
||||||
|
if (-not $GalaxySharedSecret) {
|
||||||
|
$bytes = New-Object byte[] 32
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
||||||
|
$GalaxySharedSecret = [Convert]::ToBase64String($bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve the SID — the IPC ACL needs the SID, not the down-level name.
|
||||||
|
$sid = if ($ServiceAccount.StartsWith('S-1-')) {
|
||||||
|
$ServiceAccount
|
||||||
|
} else {
|
||||||
|
(New-Object System.Security.Principal.NTAccount $ServiceAccount).Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Install OtOpcUaGalaxyHost first (OtOpcUa starts after, depends on it being up).
|
||||||
|
$galaxyEnv = @(
|
||||||
|
"OTOPCUA_GALAXY_PIPE=$GalaxyPipeName"
|
||||||
|
"OTOPCUA_ALLOWED_SID=$sid"
|
||||||
|
"OTOPCUA_GALAXY_SECRET=$GalaxySharedSecret"
|
||||||
|
"OTOPCUA_GALAXY_BACKEND=mxaccess"
|
||||||
|
"OTOPCUA_GALAXY_ZB_CONN=$ZbConnection"
|
||||||
|
"OTOPCUA_GALAXY_CLIENT_NAME=$GalaxyClientName"
|
||||||
|
) -join "`0"
|
||||||
|
$galaxyEnv += "`0`0"
|
||||||
|
|
||||||
|
Write-Host "Installing OtOpcUaGalaxyHost..."
|
||||||
|
& sc.exe create OtOpcUaGalaxyHost binPath= "`"$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe`"" `
|
||||||
|
DisplayName= 'OtOpcUa Galaxy Host (out-of-process MXAccess)' `
|
||||||
|
start= auto `
|
||||||
|
obj= $ServiceAccount | Out-Null
|
||||||
|
|
||||||
|
# Set per-service environment variables via the registry — sc.exe doesn't expose them directly.
|
||||||
|
$svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost"
|
||||||
|
$envValue = $galaxyEnv.Split("`0") | Where-Object { $_ -ne '' }
|
||||||
|
Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue
|
||||||
|
|
||||||
|
# --- Install OtOpcUa (depends on Galaxy host being installed; doesn't strictly require it
|
||||||
|
# started — OtOpcUa.Server NodeBootstrap retries on the IPC connect path).
|
||||||
|
Write-Host "Installing OtOpcUa..."
|
||||||
|
& sc.exe create OtOpcUa binPath= "`"$InstallRoot\OtOpcUa.Server.exe`"" `
|
||||||
|
DisplayName= 'OtOpcUa Server' `
|
||||||
|
start= auto `
|
||||||
|
depend= 'OtOpcUaGalaxyHost' `
|
||||||
|
obj= $ServiceAccount | Out-Null
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installed. Start with:"
|
||||||
|
Write-Host " sc.exe start OtOpcUaGalaxyHost"
|
||||||
|
Write-Host " sc.exe start OtOpcUa"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Galaxy shared secret (record this offline — required for service rebinding):"
|
||||||
|
Write-Host " $GalaxySharedSecret"
|
||||||
18
scripts/install/Uninstall-Services.ps1
Normal file
18
scripts/install/Uninstall-Services.ps1
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Stops + removes the two v2 services. Mirrors Install-Services.ps1.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()] param()
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
foreach ($svc in 'OtOpcUa', 'OtOpcUaGalaxyHost') {
|
||||||
|
if (Get-Service $svc -ErrorAction SilentlyContinue) {
|
||||||
|
Write-Host "Stopping $svc..."
|
||||||
|
Stop-Service $svc -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Removing $svc..."
|
||||||
|
& sc.exe delete $svc | Out-Null
|
||||||
|
} else {
|
||||||
|
Write-Host "$svc not installed — skipping"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "Done."
|
||||||
107
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
Normal file
107
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Translates a v1 OtOpcUa.Host appsettings.json into a v2 DriverInstance.DriverConfig JSON
|
||||||
|
blob suitable for upserting into the central Configuration DB.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Phase 2 Stream D.3 — moves the legacy MxAccess + GalaxyRepository + Historian sections out
|
||||||
|
of node-local appsettings.json and into the central DB so each node only needs Cluster.NodeId
|
||||||
|
+ ClusterId + DB conn (per decision #18). Idempotent + dry-run-able.
|
||||||
|
|
||||||
|
Output shape matches the Galaxy DriverType schema in `docs/v2/plan.md` §"Galaxy DriverConfig":
|
||||||
|
|
||||||
|
{
|
||||||
|
"MxAccess": { "ClientName": "...", "RequestTimeoutSeconds": 30 },
|
||||||
|
"Database": { "ConnectionString": "...", "PollIntervalSeconds": 60 },
|
||||||
|
"Historian": { "Enabled": false }
|
||||||
|
}
|
||||||
|
|
||||||
|
.PARAMETER AppSettingsPath
|
||||||
|
Path to the v1 appsettings.json. Defaults to ../../src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
|
||||||
|
relative to the script.
|
||||||
|
|
||||||
|
.PARAMETER OutputPath
|
||||||
|
Where to write the generated DriverConfig JSON. Defaults to stdout.
|
||||||
|
|
||||||
|
.PARAMETER DryRun
|
||||||
|
Print what would be written without writing.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh ./Migrate-AppSettings-To-DriverConfig.ps1 -AppSettingsPath C:\OtOpcUa\appsettings.json -OutputPath C:\tmp\galaxy-driverconfig.json
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$AppSettingsPath,
|
||||||
|
[string]$OutputPath,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not $AppSettingsPath) {
|
||||||
|
$AppSettingsPath = Join-Path (Split-Path -Parent $PSScriptRoot) '..\src\ZB.MOM.WW.OtOpcUa.Host\appsettings.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $AppSettingsPath)) {
|
||||||
|
Write-Error "AppSettings file not found: $AppSettingsPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = Get-Content -Raw $AppSettingsPath | ConvertFrom-Json
|
||||||
|
|
||||||
|
$mx = $src.MxAccess
|
||||||
|
$gr = $src.GalaxyRepository
|
||||||
|
$hi = $src.Historian
|
||||||
|
|
||||||
|
$driverConfig = [ordered]@{
|
||||||
|
MxAccess = [ordered]@{
|
||||||
|
ClientName = $mx.ClientName
|
||||||
|
NodeName = $mx.NodeName
|
||||||
|
GalaxyName = $mx.GalaxyName
|
||||||
|
RequestTimeoutSeconds = $mx.ReadTimeoutSeconds
|
||||||
|
WriteTimeoutSeconds = $mx.WriteTimeoutSeconds
|
||||||
|
MaxConcurrentOps = $mx.MaxConcurrentOperations
|
||||||
|
MonitorIntervalSec = $mx.MonitorIntervalSeconds
|
||||||
|
AutoReconnect = $mx.AutoReconnect
|
||||||
|
ProbeTag = $mx.ProbeTag
|
||||||
|
}
|
||||||
|
Database = [ordered]@{
|
||||||
|
ConnectionString = $gr.ConnectionString
|
||||||
|
ChangeDetectionIntervalSec = $gr.ChangeDetectionIntervalSeconds
|
||||||
|
CommandTimeoutSeconds = $gr.CommandTimeoutSeconds
|
||||||
|
ExtendedAttributes = $gr.ExtendedAttributes
|
||||||
|
Scope = $gr.Scope
|
||||||
|
PlatformName = $gr.PlatformName
|
||||||
|
}
|
||||||
|
Historian = [ordered]@{
|
||||||
|
Enabled = if ($null -ne $hi -and $null -ne $hi.Enabled) { $hi.Enabled } else { $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip null-valued leaves so the resulting JSON is compact and round-trippable.
|
||||||
|
function Remove-Nulls($obj) {
|
||||||
|
$keys = @($obj.Keys)
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($null -eq $obj[$k]) { $obj.Remove($k) | Out-Null }
|
||||||
|
elseif ($obj[$k] -is [System.Collections.Specialized.OrderedDictionary]) { Remove-Nulls $obj[$k] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Nulls $driverConfig
|
||||||
|
|
||||||
|
$json = $driverConfig | ConvertTo-Json -Depth 8
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host "=== DriverConfig (dry-run, would write to $OutputPath) ==="
|
||||||
|
Write-Host $json
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($OutputPath) {
|
||||||
|
$dir = Split-Path -Parent $OutputPath
|
||||||
|
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
|
||||||
|
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
|
||||||
|
Write-Host "Wrote DriverConfig to $OutputPath"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$json
|
||||||
|
}
|
||||||
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@* Root Blazor component. *@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>OtOpcUa Admin</title>
|
||||||
|
<base href="/"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="app.css"/>
|
||||||
|
<HeadOutlet/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Routes/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="d-flex" style="min-height: 100vh;">
|
||||||
|
<nav class="bg-dark text-light p-3" style="width: 220px;">
|
||||||
|
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="small text-light">
|
||||||
|
Signed in as <strong>@context.User.Identity?.Name</strong>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/auth/logout">
|
||||||
|
<button class="btn btn-sm btn-outline-light mt-2" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<a class="btn btn-sm btn-outline-light" href="/login">Sign in</a>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="flex-grow-1 p-4">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@inject NodeAclService AclSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<h4>Access-control grants</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_acls is null) { <p>Loading…</p> }
|
||||||
|
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var a in _acls)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@a.LdapGroup</td>
|
||||||
|
<td>@a.ScopeKind</td>
|
||||||
|
<td><code>@(a.ScopeId ?? "-")</code></td>
|
||||||
|
<td><code>@a.PermissionFlags</code></td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">LDAP group</label>
|
||||||
|
<input class="form-control" @bind="_group"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Scope kind</label>
|
||||||
|
<select class="form-select" @bind="_scopeKind">
|
||||||
|
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
|
||||||
|
<input class="form-control" @bind="_scopeId"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
|
||||||
|
<select class="form-select" @bind="_preset">
|
||||||
|
<option value="Read">Read (Browse + Read)</option>
|
||||||
|
<option value="WriteOperate">Read + Write Operate</option>
|
||||||
|
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||||
|
<option value="AlarmAck">Read + Alarm Ack</option>
|
||||||
|
<option value="Full">Full (every flag)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<NodeAcl>? _acls;
|
||||||
|
private bool _showForm;
|
||||||
|
private string _group = string.Empty;
|
||||||
|
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
||||||
|
private string _scopeId = string.Empty;
|
||||||
|
private string _preset = "Read";
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() =>
|
||||||
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
|
||||||
|
private NodePermissions ResolvePreset() => _preset switch
|
||||||
|
{
|
||||||
|
"Read" => NodePermissions.Browse | NodePermissions.Read,
|
||||||
|
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
|
||||||
|
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
|
||||||
|
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
|
||||||
|
"Full" => unchecked((NodePermissions)(-1)),
|
||||||
|
_ => NodePermissions.Browse | NodePermissions.Read,
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
|
||||||
|
|
||||||
|
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
|
||||||
|
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
|
||||||
|
|
||||||
|
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
|
||||||
|
{
|
||||||
|
_error = $"ScopeId required for {_scopeKind}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
|
||||||
|
ResolvePreset(), notes: null, CancellationToken.None);
|
||||||
|
_group = string.Empty; _scopeId = string.Empty;
|
||||||
|
_showForm = false;
|
||||||
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevokeAsync(Guid rowId)
|
||||||
|
{
|
||||||
|
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
|
||||||
|
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject AuditLogService AuditSvc
|
||||||
|
|
||||||
|
<h4>Recent audit log</h4>
|
||||||
|
|
||||||
|
@if (_entries is null) { <p>Loading…</p> }
|
||||||
|
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var a in _entries)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@a.Timestamp.ToString("u")</td>
|
||||||
|
<td>@a.Principal</td>
|
||||||
|
<td><code>@a.EventType</code></td>
|
||||||
|
<td>@a.NodeId</td>
|
||||||
|
<td>@a.GenerationId</td>
|
||||||
|
<td><small class="text-muted">@a.DetailsJson</small></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
private List<ConfigAuditLog>? _entries;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() =>
|
||||||
|
_entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@page "/clusters/{ClusterId}"
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.SignalR.Client
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
@rendermode RenderMode.InteractiveServer
|
||||||
|
@inject ClusterService ClusterSvc
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@if (_cluster is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (_liveBanner is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info py-2 small">
|
||||||
|
<strong>Live update:</strong> @_liveBanner
|
||||||
|
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">@_cluster.Name</h1>
|
||||||
|
<code class="text-muted">@_cluster.ClusterId</code>
|
||||||
|
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@if (_currentDraft is not null)
|
||||||
|
{
|
||||||
|
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
|
||||||
|
Edit current draft (gen @_currentDraft.GenerationId)
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (_tab == "overview")
|
||||||
|
{
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
|
||||||
|
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
|
||||||
|
<dt class="col-sm-3">Current published</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
|
||||||
|
else { <span class="text-muted">none published yet</span> }
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd>
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
else if (_tab == "generations")
|
||||||
|
{
|
||||||
|
<Generations ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "equipment" && _currentDraft is not null)
|
||||||
|
{
|
||||||
|
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "uns" && _currentDraft is not null)
|
||||||
|
{
|
||||||
|
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "namespaces" && _currentDraft is not null)
|
||||||
|
{
|
||||||
|
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "drivers" && _currentDraft is not null)
|
||||||
|
{
|
||||||
|
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "acls" && _currentDraft is not null)
|
||||||
|
{
|
||||||
|
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else if (_tab == "audit")
|
||||||
|
{
|
||||||
|
<AuditTab ClusterId="@ClusterId"/>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted">Open a draft to edit this cluster's content.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
private ServerCluster? _cluster;
|
||||||
|
private ConfigGeneration? _currentDraft;
|
||||||
|
private ConfigGeneration? _currentPublished;
|
||||||
|
private string _tab = "overview";
|
||||||
|
private bool _busy;
|
||||||
|
private HubConnection? _hub;
|
||||||
|
private string? _liveBanner;
|
||||||
|
|
||||||
|
private string Tab(string key) => _tab == key ? "active" : string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
await ConnectHubAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
_cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None);
|
||||||
|
var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||||
|
_currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft);
|
||||||
|
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectHubAsync()
|
||||||
|
{
|
||||||
|
_hub = new HubConnectionBuilder()
|
||||||
|
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||||
|
{
|
||||||
|
if (msg.ClusterId != ClusterId) return;
|
||||||
|
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
|
||||||
|
await LoadAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
});
|
||||||
|
|
||||||
|
await _hub.StartAsync();
|
||||||
|
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateDraftAsync()
|
||||||
|
{
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||||
|
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
|
||||||
|
}
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_hub is not null) await _hub.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@page "/clusters"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject ClusterService ClusterSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1>Clusters</h1>
|
||||||
|
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_clusters is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_clusters.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No clusters yet. Create the first one.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th>
|
||||||
|
<th>RedundancyMode</th><th>NodeCount</th><th>Enabled</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _clusters)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@c.ClusterId</code></td>
|
||||||
|
<td>@c.Name</td>
|
||||||
|
<td>@c.Enterprise</td>
|
||||||
|
<td>@c.Site</td>
|
||||||
|
<td>@c.RedundancyMode</td>
|
||||||
|
<td>@c.NodeCount</td>
|
||||||
|
<td>
|
||||||
|
@if (c.Enabled) { <span class="badge bg-success">Active</span> }
|
||||||
|
else { <span class="badge bg-secondary">Disabled</span> }
|
||||||
|
</td>
|
||||||
|
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ServerCluster>? _clusters;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">Draft diff</h1>
|
||||||
|
<small class="text-muted">
|
||||||
|
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_rows is null)
|
||||||
|
{
|
||||||
|
<p>Computing diff…</p>
|
||||||
|
}
|
||||||
|
else if (_error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@_error</div>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No differences — draft is structurally identical to the last published generation.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-hover table-sm">
|
||||||
|
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _rows)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@r.TableName</td>
|
||||||
|
<td><code>@r.LogicalId</code></td>
|
||||||
|
<td>
|
||||||
|
@switch (r.ChangeKind)
|
||||||
|
{
|
||||||
|
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||||
|
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||||
|
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||||
|
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
private List<DiffRow>? _rows;
|
||||||
|
private string _fromLabel = "(empty)";
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||||
|
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||||
|
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||||
|
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
@inject DraftValidationService ValidationSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">Draft editor</h1>
|
||||||
|
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
|
||||||
|
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
|
||||||
|
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("uns")" @onclick='() => _tab = "uns"'>UNS</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
||||||
|
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card sticky-top">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Validation</strong>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (_validating) { <p class="text-muted">Checking…</p> }
|
||||||
|
else if (_errors.Count == 0) { <div class="alert alert-success mb-0">No validation errors — safe to publish.</div> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
@foreach (var e in _errors)
|
||||||
|
{
|
||||||
|
<li class="mb-2">
|
||||||
|
<span class="badge bg-danger me-1">@e.Code</span>
|
||||||
|
<small>@e.Message</small>
|
||||||
|
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> }
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
private string _tab = "equipment";
|
||||||
|
private List<ValidationError> _errors = [];
|
||||||
|
private bool _validating;
|
||||||
|
private bool _busy;
|
||||||
|
private string? _publishError;
|
||||||
|
|
||||||
|
private string Active(string k) => _tab == k ? "active" : string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await RevalidateAsync();
|
||||||
|
|
||||||
|
private async Task RevalidateAsync()
|
||||||
|
{
|
||||||
|
_validating = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None);
|
||||||
|
_errors = errors.ToList();
|
||||||
|
}
|
||||||
|
finally { _validating = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishAsync()
|
||||||
|
{
|
||||||
|
_busy = true;
|
||||||
|
_publishError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None);
|
||||||
|
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _publishError = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject DriverInstanceService DriverSvc
|
||||||
|
@inject NamespaceService NsSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<h4>DriverInstances</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_drivers is null) { <p>Loading…</p> }
|
||||||
|
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var d in _drivers)
|
||||||
|
{
|
||||||
|
<tr><td><code>@d.DriverInstanceId</code></td><td>@d.Name</td><td>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm && _namespaces is not null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input class="form-control" @bind="_name"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">DriverType</label>
|
||||||
|
<select class="form-select" @bind="_type">
|
||||||
|
<option>Galaxy</option>
|
||||||
|
<option>ModbusTcp</option>
|
||||||
|
<option>AbCip</option>
|
||||||
|
<option>AbLegacy</option>
|
||||||
|
<option>S7</option>
|
||||||
|
<option>Focas</option>
|
||||||
|
<option>OpcUaClient</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Namespace</label>
|
||||||
|
<select class="form-select" @bind="_nsId">
|
||||||
|
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||||
|
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea>
|
||||||
|
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<DriverInstance>? _drivers;
|
||||||
|
private List<Namespace>? _namespaces;
|
||||||
|
private bool _showForm;
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private string _type = "ModbusTcp";
|
||||||
|
private string _nsId = string.Empty;
|
||||||
|
private string _config = "{}";
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId))
|
||||||
|
{
|
||||||
|
_error = "Name and Namespace are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None);
|
||||||
|
_name = string.Empty; _config = "{}";
|
||||||
|
_showForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||||
|
@inject EquipmentService EquipmentSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_equipment is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_equipment.Count == 0 && !_showForm)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No equipment in this draft yet.</p>
|
||||||
|
}
|
||||||
|
else if (_equipment.Count > 0)
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||||
|
<th>Manufacturer / Model</th><th>Serial</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var e in _equipment)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@e.EquipmentId</code></td>
|
||||||
|
<td>@e.Name</td>
|
||||||
|
<td>@e.MachineCode</td>
|
||||||
|
<td>@e.ZTag</td>
|
||||||
|
<td>@e.SAPID</td>
|
||||||
|
<td>@e.Manufacturer / @e.Model</td>
|
||||||
|
<td>@e.SerialNumber</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>New equipment</h5>
|
||||||
|
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
|
||||||
|
<DataAnnotationsValidator/>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Name (UNS segment)</label>
|
||||||
|
<InputText @bind-Value="_draft.Name" class="form-control"/>
|
||||||
|
<ValidationMessage For="() => _draft.Name"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">MachineCode</label>
|
||||||
|
<InputText @bind-Value="_draft.MachineCode" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">DriverInstanceId</label>
|
||||||
|
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">UnsLineId</label>
|
||||||
|
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">ZTag</label>
|
||||||
|
<InputText @bind-Value="_draft.ZTag" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">SAPID</label>
|
||||||
|
<InputText @bind-Value="_draft.SAPID" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Year of construction</label>
|
||||||
|
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
private List<Equipment>? _equipment;
|
||||||
|
private bool _showForm;
|
||||||
|
private Equipment _draft = NewBlankDraft();
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
private static Equipment NewBlankDraft() => new()
|
||||||
|
{
|
||||||
|
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||||
|
UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartAdd()
|
||||||
|
{
|
||||||
|
_draft = NewBlankDraft();
|
||||||
|
_error = null;
|
||||||
|
_showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
_draft.EquipmentUuid = Guid.NewGuid();
|
||||||
|
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||||
|
_draft.GenerationId = GenerationId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||||
|
_showForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Guid id)
|
||||||
|
{
|
||||||
|
await EquipmentSvc.DeleteAsync(id, CancellationToken.None);
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<h4>Generations</h4>
|
||||||
|
|
||||||
|
@if (_generations is null) { <p>Loading…</p> }
|
||||||
|
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var g in _generations)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@g.GenerationId</code></td>
|
||||||
|
<td>@StatusBadge(g.Status)</td>
|
||||||
|
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
|
||||||
|
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
|
||||||
|
<td><small>@g.PublishedBy</small></td>
|
||||||
|
<td><small>@g.Notes</small></td>
|
||||||
|
<td>
|
||||||
|
@if (g.Status == GenerationStatus.Draft)
|
||||||
|
{
|
||||||
|
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
|
||||||
|
}
|
||||||
|
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
private List<ConfigGeneration>? _generations;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync() =>
|
||||||
|
_generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None);
|
||||||
|
|
||||||
|
private async Task RollbackAsync(long targetId)
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None);
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MarkupString StatusBadge(GenerationStatus s) => s switch
|
||||||
|
{
|
||||||
|
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"),
|
||||||
|
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"),
|
||||||
|
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"),
|
||||||
|
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@inject NamespaceService NsSvc
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<h4>Namespaces</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_namespaces is null) { <p>Loading…</p> }
|
||||||
|
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var n in _namespaces)
|
||||||
|
{
|
||||||
|
<tr><td><code>@n.NamespaceId</code></td><td>@n.Kind</td><td>@n.NamespaceUri</td><td>@(n.Enabled ? "yes" : "no")</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showForm)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control" @bind="_uri"/></div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Kind</label>
|
||||||
|
<select class="form-select" @bind="_kind">
|
||||||
|
<option value="@NamespaceKind.Equipment">Equipment</option>
|
||||||
|
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
private List<Namespace>? _namespaces;
|
||||||
|
private bool _showForm;
|
||||||
|
private string _uri = string.Empty;
|
||||||
|
private NamespaceKind _kind = NamespaceKind.Equipment;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync() =>
|
||||||
|
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_uri)) return;
|
||||||
|
await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None);
|
||||||
|
_uri = string.Empty;
|
||||||
|
_showForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
@page "/clusters/new"
|
||||||
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
|
@inject ClusterService ClusterSvc
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<h1 class="mb-4">New cluster</h1>
|
||||||
|
|
||||||
|
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||||
|
<DataAnnotationsValidator/>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
|
||||||
|
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
|
||||||
|
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
||||||
|
<ValidationMessage For="() => _input.ClusterId"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Display name <span class="text-danger">*</span></label>
|
||||||
|
<InputText @bind-Value="_input.Name" class="form-control"/>
|
||||||
|
<ValidationMessage For="() => _input.Name"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Enterprise</label>
|
||||||
|
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Site</label>
|
||||||
|
<InputText @bind-Value="_input.Site" class="form-control"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Redundancy</label>
|
||||||
|
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
|
||||||
|
<option value="@RedundancyMode.None">None (single node)</option>
|
||||||
|
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||||
|
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||||
|
</InputSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mt-3">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
|
||||||
|
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private sealed class Input
|
||||||
|
{
|
||||||
|
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
||||||
|
public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, StringLength(128)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
||||||
|
[StringLength(32)] public string Site { get; set; } = "dev";
|
||||||
|
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Input _input = new();
|
||||||
|
private bool _submitting;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
private async Task CreateAsync()
|
||||||
|
{
|
||||||
|
_submitting = true;
|
||||||
|
_error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cluster = new ServerCluster
|
||||||
|
{
|
||||||
|
ClusterId = _input.ClusterId,
|
||||||
|
Name = _input.Name,
|
||||||
|
Enterprise = _input.Enterprise,
|
||||||
|
Site = _input.Site,
|
||||||
|
RedundancyMode = _input.RedundancyMode,
|
||||||
|
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||||
|
Enabled = true,
|
||||||
|
CreatedBy = "admin-ui",
|
||||||
|
};
|
||||||
|
|
||||||
|
await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None);
|
||||||
|
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||||
|
|
||||||
|
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_error = ex.Message;
|
||||||
|
}
|
||||||
|
finally { _submitting = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject UnsService UnsSvc
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<h4>UNS Areas</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_areas is null) { <p>Loading…</p> }
|
||||||
|
else if (_areas.Count == 0) { <p class="text-muted">No areas yet.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var a in _areas)
|
||||||
|
{
|
||||||
|
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showAreaForm)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control" @bind="_newAreaName"/></div>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<h4>UNS Lines</h4>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_lines is null) { <p>Loading…</p> }
|
||||||
|
else if (_lines.Count == 0) { <p class="text-muted">No lines yet.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var l in _lines)
|
||||||
|
{
|
||||||
|
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_showLineForm && _areas is not null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Area</label>
|
||||||
|
<select class="form-select" @bind="_newLineAreaId">
|
||||||
|
@foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2"><label class="form-label">Name</label><input class="form-control" @bind="_newLineName"/></div>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private List<UnsArea>? _areas;
|
||||||
|
private List<UnsLine>? _lines;
|
||||||
|
private bool _showAreaForm;
|
||||||
|
private bool _showLineForm;
|
||||||
|
private string _newAreaName = string.Empty;
|
||||||
|
private string _newLineName = string.Empty;
|
||||||
|
private string _newLineAreaId = string.Empty;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
_areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None);
|
||||||
|
_lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddAreaAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newAreaName)) return;
|
||||||
|
await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None);
|
||||||
|
_newAreaName = string.Empty;
|
||||||
|
_showAreaForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddLineAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return;
|
||||||
|
await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None);
|
||||||
|
_newLineName = string.Empty;
|
||||||
|
_showLineForm = false;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
72
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@page "/"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@inject ClusterService ClusterSvc
|
||||||
|
@inject GenerationService GenerationSvc
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<h1 class="mb-4">Fleet overview</h1>
|
||||||
|
|
||||||
|
@if (_clusters is null)
|
||||||
|
{
|
||||||
|
<p>Loading…</p>
|
||||||
|
}
|
||||||
|
else if (_clusters.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body"><h6 class="text-muted">Clusters</h6><div class="fs-2">@_clusters.Count</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body"><h6 class="text-muted">Active drafts</h6><div class="fs-2">@_activeDraftCount</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body"><h6 class="text-muted">Published generations</h6><div class="fs-2">@_publishedCount</div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card"><div class="card-body"><h6 class="text-muted">Disabled clusters</h6><div class="fs-2">@_clusters.Count(c => !c.Enabled)</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4 mb-3">Clusters</h4>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise / Site</th><th>Redundancy</th><th>Enabled</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _clusters)
|
||||||
|
{
|
||||||
|
<tr style="cursor: pointer;">
|
||||||
|
<td><code>@c.ClusterId</code></td>
|
||||||
|
<td>@c.Name</td>
|
||||||
|
<td>@c.Enterprise / @c.Site</td>
|
||||||
|
<td>@c.RedundancyMode</td>
|
||||||
|
<td>@(c.Enabled ? "Yes" : "No")</td>
|
||||||
|
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ServerCluster>? _clusters;
|
||||||
|
private int _activeDraftCount;
|
||||||
|
private int _publishedCount;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
foreach (var c in _clusters)
|
||||||
|
{
|
||||||
|
var gens = await GenerationSvc.ListRecentAsync(c.ClusterId, 50, CancellationToken.None);
|
||||||
|
_activeDraftCount += gens.Count(g => g.Status.ToString() == "Draft");
|
||||||
|
_publishedCount += gens.Count(g => g.Status.ToString() == "Published");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
100
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
@page "/login"
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using Microsoft.AspNetCore.Authentication
|
||||||
|
@using Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
||||||
|
@inject IHttpContextAccessor Http
|
||||||
|
@inject ILdapAuthService LdapAuth
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
<div class="row justify-content-center mt-5">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="mb-4">OtOpcUa Admin — sign in</h4>
|
||||||
|
|
||||||
|
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
|
||||||
|
@(_busy ? "Signing in…" : "Sign in")
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
<small class="text-muted">
|
||||||
|
LDAP bind against the configured directory. Dev defaults to GLAuth on
|
||||||
|
<code>localhost:3893</code>.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private sealed class Input
|
||||||
|
{
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Input _input = new();
|
||||||
|
private string? _error;
|
||||||
|
private bool _busy;
|
||||||
|
|
||||||
|
private async Task SignInAsync()
|
||||||
|
{
|
||||||
|
_error = null;
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
|
||||||
|
{
|
||||||
|
_error = "Username and password are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
_error = result.Error ?? "Sign-in failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Roles.Count == 0)
|
||||||
|
{
|
||||||
|
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx = Http.HttpContext
|
||||||
|
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
|
||||||
|
new(ClaimTypes.NameIdentifier, _input.Username),
|
||||||
|
};
|
||||||
|
foreach (var role in result.Roles)
|
||||||
|
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||||
|
foreach (var group in result.Groups)
|
||||||
|
claims.Add(new Claim("ldap_group", group));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
new ClaimsPrincipal(identity));
|
||||||
|
|
||||||
|
ctx.Response.Redirect("/");
|
||||||
|
}
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
Normal file
114
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
@page "/reservations"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@attribute [Authorize(Policy = "CanPublish")]
|
||||||
|
@inject ReservationService ReservationSvc
|
||||||
|
|
||||||
|
<h1 class="mb-4">External-ID reservations</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
|
||||||
|
FleetAdmin-only audit-logged action — only release when the physical asset is permanently
|
||||||
|
retired and its ID needs to be reused by a different equipment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4 class="mt-4">Active</h4>
|
||||||
|
@if (_active is null) { <p>Loading…</p> }
|
||||||
|
else if (_active.Count == 0) { <p class="text-muted">No active reservations.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _active)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@r.Kind</code></td>
|
||||||
|
<td><code>@r.Value</code></td>
|
||||||
|
<td><code>@r.EquipmentUuid</code></td>
|
||||||
|
<td>@r.ClusterId</td>
|
||||||
|
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
|
||||||
|
<td><small>@r.LastPublishedAt.ToString("u")</small></td>
|
||||||
|
<td><button class="btn btn-sm btn-outline-danger" @onclick='() => OpenReleaseDialog(r)'>Release…</button></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h4 class="mt-4">Released (most recent 100)</h4>
|
||||||
|
@if (_released is null) { <p>Loading…</p> }
|
||||||
|
else if (_released.Count == 0) { <p class="text-muted">No released reservations yet.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _released)
|
||||||
|
{
|
||||||
|
<tr><td><code>@r.Kind</code></td><td><code>@r.Value</code></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_releasing is not null)
|
||||||
|
{
|
||||||
|
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Release reservation <code>@_releasing.Kind</code> = <code>@_releasing.Value</code></h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p>
|
||||||
|
<label class="form-label">Reason (required)</label>
|
||||||
|
<textarea class="form-control" rows="3" @bind="_reason"></textarea>
|
||||||
|
@if (_error is not null) { <div class="alert alert-danger mt-2">@_error</div> }
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>
|
||||||
|
<button class="btn btn-danger" @onclick="ReleaseAsync" disabled="@_busy">Release</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<ExternalIdReservation>? _active;
|
||||||
|
private List<ExternalIdReservation>? _released;
|
||||||
|
private ExternalIdReservation? _releasing;
|
||||||
|
private string _reason = string.Empty;
|
||||||
|
private bool _busy;
|
||||||
|
private string? _error;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||||
|
|
||||||
|
private async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
_active = await ReservationSvc.ListActiveAsync(CancellationToken.None);
|
||||||
|
_released = await ReservationSvc.ListReleasedAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenReleaseDialog(ExternalIdReservation r)
|
||||||
|
{
|
||||||
|
_releasing = r;
|
||||||
|
_reason = string.Empty;
|
||||||
|
_error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReleaseAsync()
|
||||||
|
{
|
||||||
|
if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; }
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ReservationSvc.ReleaseAsync(_releasing.Kind.ToString(), _releasing.Value, _reason, CancellationToken.None);
|
||||||
|
_releasing = null;
|
||||||
|
await ReloadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; }
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||||
|
|
||||||
|
<Router AppAssembly="@typeof(Program).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||||
|
</Found>
|
||||||
|
<NotFound>
|
||||||
|
<LayoutView Layout="@typeof(MainLayout)"><p>Not found.</p></LayoutView>
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
14
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
14
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Http
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
||||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
|
||||||
|
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
|
||||||
|
/// from the UI via <see cref="AcknowledgeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AlertHub : Hub
|
||||||
|
{
|
||||||
|
public const string AllAlertsGroup = "__alerts__";
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
|
||||||
|
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AlertMessage(
|
||||||
|
string AlertId,
|
||||||
|
string Severity,
|
||||||
|
string Title,
|
||||||
|
string Detail,
|
||||||
|
DateTime RaisedAtUtc,
|
||||||
|
string? ClusterId,
|
||||||
|
string? NodeId);
|
||||||
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
|
||||||
|
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
|
||||||
|
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
|
||||||
|
/// observes a delta.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FleetStatusHub : Hub
|
||||||
|
{
|
||||||
|
public Task SubscribeCluster(string clusterId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||||
|
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UnsubscribeCluster(string clusterId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||||
|
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
|
||||||
|
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||||
|
|
||||||
|
public const string FleetGroup = "__fleet__";
|
||||||
|
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record NodeStateChangedMessage(
|
||||||
|
string NodeId,
|
||||||
|
string ClusterId,
|
||||||
|
long? CurrentGenerationId,
|
||||||
|
string? LastAppliedStatus,
|
||||||
|
string? LastAppliedError,
|
||||||
|
DateTime? LastAppliedAt,
|
||||||
|
DateTime? LastSeenAt);
|
||||||
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls <c>ClusterNodeGenerationState</c> every <see cref="PollInterval"/> and publishes
|
||||||
|
/// per-node deltas to <see cref="FleetStatusHub"/>. Also raises sticky
|
||||||
|
/// <see cref="AlertMessage"/>s on transitions into <c>Failed</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FleetStatusPoller(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IHubContext<FleetStatusHub> fleetHub,
|
||||||
|
IHubContext<AlertHub> alertHub,
|
||||||
|
ILogger<FleetStatusPoller> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("FleetStatusPoller starting — interval {Interval}s", PollInterval.TotalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await PollOnceAsync(stoppingToken); }
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "FleetStatusPoller tick failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await Task.Delay(PollInterval, stoppingToken); }
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task PollOnceAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||||
|
|
||||||
|
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||||
|
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var snapshot = new NodeStateSnapshot(
|
||||||
|
r.s.NodeId, r.ClusterId, r.s.CurrentGenerationId,
|
||||||
|
r.s.LastAppliedStatus?.ToString(), r.s.LastAppliedError,
|
||||||
|
r.s.LastAppliedAt, r.s.LastSeenAt);
|
||||||
|
|
||||||
|
var hadPrior = _last.TryGetValue(r.s.NodeId, out var prior);
|
||||||
|
if (!hadPrior || prior != snapshot)
|
||||||
|
{
|
||||||
|
_last[r.s.NodeId] = snapshot;
|
||||||
|
|
||||||
|
var msg = new NodeStateChangedMessage(
|
||||||
|
snapshot.NodeId, snapshot.ClusterId, snapshot.GenerationId,
|
||||||
|
snapshot.Status, snapshot.Error, snapshot.AppliedAt, snapshot.SeenAt);
|
||||||
|
|
||||||
|
await fleetHub.Clients.Group(FleetStatusHub.GroupName(snapshot.ClusterId))
|
||||||
|
.SendAsync("NodeStateChanged", msg, ct);
|
||||||
|
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||||
|
.SendAsync("NodeStateChanged", msg, ct);
|
||||||
|
|
||||||
|
if (snapshot.Status == "Failed" && (!hadPrior || prior.Status != "Failed"))
|
||||||
|
{
|
||||||
|
var alert = new AlertMessage(
|
||||||
|
AlertId: $"{snapshot.NodeId}:apply-failed",
|
||||||
|
Severity: "error",
|
||||||
|
Title: $"Apply failed on {snapshot.NodeId}",
|
||||||
|
Detail: snapshot.Error ?? "(no detail)",
|
||||||
|
RaisedAtUtc: DateTime.UtcNow,
|
||||||
|
ClusterId: snapshot.ClusterId,
|
||||||
|
NodeId: snapshot.NodeId);
|
||||||
|
await alertHub.Clients.Group(AlertHub.AllAlertsGroup)
|
||||||
|
.SendAsync("AlertRaised", alert, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
|
||||||
|
internal void ResetCache() => _last.Clear();
|
||||||
|
|
||||||
|
private readonly record struct NodeStateSnapshot(
|
||||||
|
string NodeId, string ClusterId, long? GenerationId,
|
||||||
|
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||||
|
}
|
||||||
80
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
80
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.WriteTo.Console()
|
||||||
|
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
||||||
|
|
||||||
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(o =>
|
||||||
|
{
|
||||||
|
o.Cookie.Name = "OtOpcUa.Admin";
|
||||||
|
o.LoginPath = "/login";
|
||||||
|
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorizationBuilder()
|
||||||
|
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||||
|
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||||
|
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||||
|
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
|
||||||
|
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ClusterService>();
|
||||||
|
builder.Services.AddScoped<GenerationService>();
|
||||||
|
builder.Services.AddScoped<EquipmentService>();
|
||||||
|
builder.Services.AddScoped<UnsService>();
|
||||||
|
builder.Services.AddScoped<NamespaceService>();
|
||||||
|
builder.Services.AddScoped<DriverInstanceService>();
|
||||||
|
builder.Services.AddScoped<NodeAclService>();
|
||||||
|
builder.Services.AddScoped<ReservationService>();
|
||||||
|
builder.Services.AddScoped<DraftValidationService>();
|
||||||
|
builder.Services.AddScoped<AuditLogService>();
|
||||||
|
|
||||||
|
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||||
|
builder.Services.Configure<LdapOptions>(
|
||||||
|
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||||
|
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||||
|
|
||||||
|
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||||
|
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseSerilogRequestLogging();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
ctx.Response.Redirect("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||||
|
app.MapHub<AlertHub>("/hubs/alerts");
|
||||||
|
|
||||||
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
await app.RunAsync();
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
|
||||||
|
public interface ILdapAuthService
|
||||||
|
{
|
||||||
|
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||||
|
}
|
||||||
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
|
||||||
|
/// <summary>Outcome of an LDAP bind attempt. <see cref="Roles"/> is the mapped-set of Admin roles.</summary>
|
||||||
|
public sealed record LdapAuthResult(
|
||||||
|
bool Success,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Username,
|
||||||
|
IReadOnlyList<string> Groups,
|
||||||
|
IReadOnlyList<string> Roles,
|
||||||
|
string? Error);
|
||||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Novell.Directory.Ldap;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||||
|
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||||
|
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||||
|
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||||
|
: ILdapAuthService
|
||||||
|
{
|
||||||
|
private readonly LdapOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(username))
|
||||||
|
return new(false, null, null, [], [], "Username is required");
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
return new(false, null, null, [], [], "Password is required");
|
||||||
|
|
||||||
|
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||||
|
return new(false, null, username, [], [],
|
||||||
|
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = new LdapConnection();
|
||||||
|
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||||
|
|
||||||
|
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||||
|
|
||||||
|
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||||
|
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||||
|
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||||
|
|
||||||
|
var displayName = username;
|
||||||
|
var groups = new List<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||||
|
var results = await Task.Run(() =>
|
||||||
|
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||||
|
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||||
|
typesOnly: false), ct);
|
||||||
|
|
||||||
|
while (results.HasMore())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = results.Next();
|
||||||
|
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||||
|
if (name is not null) displayName = name.StringValue;
|
||||||
|
|
||||||
|
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||||
|
if (groupAttr is not null)
|
||||||
|
{
|
||||||
|
foreach (var groupDn in groupAttr.StringValueArray)
|
||||||
|
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||||
|
// directory doesn't populate memberOf (or populates it differently), the
|
||||||
|
// user's primary group name is recoverable from the second RDN of the DN.
|
||||||
|
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||||
|
{
|
||||||
|
var primary = ExtractOuSegment(entry.Dn);
|
||||||
|
if (primary is not null) groups.Add(primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (LdapException ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Disconnect();
|
||||||
|
|
||||||
|
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||||
|
return new(true, displayName, username, groups, roles, null);
|
||||||
|
}
|
||||||
|
catch (LdapException ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||||
|
return new(false, null, username, [], [], "Invalid username or password");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||||
|
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (username.Contains('=')) return username; // already a DN
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||||
|
|
||||||
|
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||||
|
var results = await Task.Run(() =>
|
||||||
|
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||||
|
|
||||||
|
if (results.HasMore())
|
||||||
|
return results.Next().Dn;
|
||||||
|
|
||||||
|
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||||
|
$"No entry for uid={username}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||||
|
? $"cn={username}"
|
||||||
|
: $"cn={username},{_options.SearchBase}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string EscapeLdapFilter(string input) =>
|
||||||
|
input.Replace("\\", "\\5c")
|
||||||
|
.Replace("*", "\\2a")
|
||||||
|
.Replace("(", "\\28")
|
||||||
|
.Replace(")", "\\29")
|
||||||
|
.Replace("\0", "\\00");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||||
|
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||||
|
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||||
|
/// </summary>
|
||||||
|
internal static string? ExtractOuSegment(string dn)
|
||||||
|
{
|
||||||
|
var segments = dn.Split(',');
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
var trimmed = segment.Trim();
|
||||||
|
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return trimmed[3..];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ExtractFirstRdnValue(string dn)
|
||||||
|
{
|
||||||
|
var equalsIdx = dn.IndexOf('=');
|
||||||
|
if (equalsIdx < 0) return dn;
|
||||||
|
|
||||||
|
var valueStart = equalsIdx + 1;
|
||||||
|
var commaIdx = dn.IndexOf(',', valueStart);
|
||||||
|
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||||
|
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||||
|
/// <c>C:\publish\glauth\auth.md</c>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LdapOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Authentication:Ldap";
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string Server { get; set; } = "localhost";
|
||||||
|
public int Port { get; set; } = 3893;
|
||||||
|
public bool UseTls { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||||
|
public bool AllowInsecureLdap { get; set; }
|
||||||
|
|
||||||
|
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
|
||||||
|
/// <c>cn={user},{SearchBase}</c> is attempted.
|
||||||
|
/// </summary>
|
||||||
|
public string ServiceAccountDn { get; set; } = string.Empty;
|
||||||
|
public string ServiceAccountPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DisplayNameAttribute { get; set; } = "cn";
|
||||||
|
public string GroupAttribute { get; set; } = "memberOf";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||||
|
/// role whose source group is in their membership list. Example dev mapping:
|
||||||
|
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic LDAP-group-to-Admin-role mapper driven by <see cref="LdapOptions.GroupToRole"/>.
|
||||||
|
/// Every returned role corresponds to a group the user actually holds; no inference.
|
||||||
|
/// </summary>
|
||||||
|
public static class RoleMapper
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<string> Map(
|
||||||
|
IReadOnlyCollection<string> ldapGroups,
|
||||||
|
IReadOnlyDictionary<string, string> groupToRole)
|
||||||
|
{
|
||||||
|
if (groupToRole.Count == 0) return [];
|
||||||
|
|
||||||
|
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var group in ldapGroups)
|
||||||
|
{
|
||||||
|
if (groupToRole.TryGetValue(group, out var role))
|
||||||
|
roles.Add(role);
|
||||||
|
}
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three admin roles per <c>admin-ui.md</c> §"Admin Roles" — mapped from LDAP groups at
|
||||||
|
/// sign-in. Each role has a fixed set of capabilities (cluster CRUD, draft → publish, fleet
|
||||||
|
/// admin). The ACL-driven runtime permissions (<c>NodePermissions</c>) govern OPC UA clients;
|
||||||
|
/// these roles govern the Admin UI itself.
|
||||||
|
/// </summary>
|
||||||
|
public static class AdminRoles
|
||||||
|
{
|
||||||
|
public const string ConfigViewer = "ConfigViewer";
|
||||||
|
public const string ConfigEditor = "ConfigEditor";
|
||||||
|
public const string FleetAdmin = "FleetAdmin";
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> All => [ConfigViewer, ConfigEditor, FleetAdmin];
|
||||||
|
}
|
||||||
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
public sealed class AuditLogService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<ConfigAuditLog>> ListRecentAsync(string? clusterId, int limit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = db.ConfigAuditLogs.AsNoTracking();
|
||||||
|
if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId);
|
||||||
|
return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cluster CRUD surface used by the Blazor pages. Writes go through stored procs in later
|
||||||
|
/// phases; Phase 1 reads via EF Core directly (DENY SELECT on <c>dbo</c> schema means this
|
||||||
|
/// service connects as a DB owner during dev — production swaps in a read-only view grant).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClusterService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<ServerCluster>> ListAsync(CancellationToken ct) =>
|
||||||
|
db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<ServerCluster?> FindAsync(string clusterId, CancellationToken ct) =>
|
||||||
|
db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||||
|
|
||||||
|
public async Task<ServerCluster> CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct)
|
||||||
|
{
|
||||||
|
cluster.CreatedAt = DateTime.UtcNow;
|
||||||
|
cluster.CreatedBy = createdBy;
|
||||||
|
db.ServerClusters.Add(cluster);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return cluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the managed <see cref="DraftValidator"/> against a draft's snapshot loaded from the
|
||||||
|
/// Configuration DB. Used by the draft editor's inline validation panel and by the publish
|
||||||
|
/// dialog's pre-check. Structural-only SQL checks live in <c>sp_ValidateDraft</c>; this layer
|
||||||
|
/// owns the content / cross-generation / regex rules.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DraftValidationService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<ValidationError>> ValidateAsync(long draftId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var draft = await db.ConfigGenerations.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(g => g.GenerationId == draftId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Draft {draftId} not found");
|
||||||
|
|
||||||
|
var snapshot = new DraftSnapshot
|
||||||
|
{
|
||||||
|
GenerationId = draft.GenerationId,
|
||||||
|
ClusterId = draft.ClusterId,
|
||||||
|
Namespaces = await db.Namespaces.AsNoTracking().Where(n => n.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
DriverInstances = await db.DriverInstances.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
Devices = await db.Devices.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
UnsAreas = await db.UnsAreas.AsNoTracking().Where(a => a.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
UnsLines = await db.UnsLines.AsNoTracking().Where(l => l.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
Equipment = await db.Equipment.AsNoTracking().Where(e => e.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
Tags = await db.Tags.AsNoTracking().Where(t => t.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
PollGroups = await db.PollGroups.AsNoTracking().Where(p => p.GenerationId == draftId).ToListAsync(ct),
|
||||||
|
|
||||||
|
PriorEquipment = await db.Equipment.AsNoTracking()
|
||||||
|
.Where(e => e.GenerationId != draftId
|
||||||
|
&& db.ConfigGenerations.Any(g => g.GenerationId == e.GenerationId && g.ClusterId == draft.ClusterId))
|
||||||
|
.ToListAsync(ct),
|
||||||
|
ActiveReservations = await db.ExternalIdReservations.AsNoTracking()
|
||||||
|
.Where(r => r.ReleasedAt == null)
|
||||||
|
.ToListAsync(ct),
|
||||||
|
};
|
||||||
|
|
||||||
|
return DraftValidator.Validate(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
public sealed class DriverInstanceService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<DriverInstance>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.DriverInstances.AsNoTracking()
|
||||||
|
.Where(d => d.GenerationId == generationId)
|
||||||
|
.OrderBy(d => d.DriverInstanceId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<DriverInstance> AddAsync(
|
||||||
|
long draftId, string clusterId, string namespaceId, string name, string driverType,
|
||||||
|
string driverConfigJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var di = new DriverInstance
|
||||||
|
{
|
||||||
|
GenerationId = draftId,
|
||||||
|
DriverInstanceId = $"drv-{Guid.NewGuid():N}"[..20],
|
||||||
|
ClusterId = clusterId,
|
||||||
|
NamespaceId = namespaceId,
|
||||||
|
Name = name,
|
||||||
|
DriverType = driverType,
|
||||||
|
DriverConfig = driverConfigJson,
|
||||||
|
};
|
||||||
|
db.DriverInstances.Add(di);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return di;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only;
|
||||||
|
/// Published generations are read-only (to create changes, clone to a new draft via
|
||||||
|
/// <see cref="GenerationService.CreateDraftAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<Equipment>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.Equipment.AsNoTracking()
|
||||||
|
.Where(e => e.GenerationId == generationId)
|
||||||
|
.OrderBy(e => e.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
|
||||||
|
db.Equipment.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from
|
||||||
|
/// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Equipment> CreateAsync(long draftId, Equipment input, CancellationToken ct)
|
||||||
|
{
|
||||||
|
input.GenerationId = draftId;
|
||||||
|
input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid;
|
||||||
|
input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid);
|
||||||
|
db.Equipment.Add(input);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Equipment updated, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set.
|
||||||
|
var existing = await db.Equipment
|
||||||
|
.FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found");
|
||||||
|
|
||||||
|
existing.Name = updated.Name;
|
||||||
|
existing.MachineCode = updated.MachineCode;
|
||||||
|
existing.ZTag = updated.ZTag;
|
||||||
|
existing.SAPID = updated.SAPID;
|
||||||
|
existing.Manufacturer = updated.Manufacturer;
|
||||||
|
existing.Model = updated.Model;
|
||||||
|
existing.SerialNumber = updated.SerialNumber;
|
||||||
|
existing.HardwareRevision = updated.HardwareRevision;
|
||||||
|
existing.SoftwareRevision = updated.SoftwareRevision;
|
||||||
|
existing.YearOfConstruction = updated.YearOfConstruction;
|
||||||
|
existing.AssetLocation = updated.AssetLocation;
|
||||||
|
existing.ManufacturerUri = updated.ManufacturerUri;
|
||||||
|
existing.DeviceManualUri = updated.DeviceManualUri;
|
||||||
|
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||||
|
existing.DeviceId = updated.DeviceId;
|
||||||
|
existing.UnsLineId = updated.UnsLineId;
|
||||||
|
existing.EquipmentClassRef = updated.EquipmentClassRef;
|
||||||
|
existing.Enabled = updated.Enabled;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct);
|
||||||
|
if (row is null) return;
|
||||||
|
db.Equipment.Remove(row);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the
|
||||||
|
/// stored procedures; diff queries <c>sp_ComputeGenerationDiff</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GenerationService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<ConfigGeneration> CreateDraftAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var gen = new ConfigGeneration
|
||||||
|
{
|
||||||
|
ClusterId = clusterId,
|
||||||
|
Status = GenerationStatus.Draft,
|
||||||
|
CreatedBy = createdBy,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.ConfigGenerations.Add(gen);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return gen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<ConfigGeneration>> ListRecentAsync(string clusterId, int limit, CancellationToken ct) =>
|
||||||
|
db.ConfigGenerations.AsNoTracking()
|
||||||
|
.Where(g => g.ClusterId == clusterId)
|
||||||
|
.OrderByDescending(g => g.GenerationId)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task PublishAsync(string clusterId, long draftGenerationId, string? notes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"EXEC dbo.sp_PublishGeneration @ClusterId = {0}, @DraftGenerationId = {1}, @Notes = {2}",
|
||||||
|
[clusterId, draftGenerationId, (object?)notes ?? DBNull.Value],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RollbackAsync(string clusterId, long targetGenerationId, string? notes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"EXEC dbo.sp_RollbackToGeneration @ClusterId = {0}, @TargetGenerationId = {1}, @Notes = {2}",
|
||||||
|
[clusterId, targetGenerationId, (object?)notes ?? DBNull.Value],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DiffRow>> ComputeDiffAsync(long from, long to, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var results = new List<DiffRow>();
|
||||||
|
await using var conn = (SqlConnection)db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId = @f, @ToGenerationId = @t";
|
||||||
|
cmd.Parameters.AddWithValue("@f", from);
|
||||||
|
cmd.Parameters.AddWithValue("@t", to);
|
||||||
|
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
results.Add(new DiffRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DiffRow(string TableName, string LogicalId, string ChangeKind);
|
||||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
public sealed class NamespaceService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<Namespace>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.Namespaces.AsNoTracking()
|
||||||
|
.Where(n => n.GenerationId == generationId)
|
||||||
|
.OrderBy(n => n.NamespaceId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<Namespace> AddAsync(
|
||||||
|
long draftId, string clusterId, string namespaceUri, NamespaceKind kind, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ns = new Namespace
|
||||||
|
{
|
||||||
|
GenerationId = draftId,
|
||||||
|
NamespaceId = $"ns-{Guid.NewGuid():N}"[..20],
|
||||||
|
ClusterId = clusterId,
|
||||||
|
NamespaceUri = namespaceUri,
|
||||||
|
Kind = kind,
|
||||||
|
};
|
||||||
|
db.Namespaces.Add(ns);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return ns;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.NodeAcls.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.LdapGroup)
|
||||||
|
.ThenBy(a => a.ScopeKind)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<NodeAcl> GrantAsync(
|
||||||
|
long draftId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||||
|
NodePermissions permissions, string? notes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var acl = new NodeAcl
|
||||||
|
{
|
||||||
|
GenerationId = draftId,
|
||||||
|
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||||
|
ClusterId = clusterId,
|
||||||
|
LdapGroup = ldapGroup,
|
||||||
|
ScopeKind = scopeKind,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
PermissionFlags = permissions,
|
||||||
|
Notes = notes,
|
||||||
|
};
|
||||||
|
db.NodeAcls.Add(acl);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return acl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeAsync(Guid nodeAclRowId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, ct);
|
||||||
|
if (row is null) return;
|
||||||
|
db.NodeAcls.Remove(row);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fleet-wide external-ID reservation inspector + FleetAdmin-only release flow per
|
||||||
|
/// <c>admin-ui.md §"Release an external-ID reservation"</c>. Release is audit-logged
|
||||||
|
/// (<see cref="ConfigAuditLog"/>) via <c>sp_ReleaseExternalIdReservation</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReservationService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<ExternalIdReservation>> ListActiveAsync(CancellationToken ct) =>
|
||||||
|
db.ExternalIdReservations.AsNoTracking()
|
||||||
|
.Where(r => r.ReleasedAt == null)
|
||||||
|
.OrderBy(r => r.Kind).ThenBy(r => r.Value)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<List<ExternalIdReservation>> ListReleasedAsync(CancellationToken ct) =>
|
||||||
|
db.ExternalIdReservations.AsNoTracking()
|
||||||
|
.Where(r => r.ReleasedAt != null)
|
||||||
|
.OrderByDescending(r => r.ReleasedAt)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reason))
|
||||||
|
throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
|
||||||
|
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
|
||||||
|
[kind, value, reason],
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<UnsArea>> ListAreasAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.UnsAreas.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<List<UnsLine>> ListLinesAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.UnsLines.AsNoTracking()
|
||||||
|
.Where(l => l.GenerationId == generationId)
|
||||||
|
.OrderBy(l => l.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<UnsArea> AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var area = new UnsArea
|
||||||
|
{
|
||||||
|
GenerationId = draftId,
|
||||||
|
UnsAreaId = $"area-{Guid.NewGuid():N}"[..20],
|
||||||
|
ClusterId = clusterId,
|
||||||
|
Name = name,
|
||||||
|
Notes = notes,
|
||||||
|
};
|
||||||
|
db.UnsAreas.Add(area);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return area;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UnsLine> AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var line = new UnsLine
|
||||||
|
{
|
||||||
|
GenerationId = draftId,
|
||||||
|
UnsLineId = $"line-{Guid.NewGuid():N}"[..20],
|
||||||
|
UnsAreaId = unsAreaId,
|
||||||
|
Name = name,
|
||||||
|
Notes = notes,
|
||||||
|
};
|
||||||
|
db.UnsLines.Add(line);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
34
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin</RootNamespace>
|
||||||
|
<AssemblyName>OtOpcUa.Admin</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Admin.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
27
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||||
|
},
|
||||||
|
"Authentication": {
|
||||||
|
"Ldap": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Server": "localhost",
|
||||||
|
"Port": 3893,
|
||||||
|
"UseTls": false,
|
||||||
|
"AllowInsecureLdap": true,
|
||||||
|
"SearchBase": "dc=lmxopcua,dc=local",
|
||||||
|
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
|
||||||
|
"ServiceAccountPassword": "serviceaccount123",
|
||||||
|
"DisplayNameAttribute": "cn",
|
||||||
|
"GroupAttribute": "memberOf",
|
||||||
|
"GroupToRole": {
|
||||||
|
"ReadOnly": "ConfigViewer",
|
||||||
|
"ReadWrite": "ConfigEditor",
|
||||||
|
"AlarmAck": "FleetAdmin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Serilog": {
|
||||||
|
"MinimumLevel": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */
|
||||||
|
body { background-color: #f5f6fa; }
|
||||||
|
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on
|
||||||
|
/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes
|
||||||
|
/// register-applied to the central DB only after success). Order: namespace → driver → device →
|
||||||
|
/// equipment → poll group → tag, with Removed before Added/Modified.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApplyCallbacks
|
||||||
|
{
|
||||||
|
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
|
||||||
|
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
|
||||||
|
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
|
||||||
|
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
|
||||||
|
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
|
||||||
|
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
|
||||||
|
}
|
||||||
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||||
|
|
||||||
|
public enum ChangeKind
|
||||||
|
{
|
||||||
|
Added,
|
||||||
|
Removed,
|
||||||
|
Modified,
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||||
|
|
||||||
|
public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier
|
||||||
|
{
|
||||||
|
public async Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var diff = GenerationDiffer.Compute(from, to);
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
// Removed first, then Added/Modified — prevents FK dangling while cascades settle.
|
||||||
|
await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct);
|
||||||
|
await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct);
|
||||||
|
await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct);
|
||||||
|
await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct);
|
||||||
|
await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct);
|
||||||
|
await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct);
|
||||||
|
|
||||||
|
foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified })
|
||||||
|
{
|
||||||
|
await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct);
|
||||||
|
await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct);
|
||||||
|
await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct);
|
||||||
|
await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct);
|
||||||
|
await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct);
|
||||||
|
await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ApplyPass<T>(
|
||||||
|
IReadOnlyList<EntityChange<T>> changes,
|
||||||
|
ChangeKind kind,
|
||||||
|
Func<EntityChange<T>, CancellationToken, Task>? callback,
|
||||||
|
List<string> errors,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (callback is null) return;
|
||||||
|
|
||||||
|
foreach (var change in changes.Where(c => c.Kind == kind))
|
||||||
|
{
|
||||||
|
try { await callback(change, ct); }
|
||||||
|
catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
|
||||||
|
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
|
||||||
|
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
|
||||||
|
/// settle before new rows appear.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GenerationDiff(
|
||||||
|
IReadOnlyList<EntityChange<Namespace>> Namespaces,
|
||||||
|
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
|
||||||
|
IReadOnlyList<EntityChange<Device>> Devices,
|
||||||
|
IReadOnlyList<EntityChange<Equipment>> Equipment,
|
||||||
|
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
|
||||||
|
IReadOnlyList<EntityChange<Tag>> Tags);
|
||||||
|
|
||||||
|
public sealed record EntityChange<T>(ChangeKind Kind, string LogicalId, T? From, T? To);
|
||||||
|
|
||||||
|
public static class GenerationDiffer
|
||||||
|
{
|
||||||
|
public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to)
|
||||||
|
{
|
||||||
|
from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId };
|
||||||
|
|
||||||
|
return new GenerationDiff(
|
||||||
|
Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId,
|
||||||
|
(a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes)
|
||||||
|
== (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)),
|
||||||
|
Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId,
|
||||||
|
(a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig)
|
||||||
|
== (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)),
|
||||||
|
Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId,
|
||||||
|
(a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig)
|
||||||
|
== (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)),
|
||||||
|
Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId,
|
||||||
|
(a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled)
|
||||||
|
== (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)),
|
||||||
|
PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId,
|
||||||
|
(a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs)
|
||||||
|
== (b.DriverInstanceId, b.Name, b.IntervalMs)),
|
||||||
|
Tags: DiffById(from.Tags, to.Tags, x => x.TagId,
|
||||||
|
(a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig)
|
||||||
|
== (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<EntityChange<T>> DiffById<T>(
|
||||||
|
IReadOnlyList<T> from, IReadOnlyList<T> to,
|
||||||
|
Func<T, string> id, Func<T, T, bool> equal)
|
||||||
|
{
|
||||||
|
var fromById = from.ToDictionary(id);
|
||||||
|
var toById = to.ToDictionary(id);
|
||||||
|
var result = new List<EntityChange<T>>();
|
||||||
|
|
||||||
|
foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key)))
|
||||||
|
result.Add(new(ChangeKind.Removed, logicalId, src, default));
|
||||||
|
|
||||||
|
foreach (var (logicalId, dst) in toById)
|
||||||
|
{
|
||||||
|
if (!fromById.TryGetValue(logicalId, out var src))
|
||||||
|
result.Add(new(ChangeKind.Added, logicalId, default, dst));
|
||||||
|
else if (!equal(src, dst))
|
||||||
|
result.Add(new(ChangeKind.Modified, logicalId, src, dst));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a <see cref="GenerationDiff"/> to whatever backing runtime the node owns: the OPC UA
|
||||||
|
/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete
|
||||||
|
/// callbacks into this via <see cref="ApplyCallbacks"/> so the Configuration project stays free
|
||||||
|
/// of a Core/Server dependency (interface independence per decision #59).
|
||||||
|
/// </summary>
|
||||||
|
public interface IGenerationApplier
|
||||||
|
{
|
||||||
|
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ApplyResult(
|
||||||
|
bool Succeeded,
|
||||||
|
GenerationDiff Diff,
|
||||||
|
IReadOnlyList<string> Errors)
|
||||||
|
{
|
||||||
|
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
|
||||||
|
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used by <c>dotnet ef</c> at design time (migrations, scaffolding). Reads the connection string
|
||||||
|
/// from the <c>OTOPCUA_CONFIG_CONNECTION</c> environment variable, falling back to the local dev
|
||||||
|
/// container on <c>localhost:1433</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
|
||||||
|
{
|
||||||
|
// Host-port 14330 avoids collision with the native MSSQL14 instance on 1433 (Galaxy "ZB" DB).
|
||||||
|
private const string DefaultConnectionString =
|
||||||
|
"Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;";
|
||||||
|
|
||||||
|
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION")
|
||||||
|
?? DefaultConnectionString;
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName))
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new OtOpcUaConfigDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
|
||||||
|
public sealed class ClusterNode
|
||||||
|
{
|
||||||
|
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
|
||||||
|
public required string NodeId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public required RedundancyRole RedundancyRole { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Machine hostname / IP.</summary>
|
||||||
|
public required string Host { get; set; }
|
||||||
|
|
||||||
|
public int OpcUaPort { get; set; } = 4840;
|
||||||
|
|
||||||
|
public int DashboardPort { get; set; } = 8081;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
|
||||||
|
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
|
||||||
|
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
|
||||||
|
/// hostname change would break all client trust.
|
||||||
|
/// </summary>
|
||||||
|
public required string ApplicationUri { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
|
||||||
|
public byte ServiceLevelBase { get; set; } = 200;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
|
||||||
|
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
|
||||||
|
/// </summary>
|
||||||
|
public string? DriverConfigOverridesJson { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string CreatedBy { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public ServerCluster? Cluster { get; set; }
|
||||||
|
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
|
||||||
|
public ClusterNodeGenerationState? GenerationState { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
|
||||||
|
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClusterNodeCredential
|
||||||
|
{
|
||||||
|
public Guid CredentialId { get; set; }
|
||||||
|
|
||||||
|
public required string NodeId { get; set; }
|
||||||
|
|
||||||
|
public required CredentialKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
|
||||||
|
public required string Value { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? RotatedAt { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public ClusterNode? Node { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
|
||||||
|
/// 2-node cluster track independently per decision #84.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClusterNodeGenerationState
|
||||||
|
{
|
||||||
|
public required string NodeId { get; set; }
|
||||||
|
|
||||||
|
public long? CurrentGenerationId { get; set; }
|
||||||
|
|
||||||
|
public DateTime? LastAppliedAt { get; set; }
|
||||||
|
|
||||||
|
public NodeApplyStatus? LastAppliedStatus { get; set; }
|
||||||
|
|
||||||
|
public string? LastAppliedError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Updated on every poll for liveness detection.</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
|
||||||
|
public ClusterNode? Node { get; set; }
|
||||||
|
public ConfigGeneration? CurrentGeneration { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
|
||||||
|
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigAuditLog
|
||||||
|
{
|
||||||
|
public long AuditId { get; set; }
|
||||||
|
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string Principal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
|
||||||
|
public required string EventType { get; set; }
|
||||||
|
|
||||||
|
public string? ClusterId { get; set; }
|
||||||
|
|
||||||
|
public string? NodeId { get; set; }
|
||||||
|
|
||||||
|
public long? GenerationId { get; set; }
|
||||||
|
|
||||||
|
public string? DetailsJson { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomic, immutable snapshot of one cluster's configuration.
|
||||||
|
/// Per decision #82 — cluster-scoped, not fleet-scoped.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigGeneration
|
||||||
|
{
|
||||||
|
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public required GenerationStatus Status { get; set; }
|
||||||
|
|
||||||
|
public long? ParentGenerationId { get; set; }
|
||||||
|
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
|
||||||
|
public string? PublishedBy { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public ServerCluster? Cluster { get; set; }
|
||||||
|
public ConfigGeneration? Parent { get; set; }
|
||||||
|
}
|
||||||
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
|
||||||
|
public sealed class Device
|
||||||
|
{
|
||||||
|
public Guid DeviceRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string DeviceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
|
||||||
|
public required string DeviceConfig { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
|
||||||
|
public sealed class DriverInstance
|
||||||
|
{
|
||||||
|
public Guid DriverInstanceRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
|
||||||
|
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||||
|
/// </summary>
|
||||||
|
public required string NamespaceId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
|
||||||
|
public required string DriverType { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||||
|
public required string DriverConfig { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
public ServerCluster? Cluster { get; set; }
|
||||||
|
}
|
||||||
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
|
||||||
|
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
|
||||||
|
/// #138–139 (OPC 40010 Identification fields as first-class columns).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Equipment
|
||||||
|
{
|
||||||
|
public Guid EquipmentRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
|
||||||
|
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
|
||||||
|
/// </summary>
|
||||||
|
public required string EquipmentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
|
||||||
|
public Guid EquipmentUuid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
|
||||||
|
public required string UnsLineId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
// Operator-facing / external-system identifiers (decision #116)
|
||||||
|
|
||||||
|
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
|
||||||
|
public required string MachineCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
|
||||||
|
public string? ZTag { get; set; }
|
||||||
|
|
||||||
|
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
|
||||||
|
public string? SAPID { get; set; }
|
||||||
|
|
||||||
|
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||||
|
// All nullable so equipment can be added before identity is fully captured.
|
||||||
|
public string? Manufacturer { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
public string? SerialNumber { get; set; }
|
||||||
|
public string? HardwareRevision { get; set; }
|
||||||
|
public string? SoftwareRevision { get; set; }
|
||||||
|
public short? YearOfConstruction { get; set; }
|
||||||
|
public string? AssetLocation { get; set; }
|
||||||
|
public string? ManufacturerUri { get; set; }
|
||||||
|
public string? DeviceManualUri { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||||
|
public string? EquipmentClassRef { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
|
||||||
|
/// Exists outside generation flow specifically because old generations and disabled equipment can
|
||||||
|
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExternalIdReservation
|
||||||
|
{
|
||||||
|
public Guid ReservationId { get; set; }
|
||||||
|
|
||||||
|
public required ReservationKind Kind { get; set; }
|
||||||
|
|
||||||
|
public required string Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
|
||||||
|
public Guid EquipmentUuid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>First cluster to publish this reservation.</summary>
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string FirstPublishedBy { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
|
||||||
|
public DateTime? ReleasedAt { get; set; }
|
||||||
|
|
||||||
|
public string? ReleasedBy { get; set; }
|
||||||
|
|
||||||
|
public string? ReleaseReason { get; set; }
|
||||||
|
}
|
||||||
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
|
||||||
|
/// namespaces are content (affect what consumers see at the endpoint), not topology.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Namespace
|
||||||
|
{
|
||||||
|
public Guid NamespaceRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
|
||||||
|
public required string NamespaceId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public required NamespaceKind Kind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
|
||||||
|
public required string NamespaceUri { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
public ServerCluster? Cluster { get; set; }
|
||||||
|
}
|
||||||
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||||
|
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeAcl
|
||||||
|
{
|
||||||
|
public Guid NodeAclRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string NodeAclId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public required string LdapGroup { get; set; }
|
||||||
|
|
||||||
|
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
|
||||||
|
public string? ScopeId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
|
||||||
|
public required NodePermissions PermissionFlags { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
|
||||||
|
public sealed class PollGroup
|
||||||
|
{
|
||||||
|
public Guid PollGroupRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string PollGroupId { get; set; }
|
||||||
|
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public int IntervalMs { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
|
||||||
|
/// Per <c>config-db-schema.md</c> ServerCluster table.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerCluster
|
||||||
|
{
|
||||||
|
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||||
|
public required string Enterprise { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||||
|
public required string Site { get; set; }
|
||||||
|
|
||||||
|
public byte NodeCount { get; set; }
|
||||||
|
|
||||||
|
public required RedundancyMode RedundancyMode { get; set; }
|
||||||
|
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public required string CreatedBy { get; set; }
|
||||||
|
|
||||||
|
public DateTime? ModifiedAt { get; set; }
|
||||||
|
|
||||||
|
public string? ModifiedBy { get; set; }
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public ICollection<ClusterNode> Nodes { get; set; } = [];
|
||||||
|
public ICollection<Namespace> Namespaces { get; set; } = [];
|
||||||
|
public ICollection<ConfigGeneration> Generations { get; set; } = [];
|
||||||
|
}
|
||||||
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
|
||||||
|
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
|
||||||
|
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Tag
|
||||||
|
{
|
||||||
|
public Guid TagRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string TagId { get; set; }
|
||||||
|
|
||||||
|
public required string DriverInstanceId { get; set; }
|
||||||
|
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
|
||||||
|
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
|
||||||
|
/// </summary>
|
||||||
|
public string? EquipmentId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
|
||||||
|
public string? FolderPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
|
||||||
|
public required string DataType { get; set; }
|
||||||
|
|
||||||
|
public required TagAccessLevel AccessLevel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||||
|
public bool WriteIdempotent { get; set; }
|
||||||
|
|
||||||
|
public string? PollGroupId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
|
||||||
|
public required string TagConfig { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||||
|
public sealed class UnsArea
|
||||||
|
{
|
||||||
|
public Guid UnsAreaRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string UnsAreaId { get; set; }
|
||||||
|
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
public ServerCluster? Cluster { get; set; }
|
||||||
|
}
|
||||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||||
|
public sealed class UnsLine
|
||||||
|
{
|
||||||
|
public Guid UnsLineRowId { get; set; }
|
||||||
|
|
||||||
|
public long GenerationId { get; set; }
|
||||||
|
|
||||||
|
public required string UnsLineId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
|
||||||
|
public required string UnsAreaId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public ConfigGeneration? Generation { get; set; }
|
||||||
|
}
|
||||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
|
||||||
|
public enum CredentialKind
|
||||||
|
{
|
||||||
|
SqlLogin,
|
||||||
|
ClientCertThumbprint,
|
||||||
|
ADPrincipal,
|
||||||
|
gMSA,
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>Generation lifecycle state. Draft → Published → Superseded | RolledBack.</summary>
|
||||||
|
public enum GenerationStatus
|
||||||
|
{
|
||||||
|
Draft,
|
||||||
|
Published,
|
||||||
|
Superseded,
|
||||||
|
RolledBack,
|
||||||
|
}
|
||||||
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
|
||||||
|
public enum NamespaceKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
|
||||||
|
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
|
||||||
|
/// applies.
|
||||||
|
/// </summary>
|
||||||
|
Equipment,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in).
|
||||||
|
/// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it.
|
||||||
|
/// </summary>
|
||||||
|
SystemPlatform,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
|
||||||
|
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
|
||||||
|
/// driver lands.
|
||||||
|
/// </summary>
|
||||||
|
Simulated,
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
|
||||||
|
public enum NodeAclScopeKind
|
||||||
|
{
|
||||||
|
Cluster,
|
||||||
|
Namespace,
|
||||||
|
UnsArea,
|
||||||
|
UnsLine,
|
||||||
|
Equipment,
|
||||||
|
Tag,
|
||||||
|
}
|
||||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>Status tracked per node in <see cref="Entities.ClusterNodeGenerationState"/>.</summary>
|
||||||
|
public enum NodeApplyStatus
|
||||||
|
{
|
||||||
|
Applied,
|
||||||
|
RolledBack,
|
||||||
|
Failed,
|
||||||
|
InProgress,
|
||||||
|
}
|
||||||
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA client data-path permissions per <c>acl-design.md</c>.
|
||||||
|
/// Stored as <c>int</c> bitmask in <see cref="Entities.NodeAcl.PermissionFlags"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Flags]
|
||||||
|
public enum NodePermissions : uint
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
// Read-side
|
||||||
|
Browse = 1 << 0,
|
||||||
|
Read = 1 << 1,
|
||||||
|
Subscribe = 1 << 2,
|
||||||
|
HistoryRead = 1 << 3,
|
||||||
|
|
||||||
|
// Write-side (mirrors v1 SecurityClassification model)
|
||||||
|
WriteOperate = 1 << 4,
|
||||||
|
WriteTune = 1 << 5,
|
||||||
|
WriteConfigure = 1 << 6,
|
||||||
|
|
||||||
|
// Alarm-side
|
||||||
|
AlarmRead = 1 << 7,
|
||||||
|
AlarmAcknowledge = 1 << 8,
|
||||||
|
AlarmConfirm = 1 << 9,
|
||||||
|
AlarmShelve = 1 << 10,
|
||||||
|
|
||||||
|
// OPC UA Part 4 §5.11
|
||||||
|
MethodCall = 1 << 11,
|
||||||
|
|
||||||
|
// Bundles (one-click grants in Admin UI)
|
||||||
|
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
|
||||||
|
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
|
||||||
|
Engineer = Operator | WriteTune | AlarmShelve,
|
||||||
|
Admin = Engineer | WriteConfigure | MethodCall,
|
||||||
|
}
|
||||||
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cluster redundancy mode per OPC UA Part 5 §6.5. Persisted as string in
|
||||||
|
/// <c>ServerCluster.RedundancyMode</c> with a CHECK constraint coupling to <c>NodeCount</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum RedundancyMode
|
||||||
|
{
|
||||||
|
/// <summary>Single-node cluster. Required when <c>NodeCount = 1</c>.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>Warm redundancy (non-transparent). Two-node cluster.</summary>
|
||||||
|
Warm,
|
||||||
|
|
||||||
|
/// <summary>Hot redundancy (non-transparent). Two-node cluster.</summary>
|
||||||
|
Hot,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>Per-node redundancy role within a cluster. Per decision #84.</summary>
|
||||||
|
public enum RedundancyRole
|
||||||
|
{
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
Standalone,
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>External-ID reservation kind. Per decision #124.</summary>
|
||||||
|
public enum ReservationKind
|
||||||
|
{
|
||||||
|
ZTag,
|
||||||
|
SAPID,
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
/// <summary>Tag-level OPC UA access level baseline. Further narrowed per-user by NodeAcl grants.</summary>
|
||||||
|
public enum TagAccessLevel
|
||||||
|
{
|
||||||
|
Read,
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A self-contained snapshot of one generation — enough to rebuild the address space on a node
|
||||||
|
/// that has lost DB connectivity. The payload is the JSON-serialized <c>sp_GetGenerationContent</c>
|
||||||
|
/// result; the local cache doesn't inspect the shape, it just round-trips bytes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GenerationSnapshot
|
||||||
|
{
|
||||||
|
public int Id { get; set; } // LiteDB auto-ID
|
||||||
|
public required string ClusterId { get; set; }
|
||||||
|
public required long GenerationId { get; set; }
|
||||||
|
public required DateTime CachedAt { get; set; }
|
||||||
|
public required string PayloadJson { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
|
||||||
|
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
|
||||||
|
/// </summary>
|
||||||
|
public interface ILocalConfigCache
|
||||||
|
{
|
||||||
|
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
|
||||||
|
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||||
|
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using LiteDB;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
|
||||||
|
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
|
||||||
|
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
|
||||||
|
/// delete and re-fetch from the central DB (decision #80).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||||
|
{
|
||||||
|
private const string CollectionName = "generations";
|
||||||
|
private readonly LiteDatabase _db;
|
||||||
|
private readonly ILiteCollection<GenerationSnapshot> _col;
|
||||||
|
|
||||||
|
public LiteDbConfigCache(string dbPath)
|
||||||
|
{
|
||||||
|
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
|
||||||
|
// the header and "recover"), so we force a write + read probe to fail fast on real corruption.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true });
|
||||||
|
_col = _db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||||
|
_col.EnsureIndex(s => s.ClusterId);
|
||||||
|
_col.EnsureIndex(s => s.GenerationId);
|
||||||
|
_ = _col.Count();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||||
|
or NotSupportedException or UnauthorizedAccessException
|
||||||
|
or ArgumentOutOfRangeException or FormatException)
|
||||||
|
{
|
||||||
|
_db?.Dispose();
|
||||||
|
throw new LocalConfigCacheCorruptException(
|
||||||
|
$"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var snapshot = _col
|
||||||
|
.Find(s => s.ClusterId == clusterId)
|
||||||
|
.OrderByDescending(s => s.GenerationId)
|
||||||
|
.FirstOrDefault();
|
||||||
|
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
// upsert by (ClusterId, GenerationId) — replace in place if already cached
|
||||||
|
var existing = _col
|
||||||
|
.Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
_col.Insert(snapshot);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
snapshot.Id = existing.Id;
|
||||||
|
_col.Update(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var doomed = _col
|
||||||
|
.Find(s => s.ClusterId == clusterId)
|
||||||
|
.OrderByDescending(s => s.GenerationId)
|
||||||
|
.Skip(keepLatest)
|
||||||
|
.Select(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var id in doomed)
|
||||||
|
_col.Delete(id);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LocalConfigCacheCorruptException(string message, Exception inner)
|
||||||
|
: Exception(message, inner);
|
||||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialSchema : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ConfigAuditLog",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
AuditId = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
|
||||||
|
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalIdReservation",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||||
|
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ServerCluster",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
|
||||||
|
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
|
||||||
|
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClusterNode",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||||
|
OpcUaPort = table.Column<int>(type: "int", nullable: false),
|
||||||
|
DashboardPort = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
|
||||||
|
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterNode_ServerCluster_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "ServerCluster",
|
||||||
|
principalColumn: "ClusterId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ConfigGeneration",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
|
||||||
|
column: x => x.ParentGenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "ServerCluster",
|
||||||
|
principalColumn: "ClusterId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClusterNodeCredential",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
|
||||||
|
column: x => x.NodeId,
|
||||||
|
principalTable: "ClusterNode",
|
||||||
|
principalColumn: "NodeId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClusterNodeGenerationState",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||||
|
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||||
|
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
|
||||||
|
column: x => x.NodeId,
|
||||||
|
principalTable: "ClusterNode",
|
||||||
|
principalColumn: "NodeId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
|
||||||
|
column: x => x.CurrentGenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Device",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
|
||||||
|
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Device_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DriverInstance",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
|
||||||
|
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_DriverInstance_ServerCluster_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "ServerCluster",
|
||||||
|
principalColumn: "ClusterId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Equipment",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||||
|
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||||
|
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
|
||||||
|
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||||
|
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||||
|
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||||
|
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Equipment_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Namespace",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Namespace_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Namespace_ServerCluster_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "ServerCluster",
|
||||||
|
principalColumn: "ClusterId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NodeAcl",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
PermissionFlags = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PollGroup",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
IntervalMs = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
|
||||||
|
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PollGroup_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Tag",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||||
|
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||||
|
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Tag", x => x.TagRowId);
|
||||||
|
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Tag_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UnsArea",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UnsArea_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UnsArea_ServerCluster_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "ServerCluster",
|
||||||
|
principalColumn: "ClusterId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UnsLine",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||||
|
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UnsLine_ConfigGeneration_GenerationId",
|
||||||
|
column: x => x.GenerationId,
|
||||||
|
principalTable: "ConfigGeneration",
|
||||||
|
principalColumn: "GenerationId",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ClusterNode_ApplicationUri",
|
||||||
|
table: "ClusterNode",
|
||||||
|
column: "ApplicationUri",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ClusterNode_Primary_Per_Cluster",
|
||||||
|
table: "ClusterNode",
|
||||||
|
column: "ClusterId",
|
||||||
|
unique: true,
|
||||||
|
filter: "[RedundancyRole] = 'Primary'");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClusterNodeCredential_NodeId",
|
||||||
|
table: "ClusterNodeCredential",
|
||||||
|
columns: new[] { "NodeId", "Enabled" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ClusterNodeCredential_Value",
|
||||||
|
table: "ClusterNodeCredential",
|
||||||
|
columns: new[] { "Kind", "Value" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[Enabled] = 1");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClusterNodeGenerationState_Generation",
|
||||||
|
table: "ClusterNodeGenerationState",
|
||||||
|
column: "CurrentGenerationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ConfigAuditLog_Cluster_Time",
|
||||||
|
table: "ConfigAuditLog",
|
||||||
|
columns: new[] { "ClusterId", "Timestamp" },
|
||||||
|
descending: new[] { false, true });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ConfigAuditLog_Generation",
|
||||||
|
table: "ConfigAuditLog",
|
||||||
|
column: "GenerationId",
|
||||||
|
filter: "[GenerationId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ConfigGeneration_Cluster_Published",
|
||||||
|
table: "ConfigGeneration",
|
||||||
|
columns: new[] { "ClusterId", "Status", "GenerationId" },
|
||||||
|
descending: new[] { false, false, true })
|
||||||
|
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ConfigGeneration_ParentGenerationId",
|
||||||
|
table: "ConfigGeneration",
|
||||||
|
column: "ParentGenerationId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ConfigGeneration_Draft_Per_Cluster",
|
||||||
|
table: "ConfigGeneration",
|
||||||
|
column: "ClusterId",
|
||||||
|
unique: true,
|
||||||
|
filter: "[Status] = 'Draft'");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Device_Generation_Driver",
|
||||||
|
table: "Device",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Device_Generation_LogicalId",
|
||||||
|
table: "Device",
|
||||||
|
columns: new[] { "GenerationId", "DeviceId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[DeviceId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverInstance_ClusterId",
|
||||||
|
table: "DriverInstance",
|
||||||
|
column: "ClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverInstance_Generation_Cluster",
|
||||||
|
table: "DriverInstance",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DriverInstance_Generation_Namespace",
|
||||||
|
table: "DriverInstance",
|
||||||
|
columns: new[] { "GenerationId", "NamespaceId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_DriverInstance_Generation_LogicalId",
|
||||||
|
table: "DriverInstance",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[DriverInstanceId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Equipment_Generation_Driver",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Equipment_Generation_Line",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "UnsLineId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Equipment_Generation_MachineCode",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "MachineCode" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Equipment_Generation_SAPID",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "SAPID" },
|
||||||
|
filter: "[SAPID] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Equipment_Generation_ZTag",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "ZTag" },
|
||||||
|
filter: "[ZTag] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Equipment_Generation_LinePath",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "UnsLineId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Equipment_Generation_LogicalId",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "EquipmentId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[EquipmentId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Equipment_Generation_Uuid",
|
||||||
|
table: "Equipment",
|
||||||
|
columns: new[] { "GenerationId", "EquipmentUuid" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalIdReservation_Equipment",
|
||||||
|
table: "ExternalIdReservation",
|
||||||
|
column: "EquipmentUuid");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ExternalIdReservation_KindValue_Active",
|
||||||
|
table: "ExternalIdReservation",
|
||||||
|
columns: new[] { "Kind", "Value" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[ReleasedAt] IS NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Namespace_ClusterId",
|
||||||
|
table: "Namespace",
|
||||||
|
column: "ClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Namespace_Generation_Cluster",
|
||||||
|
table: "Namespace",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Namespace_Generation_Cluster_Kind",
|
||||||
|
table: "Namespace",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId", "Kind" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Namespace_Generation_LogicalId",
|
||||||
|
table: "Namespace",
|
||||||
|
columns: new[] { "GenerationId", "NamespaceId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[NamespaceId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Namespace_Generation_LogicalId_Cluster",
|
||||||
|
table: "Namespace",
|
||||||
|
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[NamespaceId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Namespace_Generation_NamespaceUri",
|
||||||
|
table: "Namespace",
|
||||||
|
columns: new[] { "GenerationId", "NamespaceUri" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NodeAcl_Generation_Cluster",
|
||||||
|
table: "NodeAcl",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NodeAcl_Generation_Group",
|
||||||
|
table: "NodeAcl",
|
||||||
|
columns: new[] { "GenerationId", "LdapGroup" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NodeAcl_Generation_Scope",
|
||||||
|
table: "NodeAcl",
|
||||||
|
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
|
||||||
|
filter: "[ScopeId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_NodeAcl_Generation_GroupScope",
|
||||||
|
table: "NodeAcl",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[ScopeId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_NodeAcl_Generation_LogicalId",
|
||||||
|
table: "NodeAcl",
|
||||||
|
columns: new[] { "GenerationId", "NodeAclId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[NodeAclId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PollGroup_Generation_Driver",
|
||||||
|
table: "PollGroup",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_PollGroup_Generation_LogicalId",
|
||||||
|
table: "PollGroup",
|
||||||
|
columns: new[] { "GenerationId", "PollGroupId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[PollGroupId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ServerCluster_Site",
|
||||||
|
table: "ServerCluster",
|
||||||
|
column: "Site");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ServerCluster_Name",
|
||||||
|
table: "ServerCluster",
|
||||||
|
column: "Name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tag_Generation_Driver_Device",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tag_Generation_Equipment",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "GenerationId", "EquipmentId" },
|
||||||
|
filter: "[EquipmentId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Tag_Generation_EquipmentPath",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[EquipmentId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Tag_Generation_FolderPath",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[EquipmentId] IS NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_Tag_Generation_LogicalId",
|
||||||
|
table: "Tag",
|
||||||
|
columns: new[] { "GenerationId", "TagId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[TagId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UnsArea_ClusterId",
|
||||||
|
table: "UnsArea",
|
||||||
|
column: "ClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UnsArea_Generation_Cluster",
|
||||||
|
table: "UnsArea",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_UnsArea_Generation_ClusterName",
|
||||||
|
table: "UnsArea",
|
||||||
|
columns: new[] { "GenerationId", "ClusterId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_UnsArea_Generation_LogicalId",
|
||||||
|
table: "UnsArea",
|
||||||
|
columns: new[] { "GenerationId", "UnsAreaId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[UnsAreaId] IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UnsLine_Generation_Area",
|
||||||
|
table: "UnsLine",
|
||||||
|
columns: new[] { "GenerationId", "UnsAreaId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_UnsLine_Generation_AreaName",
|
||||||
|
table: "UnsLine",
|
||||||
|
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_UnsLine_Generation_LogicalId",
|
||||||
|
table: "UnsLine",
|
||||||
|
columns: new[] { "GenerationId", "UnsLineId" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[UnsLineId] IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClusterNodeCredential");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClusterNodeGenerationState");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ConfigAuditLog");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Device");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DriverInstance");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Equipment");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalIdReservation");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Namespace");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NodeAcl");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PollGroup");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Tag");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UnsArea");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UnsLine");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClusterNode");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ConfigGeneration");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ServerCluster");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
|
||||||
|
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
|
||||||
|
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
|
||||||
|
/// </summary>
|
||||||
|
public partial class StoredProcedures : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
|
||||||
|
migrationBuilder.Sql(Procs.GetGenerationContent);
|
||||||
|
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
|
||||||
|
migrationBuilder.Sql(Procs.ValidateDraft);
|
||||||
|
migrationBuilder.Sql(Procs.PublishGeneration);
|
||||||
|
migrationBuilder.Sql(Procs.RollbackToGeneration);
|
||||||
|
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
|
||||||
|
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
foreach (var name in new[]
|
||||||
|
{
|
||||||
|
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
|
||||||
|
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
|
||||||
|
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
|
||||||
|
})
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Procs
|
||||||
|
{
|
||||||
|
public const string GetCurrentGenerationForCluster = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
|
||||||
|
@NodeId nvarchar(64),
|
||||||
|
@ClusterId nvarchar(64)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||||
|
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ClusterNode
|
||||||
|
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
|
||||||
|
FROM dbo.ConfigGeneration
|
||||||
|
WHERE ClusterId = @ClusterId AND Status = 'Published'
|
||||||
|
ORDER BY GenerationId DESC;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string GetGenerationContent = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
|
||||||
|
@NodeId nvarchar(64),
|
||||||
|
@GenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||||
|
DECLARE @ClusterId nvarchar(64);
|
||||||
|
|
||||||
|
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
|
||||||
|
|
||||||
|
IF @ClusterId IS NULL
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.ClusterNodeCredential c
|
||||||
|
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
|
||||||
|
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
|
||||||
|
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
|
||||||
|
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string RegisterNodeGenerationApplied = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
|
||||||
|
@NodeId nvarchar(64),
|
||||||
|
@GenerationId bigint,
|
||||||
|
@Status nvarchar(16),
|
||||||
|
@Error nvarchar(max) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||||
|
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
MERGE dbo.ClusterNodeGenerationState AS tgt
|
||||||
|
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
|
||||||
|
WHEN MATCHED THEN UPDATE SET
|
||||||
|
CurrentGenerationId = @GenerationId,
|
||||||
|
LastAppliedAt = SYSUTCDATETIME(),
|
||||||
|
LastAppliedStatus = @Status,
|
||||||
|
LastAppliedError = @Error,
|
||||||
|
LastSeenAt = SYSUTCDATETIME()
|
||||||
|
WHEN NOT MATCHED THEN INSERT
|
||||||
|
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
|
||||||
|
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
|
||||||
|
|
||||||
|
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
|
||||||
|
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
|
||||||
|
CONCAT('{""status"":""', @Status, '""}'));
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string ValidateDraft = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
|
||||||
|
@DraftGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64);
|
||||||
|
DECLARE @Status nvarchar(16);
|
||||||
|
|
||||||
|
SELECT @ClusterId = ClusterId, @Status = Status
|
||||||
|
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
|
||||||
|
|
||||||
|
IF @ClusterId IS NULL
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF @Status <> 'Draft'
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM dbo.Tag t
|
||||||
|
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
|
||||||
|
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM dbo.Tag t
|
||||||
|
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
|
||||||
|
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM dbo.Tag t
|
||||||
|
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
|
||||||
|
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.DriverInstance di
|
||||||
|
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
|
||||||
|
WHERE di.GenerationId = @DraftGenerationId
|
||||||
|
AND ns.ClusterId <> di.ClusterId)
|
||||||
|
BEGIN
|
||||||
|
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||||
|
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
|
||||||
|
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Equipment draft
|
||||||
|
JOIN dbo.Equipment prior
|
||||||
|
ON prior.EquipmentId = draft.EquipmentId
|
||||||
|
AND prior.EquipmentUuid <> draft.EquipmentUuid
|
||||||
|
AND prior.GenerationId <> draft.GenerationId
|
||||||
|
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
|
||||||
|
WHERE draft.GenerationId = @DraftGenerationId
|
||||||
|
AND pg.ClusterId = @ClusterId)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Equipment draft
|
||||||
|
JOIN dbo.ExternalIdReservation r
|
||||||
|
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
|
||||||
|
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||||
|
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dbo.Equipment draft
|
||||||
|
JOIN dbo.ExternalIdReservation r
|
||||||
|
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
|
||||||
|
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||||
|
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string PublishGeneration = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
|
||||||
|
@ClusterId nvarchar(64),
|
||||||
|
@DraftGenerationId bigint,
|
||||||
|
@Notes nvarchar(1024) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
|
||||||
|
DECLARE @LockResult int;
|
||||||
|
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
|
||||||
|
IF @LockResult < 0
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
|
||||||
|
ROLLBACK;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
|
||||||
|
|
||||||
|
MERGE dbo.ExternalIdReservation AS tgt
|
||||||
|
USING (
|
||||||
|
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
|
||||||
|
FROM dbo.Equipment
|
||||||
|
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'SAPID', SAPID, EquipmentUuid
|
||||||
|
FROM dbo.Equipment
|
||||||
|
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
|
||||||
|
) AS src
|
||||||
|
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
|
||||||
|
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
|
||||||
|
WHEN NOT MATCHED BY TARGET THEN
|
||||||
|
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
|
||||||
|
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
|
||||||
|
|
||||||
|
UPDATE dbo.ConfigGeneration
|
||||||
|
SET Status = 'Superseded'
|
||||||
|
WHERE ClusterId = @ClusterId AND Status = 'Published';
|
||||||
|
|
||||||
|
UPDATE dbo.ConfigGeneration
|
||||||
|
SET Status = 'Published',
|
||||||
|
PublishedAt = SYSUTCDATETIME(),
|
||||||
|
PublishedBy = SUSER_SNAME(),
|
||||||
|
Notes = ISNULL(@Notes, Notes)
|
||||||
|
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
|
||||||
|
|
||||||
|
IF @@ROWCOUNT = 0
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
|
||||||
|
ROLLBACK;
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||||
|
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string RollbackToGeneration = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
|
||||||
|
@ClusterId nvarchar(64),
|
||||||
|
@TargetGenerationId bigint,
|
||||||
|
@Notes nvarchar(1024) = NULL
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM dbo.ConfigGeneration
|
||||||
|
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
|
||||||
|
AND Status IN ('Published', 'Superseded'))
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
|
||||||
|
ROLLBACK; RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
DECLARE @NewGenId bigint;
|
||||||
|
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
|
||||||
|
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
|
||||||
|
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
|
||||||
|
SET @NewGenId = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
|
||||||
|
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
|
||||||
|
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
|
||||||
|
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||||
|
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
|
||||||
|
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
|
||||||
|
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
|
||||||
|
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||||
|
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
|
||||||
|
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
|
||||||
|
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
|
||||||
|
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
|
||||||
|
|
||||||
|
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
|
||||||
|
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
|
||||||
|
CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}'));
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string ComputeGenerationDiff = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||||
|
@FromGenerationId bigint,
|
||||||
|
@ToGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||||
|
|
||||||
|
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||||
|
DROP TABLE #diff;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
public const string ReleaseExternalIdReservation = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
|
||||||
|
@Kind nvarchar(16),
|
||||||
|
@Value nvarchar(64),
|
||||||
|
@ReleaseReason nvarchar(512)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('ReleaseReason is required', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
UPDATE dbo.ExternalIdReservation
|
||||||
|
SET ReleasedAt = SYSUTCDATETIME(),
|
||||||
|
ReleasedBy = SUSER_SNAME(),
|
||||||
|
ReleaseReason = @ReleaseReason
|
||||||
|
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
|
||||||
|
|
||||||
|
IF @@ROWCOUNT = 0
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
|
||||||
|
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
|
||||||
|
CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}'));
|
||||||
|
END
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
|
||||||
|
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
|
||||||
|
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
|
||||||
|
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
|
||||||
|
/// this migration and then added to one of the two roles.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AuthorizationGrants : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
|
||||||
|
CREATE ROLE OtOpcUaNode;
|
||||||
|
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
|
||||||
|
CREATE ROLE OtOpcUaAdmin;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
|
||||||
|
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
|
||||||
|
|
||||||
|
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||||
|
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||||
|
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||||
|
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
|
||||||
|
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
|
||||||
|
DROP ROLE OtOpcUaNode;
|
||||||
|
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
|
||||||
|
DROP ROLE OtOpcUaAdmin;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
|
||||||
|
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||||
|
: DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
|
||||||
|
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
|
||||||
|
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
|
||||||
|
public DbSet<ConfigGeneration> ConfigGenerations => Set<ConfigGeneration>();
|
||||||
|
public DbSet<Namespace> Namespaces => Set<Namespace>();
|
||||||
|
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
|
||||||
|
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
|
||||||
|
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
|
||||||
|
public DbSet<Device> Devices => Set<Device>();
|
||||||
|
public DbSet<Equipment> Equipment => Set<Equipment>();
|
||||||
|
public DbSet<Tag> Tags => Set<Tag>();
|
||||||
|
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||||
|
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||||
|
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||||
|
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||||
|
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
ConfigureServerCluster(modelBuilder);
|
||||||
|
ConfigureClusterNode(modelBuilder);
|
||||||
|
ConfigureClusterNodeCredential(modelBuilder);
|
||||||
|
ConfigureConfigGeneration(modelBuilder);
|
||||||
|
ConfigureNamespace(modelBuilder);
|
||||||
|
ConfigureUnsArea(modelBuilder);
|
||||||
|
ConfigureUnsLine(modelBuilder);
|
||||||
|
ConfigureDriverInstance(modelBuilder);
|
||||||
|
ConfigureDevice(modelBuilder);
|
||||||
|
ConfigureEquipment(modelBuilder);
|
||||||
|
ConfigureTag(modelBuilder);
|
||||||
|
ConfigurePollGroup(modelBuilder);
|
||||||
|
ConfigureNodeAcl(modelBuilder);
|
||||||
|
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||||
|
ConfigureConfigAuditLog(modelBuilder);
|
||||||
|
ConfigureExternalIdReservation(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ServerCluster>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ServerCluster", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
|
||||||
|
"((NodeCount = 1 AND RedundancyMode = 'None') " +
|
||||||
|
"OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.ClusterId);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
|
e.Property(x => x.Enterprise).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Site).HasMaxLength(32);
|
||||||
|
e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||||
|
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||||
|
e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.ModifiedBy).HasMaxLength(128);
|
||||||
|
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
|
||||||
|
e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureClusterNode(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ClusterNode>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ClusterNode");
|
||||||
|
e.HasKey(x => x.NodeId);
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.RedundancyRole).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.Host).HasMaxLength(255);
|
||||||
|
e.Property(x => x.ApplicationUri).HasMaxLength(256);
|
||||||
|
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
|
||||||
|
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
|
||||||
|
.HasForeignKey(x => x.ClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// Fleet-wide unique per decision #86
|
||||||
|
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
|
||||||
|
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
|
||||||
|
// At most one Primary per cluster
|
||||||
|
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||||
|
.HasFilter("[RedundancyRole] = 'Primary'")
|
||||||
|
.HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ClusterNodeCredential>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ClusterNodeCredential");
|
||||||
|
e.HasKey(x => x.CredentialId);
|
||||||
|
e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||||
|
e.Property(x => x.Value).HasMaxLength(512);
|
||||||
|
e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Node).WithMany(n => n.Credentials)
|
||||||
|
.HasForeignKey(x => x.NodeId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
|
||||||
|
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||||
|
.HasFilter("[Enabled] = 1")
|
||||||
|
.HasDatabaseName("UX_ClusterNodeCredential_Value");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureConfigGeneration(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ConfigGeneration>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ConfigGeneration");
|
||||||
|
e.HasKey(x => x.GenerationId);
|
||||||
|
e.Property(x => x.GenerationId).UseIdentityColumn(seed: 1, increment: 1);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.PublishedAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.PublishedBy).HasMaxLength(128);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||||
|
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Cluster).WithMany(c => c.Generations)
|
||||||
|
.HasForeignKey(x => x.ClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Parent).WithMany()
|
||||||
|
.HasForeignKey(x => x.ParentGenerationId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.ClusterId, x.Status, x.GenerationId })
|
||||||
|
.IsDescending(false, false, true)
|
||||||
|
.IncludeProperties(x => x.PublishedAt)
|
||||||
|
.HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
|
||||||
|
// One Draft per cluster at a time
|
||||||
|
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||||
|
.HasFilter("[Status] = 'Draft'")
|
||||||
|
.HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureNamespace(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Namespace>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("Namespace");
|
||||||
|
e.HasKey(x => x.NamespaceRowId);
|
||||||
|
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||||
|
e.Property(x => x.NamespaceUri).HasMaxLength(256);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany()
|
||||||
|
.HasForeignKey(x => x.GenerationId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
|
||||||
|
.HasForeignKey(x => x.ClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Kind }).IsUnique()
|
||||||
|
.HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.NamespaceUri }).IsUnique()
|
||||||
|
.HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).IsUnique()
|
||||||
|
.HasDatabaseName("UX_Namespace_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.NamespaceId, x.ClusterId }).IsUnique()
|
||||||
|
.HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId })
|
||||||
|
.HasDatabaseName("IX_Namespace_Generation_Cluster");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureUnsArea(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<UnsArea>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("UnsArea");
|
||||||
|
e.HasKey(x => x.UnsAreaRowId);
|
||||||
|
e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(512);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_UnsArea_Generation_Cluster");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_ClusterName");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureUnsLine(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<UnsLine>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("UnsLine");
|
||||||
|
e.HasKey(x => x.UnsLineRowId);
|
||||||
|
e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(512);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).HasDatabaseName("IX_UnsLine_Generation_Area");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_AreaName");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<DriverInstance>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("DriverInstance", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
||||||
|
"ISJSON(DriverConfig) = 1");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.DriverInstanceRowId);
|
||||||
|
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
|
e.Property(x => x.DriverType).HasMaxLength(32);
|
||||||
|
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_DriverInstance_Generation_Cluster");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).HasDatabaseName("IX_DriverInstance_Generation_Namespace");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).IsUnique().HasDatabaseName("UX_DriverInstance_Generation_LogicalId");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureDevice(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Device>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("Device", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.DeviceRowId);
|
||||||
|
e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
|
e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Device_Generation_Driver");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DeviceId }).IsUnique().HasDatabaseName("UX_Device_Generation_LogicalId");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureEquipment(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Equipment>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("Equipment");
|
||||||
|
e.HasKey(x => x.EquipmentRowId);
|
||||||
|
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(32);
|
||||||
|
e.Property(x => x.MachineCode).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ZTag).HasMaxLength(64);
|
||||||
|
e.Property(x => x.SAPID).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Manufacturer).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Model).HasMaxLength(64);
|
||||||
|
e.Property(x => x.SerialNumber).HasMaxLength(64);
|
||||||
|
e.Property(x => x.HardwareRevision).HasMaxLength(32);
|
||||||
|
e.Property(x => x.SoftwareRevision).HasMaxLength(32);
|
||||||
|
e.Property(x => x.AssetLocation).HasMaxLength(256);
|
||||||
|
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
|
||||||
|
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
|
||||||
|
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Equipment_Generation_Driver");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).HasDatabaseName("IX_Equipment_Generation_Line");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.EquipmentId }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LinePath");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.EquipmentUuid }).IsUnique().HasDatabaseName("UX_Equipment_Generation_Uuid");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ZTag }).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_ZTag");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.SAPID }).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_SAPID");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.MachineCode }).HasDatabaseName("IX_Equipment_Generation_MachineCode");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureTag(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Tag>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("Tag", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.TagRowId);
|
||||||
|
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.TagId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
|
e.Property(x => x.FolderPath).HasMaxLength(512);
|
||||||
|
e.Property(x => x.DataType).HasMaxLength(32);
|
||||||
|
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Generation_Driver_Device");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.EquipmentId })
|
||||||
|
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_Tag_Generation_Equipment");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.TagId }).IsUnique().HasDatabaseName("UX_Tag_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique()
|
||||||
|
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("UX_Tag_Generation_EquipmentPath");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
|
||||||
|
.HasFilter("[EquipmentId] IS NULL")
|
||||||
|
.HasDatabaseName("UX_Tag_Generation_FolderPath");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigurePollGroup(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<PollGroup>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("PollGroup", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.PollGroupRowId);
|
||||||
|
e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Name).HasMaxLength(128);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_PollGroup_Generation_Driver");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.PollGroupId }).IsUnique().HasDatabaseName("UX_PollGroup_Generation_LogicalId");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<NodeAcl>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("NodeAcl");
|
||||||
|
e.HasKey(x => x.NodeAclRowId);
|
||||||
|
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.NodeAclId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.LdapGroup).HasMaxLength(256);
|
||||||
|
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.ScopeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.PermissionFlags).HasConversion<int>();
|
||||||
|
e.Property(x => x.Notes).HasMaxLength(512);
|
||||||
|
|
||||||
|
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_NodeAcl_Generation_Cluster");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.LdapGroup }).HasDatabaseName("IX_NodeAcl_Generation_Group");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ScopeKind, x.ScopeId })
|
||||||
|
.HasFilter("[ScopeId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_NodeAcl_Generation_Scope");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.NodeAclId }).IsUnique().HasDatabaseName("UX_NodeAcl_Generation_LogicalId");
|
||||||
|
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
|
||||||
|
.HasDatabaseName("UX_NodeAcl_Generation_GroupScope");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureClusterNodeGenerationState(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ClusterNodeGenerationState>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ClusterNodeGenerationState");
|
||||||
|
e.HasKey(x => x.NodeId);
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.LastAppliedAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.LastAppliedStatus).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.LastAppliedError).HasMaxLength(2048);
|
||||||
|
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||||
|
|
||||||
|
e.HasOne(x => x.Node).WithOne(n => n.GenerationState).HasForeignKey<ClusterNodeGenerationState>(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
e.HasOne(x => x.CurrentGeneration).WithMany().HasForeignKey(x => x.CurrentGenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
e.HasIndex(x => x.CurrentGenerationId).HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ConfigAuditLog>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ConfigAuditLog", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||||
|
"DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||||
|
});
|
||||||
|
e.HasKey(x => x.AuditId);
|
||||||
|
e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
|
||||||
|
e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.Principal).HasMaxLength(128);
|
||||||
|
e.Property(x => x.EventType).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
|
||||||
|
e.HasIndex(x => x.GenerationId)
|
||||||
|
.HasFilter("[GenerationId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_ConfigAuditLog_Generation");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ExternalIdReservation>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("ExternalIdReservation");
|
||||||
|
e.HasKey(x => x.ReservationId);
|
||||||
|
e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||||
|
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
|
||||||
|
e.Property(x => x.Value).HasMaxLength(64);
|
||||||
|
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
|
||||||
|
e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
|
||||||
|
e.Property(x => x.ReleasedBy).HasMaxLength(128);
|
||||||
|
e.Property(x => x.ReleaseReason).HasMaxLength(512);
|
||||||
|
|
||||||
|
// Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
|
||||||
|
// The UX_ filtered index covers active-reservation lookups; history queries over released rows
|
||||||
|
// fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
|
||||||
|
// index is declared because EF Core merges duplicate column sets into a single index, which would
|
||||||
|
// clobber the filtered-unique name.
|
||||||
|
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||||
|
.HasFilter("[ReleasedAt] IS NULL")
|
||||||
|
.HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
|
||||||
|
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inputs for draft validation. Contains the draft's rows plus the minimum prior-generation
|
||||||
|
/// rows needed for cross-generation invariants (EquipmentUuid stability, UnsArea identity).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DraftSnapshot
|
||||||
|
{
|
||||||
|
public required long GenerationId { get; init; }
|
||||||
|
public required string ClusterId { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
|
||||||
|
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||||
|
public IReadOnlyList<Device> Devices { get; init; } = [];
|
||||||
|
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||||
|
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||||
|
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||||
|
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||||
|
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
||||||
|
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Active reservations (<c>ReleasedAt IS NULL</c>) for pre-flight.</summary>
|
||||||
|
public IReadOnlyList<ExternalIdReservation> ActiveReservations { get; init; } = [];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user