diff --git a/.github/workflows/v2-ci.yml b/.github/workflows/v2-ci.yml new file mode 100644 index 0000000..233f048 --- /dev/null +++ b/.github/workflows/v2-ci.yml @@ -0,0 +1,70 @@ +# CI for the v2 branch — runs on every push + PR to the v2-akka-fuse / master +# branches. Layered into three jobs: +# build dotnet restore + build (fast feedback on compile errors) +# unit-tests every v2 unit-test project +# integration 2-node Host.IntegrationTests harness +# +# Skips E2E (Category=E2E) — that runs nightly via v2-e2e.yml against the full +# four-node docker-dev stack. +# +# Compatible with both GitHub Actions and Gitea Actions (act_runner). The .NET 10 +# SDK is pinned via global.json at the repo root; if no global.json exists, the +# setup-dotnet step falls back to dotnet-version below. + +name: v2-ci + +on: + push: + branches: [v2-akka-fuse, master] + pull_request: + branches: [v2-akka-fuse, master] + workflow_dispatch: {} + +env: + DOTNET_NOLOGO: "1" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: dotnet restore + run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx + - name: dotnet build + run: dotnet build ZB.MOM.WW.OtOpcUa.slnx --no-restore --configuration Release + + unit-tests: + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + project: + - tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests + - tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests + - tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests + - tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests + - tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: dotnet test ${{ matrix.project }} + run: dotnet test ${{ matrix.project }} --configuration Release --filter "Category!=E2E" + + integration: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: dotnet test Host.IntegrationTests + run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests --configuration Release --filter "Category!=E2E" diff --git a/.github/workflows/v2-e2e.yml b/.github/workflows/v2-e2e.yml new file mode 100644 index 0000000..6dd9bdf --- /dev/null +++ b/.github/workflows/v2-e2e.yml @@ -0,0 +1,57 @@ +# Nightly E2E job. Runs against the docker-dev four-node fleet (admin-a + +# admin-b + driver-a + driver-b + SQL + LDAP + Traefik). Trigger: +# - cron at 03:00 UTC daily +# - workflow_dispatch from the Actions UI for on-demand runs +# +# The E2E test project (tests/Server/ZB.MOM.WW.OtOpcUa.E2ETests) does not yet +# exist — it lands when the F-series follow-ups F10/F11/F12 wire enough of the +# SDK/historian/probe so an end-to-end driver round-trip is meaningful. Until +# then this workflow is a green no-op (the `--filter Category=E2E` matches +# zero tests, and `dotnet test` returns 0). + +name: v2-e2e + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: {} + +env: + DOTNET_NOLOGO: "1" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + +jobs: + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: dotnet restore + run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx + + - name: Build docker-dev fleet + run: docker compose -f docker-dev/docker-compose.yml up -d --build + + - name: Wait for cluster + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost/health/active >/dev/null; then + echo "Admin leader healthy after ${i}s" + exit 0 + fi + sleep 2 + done + echo "Timed out waiting for /health/active" + docker compose -f docker-dev/docker-compose.yml logs --tail=200 + exit 1 + + - name: dotnet test (E2E only) + run: dotnet test --configuration Release --filter "Category=E2E" + + - name: Tear down + if: always() + run: docker compose -f docker-dev/docker-compose.yml down -v diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9d534b0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + latest + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..35ccaa6 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,103 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 49a09fd..124972d 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -2,6 +2,8 @@ + + @@ -10,8 +12,12 @@ - - + + + + + + @@ -46,6 +52,7 @@ + @@ -54,9 +61,11 @@ - - - + + + + + diff --git a/docker-dev/Dockerfile b/docker-dev/Dockerfile new file mode 100644 index 0000000..b4cf1f7 --- /dev/null +++ b/docker-dev/Dockerfile @@ -0,0 +1,20 @@ +# Multi-stage build of OtOpcUa.Host targeting linux-x64. Used by docker-dev/docker-compose.yml +# to spin four host containers (admin-a, admin-b, driver-a, driver-b) from a single image — +# Compose drives OTOPCUA_ROLES + Cluster:* env per container to differentiate them. + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore ZB.MOM.WW.OtOpcUa.slnx +RUN dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj \ + -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app ./ + +EXPOSE 9000 +EXPOSE 4053 +EXPOSE 4840 + +ENTRYPOINT ["dotnet", "OtOpcUa.Host.dll"] diff --git a/docker-dev/README.md b/docker-dev/README.md new file mode 100644 index 0000000..5f08ac2 --- /dev/null +++ b/docker-dev/README.md @@ -0,0 +1,62 @@ +# docker-dev + +Mac-friendly four-node OtOpcUa fleet for manual UI exercise + integration smoke tests. Spins up an Akka cluster + SQL Server + OpenLDAP + Traefik in front of two admin nodes. + +## Stack + +| Service | Role | Ports | +|---|---|---| +| `sql` | SQL Server 2022 (`ConfigDb` backing store) | host `14330` → container `1433` | +| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` | +| `admin-a` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, cluster seed | internal `9000` | +| `admin-b` | OtOpcUa.Host, `OTOPCUA_ROLES=admin`, joins admin-a | internal `9000` | +| `driver-a` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4840` → container `4840` | +| `driver-b` | OtOpcUa.Host, `OTOPCUA_ROLES=driver` | host `4841` → container `4840` | +| `traefik` | Routes `:80` to whichever admin-* currently passes `/health/active` | host `80`, dashboard `8080` | + +All six containers share an Akka cluster bound to port `4053` inside the Compose network. The Akka `PublicHostname` of each container matches its Compose service name; the seed-node list points at `admin-a` so the other three join via that. + +## Bring up + +```bash +# from the repo root +docker compose -f docker-dev/docker-compose.yml up -d --build + +# wait ~15 seconds for SQL to come up + the cluster to form + +open http://localhost # Blazor admin UI via Traefik +open http://localhost:8080 # Traefik dashboard +``` + +The first build takes a few minutes (.NET SDK image + restore + publish). Subsequent rebuilds are faster with Docker's layer cache. + +## Auth (dev only) + +Use one of the LDAP dev users from `LDAP_USERS` in `docker-compose.yml`: + +| Username | Password | +|---|---| +| `alice` | `alice123` | +| `bob` | `bob123` | + +The compose mounts everyone into `ou=FleetAdmin` so the dev role mapping resolves to `FleetAdmin`. + +## Tear down + +```bash +docker compose -f docker-dev/docker-compose.yml down -v +``` + +The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across restarts. + +## Failover smoke + +1. Watch the Traefik dashboard at `http://localhost:8080`. Both `admin-a` and `admin-b` should be listed as healthy in the `otopcua-admin` service. +2. `docker compose -f docker-dev/docker-compose.yml stop admin-a` — `admin-b` should pick up the admin role-leader within ~15 s (Akka split-brain stable-after). Traefik will route traffic to `admin-b` once its `/health/active` returns 200. +3. `docker compose -f docker-dev/docker-compose.yml start admin-a` — `admin-a` rejoins as a follower; `admin-b` keeps the leader role until something disturbs it. + +## Notes + +- This compose is for the **local Mac/Linux developer rig**. The team's CI + soak runs go to the remote docker host at `10.100.0.35` (see `docs/v2/dev-environment.md`); the file there mirrors this one with adjusted port bindings. +- The OPC UA driver endpoints (`opc.tcp://localhost:4840`, `opc.tcp://localhost:4841`) are reachable directly from the host — Traefik is only in front of the admin HTTP surface. +- Galaxy + Wonderware drivers can't run in Linux containers (they need the Windows-only mxaccessgw + Historian SDK). On non-Windows, `DriverInstanceActor.ShouldStub(driverType, roles)` returns `true` for those types and the actor goes straight to a `Stubbed` state that returns deterministic success. diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml new file mode 100644 index 0000000..4dcc67a --- /dev/null +++ b/docker-dev/docker-compose.yml @@ -0,0 +1,130 @@ +# docker-dev/ — Mac-friendly four-node fleet for v2 development + manual UI exercise. +# +# Stack: +# sql SQL Server 2022 (ConfigDb backing store) +# ldap OpenLDAP with the dev users from C:\publish\glauth\auth.md mirrored in +# admin-a OtOpcUa.Host with OTOPCUA_ROLES=admin (cluster seed) +# admin-b OtOpcUa.Host with OTOPCUA_ROLES=admin (joins admin-a) +# driver-a OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a) +# driver-b OtOpcUa.Host with OTOPCUA_ROLES=driver (joins via admin-a) +# traefik Routes :80 to whichever admin-* currently passes /health/active +# +# Usage: +# docker compose -f docker-dev/docker-compose.yml up -d --build +# open http://localhost # Blazor admin UI via Traefik +# open http://localhost:8080 # Traefik dashboard +# +# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v + +name: otopcua-dev + +services: + + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "OtOpcUa!Dev123" + MSSQL_PID: Developer + ports: + - "14330:1433" + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -No -Q 'SELECT 1' || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + + ldap: + image: bitnami/openldap:2.6 + environment: + LDAP_ROOT: "dc=lmxopcua,dc=local" + LDAP_ADMIN_USERNAME: "admin" + LDAP_ADMIN_PASSWORD: "ldapadmin" + LDAP_USERS: "alice,bob" + LDAP_PASSWORDS: "alice123,bob123" + LDAP_USER_DC: "ou=FleetAdmin" + ports: + - "3893:1389" + + admin-a: &otopcua-host + build: + context: .. + dockerfile: docker-dev/Dockerfile + image: otopcua-host:dev + depends_on: + sql: { condition: service_healthy } + environment: + OTOPCUA_ROLES: "admin" + ASPNETCORE_URLS: "http://+:9000" + ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + Cluster__Hostname: "0.0.0.0" + Cluster__Port: "4053" + Cluster__PublicHostname: "admin-a" + Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053" + Cluster__Roles__0: "admin" + Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345" + Security__Jwt__Issuer: "otopcua-dev" + Security__Jwt__Audience: "otopcua-dev" + Authentication__Ldap__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + + admin-b: + <<: *otopcua-host + environment: + OTOPCUA_ROLES: "admin" + ASPNETCORE_URLS: "http://+:9000" + ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + Cluster__Hostname: "0.0.0.0" + Cluster__Port: "4053" + Cluster__PublicHostname: "admin-b" + Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053" + Cluster__Roles__0: "admin" + Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345" + Security__Jwt__Issuer: "otopcua-dev" + Security__Jwt__Audience: "otopcua-dev" + Authentication__Ldap__Server: "ldap" + Authentication__Ldap__Port: "1389" + Authentication__Ldap__AllowInsecureLdap: "true" + + driver-a: + <<: *otopcua-host + environment: + OTOPCUA_ROLES: "driver" + ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + Cluster__Hostname: "0.0.0.0" + Cluster__Port: "4053" + Cluster__PublicHostname: "driver-a" + Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053" + Cluster__Roles__0: "driver" + ports: + - "4840:4840" + + driver-b: + <<: *otopcua-host + environment: + OTOPCUA_ROLES: "driver" + ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" + Cluster__Hostname: "0.0.0.0" + Cluster__Port: "4053" + Cluster__PublicHostname: "driver-b" + Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053" + Cluster__Roles__0: "driver" + ports: + - "4841:4840" + + traefik: + image: traefik:v3.1 + command: + - --entrypoints.web.address=:80 + - --providers.file.filename=/etc/traefik/dynamic.yml + - --providers.file.watch=true + - --api.insecure=true + ports: + - "80:80" + - "8080:8080" + volumes: + - ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro + depends_on: + - admin-a + - admin-b diff --git a/docker-dev/traefik-dynamic.yml b/docker-dev/traefik-dynamic.yml new file mode 100644 index 0000000..de51e14 --- /dev/null +++ b/docker-dev/traefik-dynamic.yml @@ -0,0 +1,21 @@ +# docker-dev companion to scripts/install/traefik-dynamic.yml. Same routing rules, +# but the upstream targets are the Compose service names (admin-a, admin-b) on +# port 9000 instead of the Windows hostnames a bare-metal deployment would use. + +http: + routers: + otopcua-admin: + entryPoints: ["web"] + rule: "PathPrefix(`/`)" + service: otopcua-admin + + services: + otopcua-admin: + loadBalancer: + servers: + - url: "http://admin-a:9000" + - url: "http://admin-b:9000" + healthCheck: + path: /health/active + interval: 5s + timeout: 2s diff --git a/docs/README.md b/docs/README.md index d8cb059..c85de84 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,10 +9,13 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess ## Platform overview -- **Core** owns the OPC UA stack, address space, session/security/subscription machinery. +> **v2 (2026-05-26):** the separate `OtOpcUa.Server` + `OtOpcUa.Admin` services fused into a single role-gated `OtOpcUa.Host` binary, joined by an Akka.NET cluster. See [v2 design](plans/2026-05-26-akka-hosting-alignment-design.md) for the architectural decision. + +- **Core** owns shared abstractions (driver capability contracts, scripting, virtual tags, alarm historian). - **Drivers** plug in via capability interfaces in `ZB.MOM.WW.OtOpcUa.Core.Abstractions`: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`, `IAlarmSource`, `IHistoryProvider`, `IPerCallHostResolver`. Each driver opts into whichever it supports. -- **Server** is the OPC UA endpoint process (net10, AnyCPU). Hosts every driver in-process. The Galaxy driver reaches MXAccess via gRPC to a separately-installed **mxaccessgw** sidecar (sibling repo); it is no longer hosted from this repo. -- **Admin** is the Blazor Server operator UI (net10, x64). Owns the Config DB draft/publish flow, ACL + role-grant authoring, fleet status + `/metrics` scrape endpoint. +- **Host** (`src/Server/ZB.MOM.WW.OtOpcUa.Host`) is the single fused binary (.NET 10, AnyCPU). `OTOPCUA_ROLES` env decides what to mount: `admin` (Blazor + control-plane singletons), `driver` (OPC UA endpoint + per-node actors), or both. See [ServiceHosting.md](ServiceHosting.md). +- **Cluster + ControlPlane + Runtime + AdminUI + Security** sit between Core and Host. The cluster glues per-node actors into one logical fleet; the control-plane singletons (deploy coordinator, audit writer, redundancy state) live on the admin role-leader. See [Redundancy.md](Redundancy.md). +- The Galaxy driver still reaches MXAccess via gRPC to a separately-installed **mxaccessgw** sidecar (sibling repo). ## Where to find what diff --git a/docs/Redundancy.md b/docs/Redundancy.md index 6de172f..fbab890 100644 --- a/docs/Redundancy.md +++ b/docs/Redundancy.md @@ -1,103 +1,93 @@ -# Redundancy +# Redundancy (v2) ## Overview -OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two (or more) OtOpcUa Server processes run side-by-side, share the same Config DB, the same driver backends (Galaxy ZB, MXAccess runtime, remote PLCs), and advertise the same OPC UA node tree. Each process owns a distinct `ApplicationUri`; OPC UA clients see both endpoints via the standard `ServerUriArray` and pick one based on the `ServiceLevel` that each server publishes. +OtOpcUa supports OPC UA **non-transparent** warm/hot redundancy. Two or more `OtOpcUa.Host` processes run side-by-side, share the same Config DB, and join the same Akka.NET cluster. Each process owns a distinct `ApplicationUri`; OPC UA clients see both endpoints via the standard `ServerUriArray` and pick one based on the `ServiceLevel` byte that each server publishes. -The redundancy surface lives in `src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`: +> **v2 change.** v1's operator-managed `ClusterNode.RedundancyRole` column + `RedundancyCoordinator` / `ApplyLeaseRegistry` / `PeerHttpProbeLoop` are gone. Primary/secondary is now derived from **Akka cluster role-leader** for the `driver` role. The operator no longer writes a role into the DB; cluster topology + health drive ServiceLevel automatically. -| Class | Role | -|---|---| -| `RedundancyCoordinator` | Process-singleton; owns the current `RedundancyTopology` loaded from the `ClusterNode` table. `RefreshAsync` re-reads after `sp_PublishGeneration` so operator role swaps take effect without a process restart. CAS-style swap (`Interlocked.Exchange`) means readers always see a coherent snapshot. | -| `RedundancyTopology` | Immutable `(ClusterId, Self, Peers, ServerUriArray, ValidityFlags)` snapshot. | -| `ApplyLeaseRegistry` | Tracks in-progress `sp_PublishGeneration` apply leases keyed on `(ConfigGenerationId, PublishRequestId)`. `await using` the disposable scope guarantees every exit path (success / exception / cancellation) decrements the lease; a stale-lease watchdog force-closes any lease older than `ApplyMaxDuration` (default 10 minutes) so a crashed publisher can't pin the node at `PrimaryMidApply`. | -| `PeerReachabilityTracker` | Maintains last-known reachability for each peer node over two independent probes — OPC UA ping and HTTP `/healthz`. Both must succeed for `peerReachable = true`. | -| `RecoveryStateManager` | Gates transitions out of the `Recovering*` bands; requires dwell + publish-witness satisfaction before allowing a return to nominal. | -| `ServiceLevelCalculator` | Pure function `(role, selfHealthy, peerUa, peerHttp, applyInProgress, recoveryDwellMet, topologyValid, operatorMaintenance) → byte`. | -| `RedundancyStatePublisher` | Orchestrates inputs into the calculator, pushes the resulting byte to the OPC UA `ServiceLevel` variable via an edge-triggered `OnStateChanged` event, and fires `OnServerUriArrayChanged` when the topology's `ServerUriArray` shifts. | +The runtime pieces live in: -## Data model - -Per-node redundancy state lives in the Config DB `ClusterNode` table (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs`): - -| Column | Role | -|---|---| -| `NodeId` | Unique node identity; matches `Node:NodeId` in the server's bootstrap `appsettings.json`. | -| `ClusterId` | Foreign key into `ServerCluster`. | -| `RedundancyRole` | `Primary`, `Secondary`, or `Standalone` (`RedundancyRole` enum in `Configuration/Enums`). | -| `ServiceLevelBase` | Per-node base value used to bias nominal ServiceLevel output. | -| `ApplicationUri` | Unique-per-node OPC UA ApplicationUri advertised in endpoint descriptions. | - -`ServerUriArray` is derived from the set of peer `ApplicationUri` values at topology-load time and republished when the topology changes. - -## ServiceLevel matrix - -`ServiceLevelCalculator` produces one of the following bands (see `ServiceLevelBand` enum in the same file): - -| Band | Byte | Meaning | +| Component | Project | Role | |---|---|---| -| `Maintenance` | 0 | Operator-declared maintenance. | -| `NoData` | 1 | Self-reported unhealthy (`/healthz` fails). | -| `InvalidTopology` | 2 | More than one Primary detected; both nodes self-demote. | -| `RecoveringBackup` | 30 | Backup post-fault, dwell not met. | -| `BackupMidApply` | 50 | Backup inside a publish-apply window. | -| `IsolatedBackup` | 80 | Primary unreachable; Backup says "take over if asked" — does **not** auto-promote (non-transparent model). | -| `AuthoritativeBackup` | 100 | Backup nominal. | -| `RecoveringPrimary` | 180 | Primary post-fault, dwell not met. | -| `PrimaryMidApply` | 200 | Primary inside a publish-apply window. | -| `IsolatedPrimary` | 230 | Primary with unreachable peer, retains authority. | -| `AuthoritativePrimary` | 255 | Primary nominal. | +| `ServiceLevelCalculator` | `OtOpcUa.ControlPlane.Redundancy` | Pure function `(NodeHealthInputs) → byte`. No side effects. | +| `RedundancyStateActor` | `OtOpcUa.ControlPlane.Redundancy` | Admin-role cluster singleton; subscribes to cluster topology events, debounces 250ms, broadcasts `RedundancyStateChanged` on the `redundancy-state` DPS topic. | +| `DbHealthProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; runs `SELECT 1` against ConfigDb every 5s. Read by health endpoint + redundancy calc. | +| `PeerOpcUaProbeActor` | `OtOpcUa.Runtime.Health` | Per-node; pings peer `opc.tcp://peer:4840` (real probe call is staged for follow-up F12). | +| `ClusterRoleInfo` | `OtOpcUa.Cluster` | Live view of cluster membership + role-leader; exposes `IClusterRoleInfo` to the rest of the host. | -The reserved bands (0 Maintenance, 1 NoData, 2 InvalidTopology) take precedence over operational states per OPC UA Part 5 §6.3.34. Operational values occupy 2..255 so spec-compliant clients that treat "<3 = unhealthy" keep working. +## ServiceLevel tiers (Part 5 §6.5) -Standalone nodes (single-instance deployments) report `AuthoritativePrimary` when healthy and `PrimaryMidApply` during publish. +`ServiceLevelCalculator.Compute(NodeHealthInputs)` returns a byte in 0..255 by tier: -## Publish fencing and split-brain prevention +| Tier | Byte | Condition | +|---|---|---| +| Down | 0 | Member status is not `Up` or `Joining` (leaving, removed, exiting). | +| Critically degraded | 100 | ConfigDb unreachable AND data is stale. | +| Stale | 200 | Data stale but ConfigDb reachable. | +| Healthy follower | 240 | DB ok + OPC UA probe ok + not stale. | +| Healthy leader | 250 | Healthy + this node is the `driver` role-leader. | -Any Admin-triggered `sp_PublishGeneration` acquires an apply lease through `ApplyLeaseRegistry.BeginApplyLease`. While the lease is held: +Drivers write their computed byte into the OPC UA `ServiceLevel` Variable on each refresh. Clients with the standard redundancy heuristic ("pick the highest ServiceLevel") therefore prefer the role-leader and fall back to followers on its degradation. -- The calculator reports `PrimaryMidApply` / `BackupMidApply` — clients see the band shift and cut over to the unaffected peer rather than racing against a half-applied generation. -- `RedundancyCoordinator.RefreshAsync` is called at the end of the apply window so the post-publish topology becomes visible exactly once, atomically. -- The watchdog force-closes any lease older than `ApplyMaxDuration`; a stuck publisher therefore cannot strand a node at `PrimaryMidApply`. +## Data flow -Because role transitions are **operator-driven** (write `RedundancyRole` in the Config DB + publish), the Backup never auto-promotes. An `IsolatedBackup` at 80 is the signal that the operator should intervene; auto-failover is intentionally out of scope for the non-transparent model (decision #154). +``` +Cluster topology event ──┐ +DB health probe ─────────┤ +OPC UA peer probe ───────┤ + ▼ + RedundancyStateActor (admin singleton) + │ debounce 250ms + ▼ + DPS topic "redundancy-state" + │ + ▼ + Driver nodes' OpcUaPublishActor + │ + ▼ + ServiceLevelCalculator → byte + │ + ▼ + OPC UA ServiceLevel Variable +``` -## Metrics +The admin singleton is the cluster's only `RedundancyStateActor`. If the admin leader fails over, the new admin node spins up its replacement, re-subscribes to cluster events, and publishes a fresh snapshot from the current `Cluster.State`. There is no DB-persisted state to recover. -`RedundancyMetrics` in `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/RedundancyMetrics.cs` registers the `ZB.MOM.WW.OtOpcUa.Redundancy` meter on the Admin process. Instruments: +## Configuration -| Name | Kind | Tags | Description | -|---|---|---|---| -| `otopcua.redundancy.role_transition` | Counter | `cluster.id`, `node.id`, `from_role`, `to_role` | Incremented every time `FleetStatusPoller` observes a `RedundancyRole` change on a `ClusterNode` row. | -| `otopcua.redundancy.primary_count` | ObservableGauge | `cluster.id` | Primary-role nodes per cluster — should be exactly 1 in nominal state. | -| `otopcua.redundancy.secondary_count` | ObservableGauge | `cluster.id` | Secondary-role nodes per cluster. | -| `otopcua.redundancy.stale_count` | ObservableGauge | `cluster.id` | Nodes whose `LastSeenAt` exceeded the stale threshold. | +Per-node identity comes from `appsettings.json` + the `OTOPCUA_ROLES` env var: -Admin `Program.cs` wires OpenTelemetry to the Prometheus exporter when `Metrics:Prometheus:Enabled=true` (default), exposing the meter under `/metrics`. The endpoint is intentionally unauthenticated — fleet conventions put it behind a reverse-proxy basic-auth gate if needed. +```json +{ + "Cluster": { + "Hostname": "0.0.0.0", + "Port": 4053, + "PublicHostname": "node-a.lan", + "SeedNodes": ["akka.tcp://otopcua@node-a.lan:4053"], + "Roles": ["admin", "driver"] + } +} +``` -## Real-time notifications (Admin UI) +``` +OTOPCUA_ROLES=admin,driver +``` -`FleetStatusPoller` in `src/Server/ZB.MOM.WW.OtOpcUa.Admin/Hubs/` polls the `ClusterNode` table, records role transitions, updates `RedundancyMetrics.SetClusterCounts`, and pushes a `RoleChanged` SignalR event onto `FleetStatusHub` when a transition is observed. `RedundancyTab.razor` subscribes with `_hub.On("RoleChanged", …)` so connected Admin sessions see role swaps the moment they happen. +Both nodes share the same `ConfigDb` connection string; `Cluster.PublicHostname` + `Roles` are what makes them distinct in cluster gossip. The first node bootstraps the cluster (its address goes in `SeedNodes`); the second node joins via the same `SeedNodes` list. -## Configuring a redundant pair +There is no longer a `Node:NodeId` setting, no `ClusterNode.RedundancyRole`, no `ServiceLevelBase`. NodeId is derived as `host:port` of the cluster `PublicHostname` (see `ClusterRoleInfo.LocalNode` for the formula). -Redundancy is configured **in the Config DB, not appsettings.json**. The fields that must differ between the two instances: +## Split-brain -| Field | Location | Instance 1 | Instance 2 | -|---|---|---|---| -| `NodeId` | `appsettings.json` `Node:NodeId` (bootstrap) | `node-a` | `node-b` | -| `ClusterNode.ApplicationUri` | Config DB | `urn:node-a:OtOpcUa` | `urn:node-b:OtOpcUa` | -| `ClusterNode.RedundancyRole` | Config DB | `Primary` | `Secondary` | -| `ClusterNode.ServiceLevelBase` | Config DB | typically 255 | typically 100 | +`akka.conf` configures Akka's split-brain resolver with `active-strategy = keep-oldest`, `stable-after = 15s`, and `failure-detector.threshold = 10.0`. Under a clean partition: the oldest member stays up + the smaller (or younger) side downs itself within ~15 seconds. The `RedundancyStateActor` on the surviving partition re-computes from the post-partition `Cluster.State`. -Shared between instances: `ClusterId`, Config DB connection string, published generation, cluster-level ACLs, UNS hierarchy, driver instances. - -Role swaps, stand-alone promotions, and base-level adjustments all happen through the Admin UI `RedundancyTab` — the operator edits the `ClusterNode` row in a draft generation and publishes. `RedundancyCoordinator.RefreshAsync` picks up the new topology without a process restart. +There is no operator-driven role swap during a partition. Failover is what the cluster does automatically. ## Client-side failover -The OtOpcUa Client CLI at `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md) for the command reference. +The OtOpcUa Client CLI at `src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md). ## Depth reference -For the full decision trail and implementation plan — topology invariants, peer-probe cadence, recovery-dwell policy, compliance-script guard against enum-value drift — see `docs/v2/plan.md` §Phase 6.3. +For the full design — message contracts, tiered calculator truth table, recovery semantics — see `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §6. diff --git a/docs/ServiceHosting.md b/docs/ServiceHosting.md index 2a28a11..df0ff98 100644 --- a/docs/ServiceHosting.md +++ b/docs/ServiceHosting.md @@ -1,62 +1,76 @@ -# Service Hosting +# Service Hosting (v2) ## Overview -A production OtOpcUa deployment runs **two or three processes**, each -with a distinct runtime and install surface: +A production OtOpcUa deployment runs **one binary per node**, plus the optional Wonderware historian sidecar: | Process | Project | Runtime | Platform | Responsibility | |---|---|---|---|---| -| **OtOpcUa Server** | `src/Server/ZB.MOM.WW.OtOpcUa.Server` | .NET 10 | x64 | Hosts the OPC UA endpoint; loads every driver in-process (Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS, OPC UA Client, Galaxy via mxaccessgw); exposes `/healthz`. | -| **OtOpcUa Admin** | `src/Server/ZB.MOM.WW.OtOpcUa.Admin` | .NET 10 (ASP.NET Core / Blazor Server) | x64 | Operator UI for Config DB editing + fleet status, SignalR hubs (`FleetStatusHub`, `AlertHub`), Prometheus `/metrics`. | -| **OtOpcUa Wonderware Historian** *(optional)* | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware` | .NET Framework 4.8 | x86 (32-bit) | Out-of-process sidecar exposing the Wonderware Historian SDK over a named pipe. Required only when `Historian:Wonderware:Enabled=true` in `appsettings.json`. | +| **OtOpcUa Host** | `src/Server/ZB.MOM.WW.OtOpcUa.Host` | .NET 10 | AnyCPU | Single fused binary. `OTOPCUA_ROLES` env decides what to mount: `admin` (Blazor + auth + control-plane singletons), `driver` (OPC UA endpoint + per-driver actors), or both. | +| **OtOpcUa Wonderware Historian** *(optional)* | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware` | .NET Framework 4.8 | x86 (32-bit) | Out-of-process sidecar exposing the Wonderware Historian SDK over a named pipe. Required only when `Historian:Wonderware:Enabled=true`. | -Galaxy access uses a separately-installed **mxaccessgw** running out -of a sibling repo (`c:\Users\dohertj2\Desktop\mxaccessgw\`) — see -`docs/v2/Galaxy.ParityRig.md` for setup. The mxaccessgw owns the -MXAccess COM bitness constraint (its worker is x86 net48); nothing -in the OtOpcUa repo carries that constraint anymore. PR 7.2 retired -the legacy in-process `Galaxy.Host` / `Galaxy.Proxy` / `Galaxy.Shared` -projects + the `OtOpcUaGalaxyHost` Windows service. +Galaxy access still uses the separately-installed **mxaccessgw** sidecar (see `docs/v2/Galaxy.ParityRig.md`); the gateway owns the MXAccess COM bitness constraint (its worker is x86 net48). Nothing in the OtOpcUa repo carries that constraint anymore. -## OtOpcUa Server +> **v2 change.** v1's separate `OtOpcUa.Server` + `OtOpcUa.Admin` Windows services merged into a single role-gated `OtOpcUa.Host` binary. Two installers became one (with a `-Roles` parameter). The whole DI graph is composed in `OtOpcUa.Host/Program.cs`; per-role wiring is conditional on the env var. -Hosted via `Microsoft.Extensions.Hosting` with `AddWindowsService` -(decision #30 — replaced TopShelf in v2). The host's `Build()` -returns immediately when launched interactively (e.g. `dotnet run`) -but blocks for SCM signals when running as a Windows service. +## Role gating -In-process drivers are registered at startup in `Program.cs`'s -`DriverFactoryRegistry` block; the `DriverInstance` rows in the -central Config DB select which driver factories materialise into -live `IDriver` instances. See `docs/v2/driver-specs.md` for the -per-driver `DriverConfig` JSON shapes. +`Program.cs` reads `OTOPCUA_ROLES`, parses it with `RoleParser`, and conditionally registers services: -## OtOpcUa Admin +| Role present | Wires | +|---|---| +| `admin` | `AddOtOpcUaAuth`, `AddAdminUI`, `AddSignalR`, `AddOtOpcUaAdminClients`, `MapOtOpcUaAuth`, `MapAdminUI`, `MapOtOpcUaHubs`, `WithOtOpcUaControlPlaneSingletons` (5 admin singletons via `Akka.Hosting`) | +| `driver` | `WithOtOpcUaRuntimeActors` (DriverHostActor + DbHealthProbeActor) — and the OPC UA endpoint on port 4840 | +| Either / both | `AddOtOpcUaConfigDb`, `AddOtOpcUaCluster`, `AddOtOpcUaHealth` (`/health/ready`, `/health/active`, `/healthz`) | -Same hosting model; runs the Blazor Server UI + SignalR hubs. -Reads from the same Config DB the Server writes to. +Single-node dev: `OTOPCUA_ROLES=admin,driver`. Production: typically two admin nodes (HA pair) + N driver nodes. + +## Akka cluster + +The host joins an Akka.NET cluster bound to the address in `appsettings.json::Cluster`: + +```json +{ + "Cluster": { + "Hostname": "0.0.0.0", + "Port": 4053, + "PublicHostname": "node-a.lan", + "SeedNodes": ["akka.tcp://otopcua@node-a.lan:4053"], + "Roles": ["admin", "driver"] + } +} +``` + +- `WithOtOpcUaClusterBootstrap` (in `OtOpcUa.Cluster`) loads the embedded HOCON (split-brain resolver, pinned dispatcher, failure detector tuning) and overlays remote endpoint + cluster options. +- All cluster singletons + per-node actors live on this single ActorSystem — there is no second Akka instance. + +See [Redundancy.md](Redundancy.md) for the role-leader + ServiceLevel story. + +## Health endpoints + +Both admin and driver nodes expose: + +| Path | Status meaning | +|---|---| +| `/healthz` | Process alive. | +| `/health/ready` | ConfigDb reachable + cluster member state is `Up`. | +| `/health/active` | Admin-role leader (the node Traefik or an HA LB should route traffic to). | + +Used by Traefik for the active-leader-only routing pattern (see [Task 63 traefik docs](v2/Architecture-v2.md) — TODO). ## OtOpcUa Wonderware Historian (optional) -When `Historian:Wonderware:Enabled=true`, the Server speaks to a -sidecar that wraps the Wonderware Historian SDK (which is .NET -Framework only). The pipe IPC contract is in -`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Contracts/` -and the sidecar's pipe handler lives at -`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Pipe/`. - -Install via the `-InstallWonderwareHistorian` switch on -`scripts/install/Install-Services.ps1`. +Unchanged from v1. Pipe IPC contract lives in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Contracts/`; sidecar pipe handler in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Pipe/`. Install via `scripts/install/Install-Services.ps1 -InstallWonderwareHistorian`. ## Install / Uninstall -- `scripts/install/Install-Services.ps1` — installs `OtOpcUa` and - optionally `OtOpcUaWonderwareHistorian`. -- `scripts/install/Uninstall-Services.ps1` — stops + removes both, - plus `OtOpcUaGalaxyHost` if a pre-7.2 rig still carries it. +- `scripts/install/Install-Services.ps1 -Roles admin,driver` — installs `OtOpcUaHost`. v2 rewrite tracked as plan Task 62. +- `scripts/install/Uninstall-Services.ps1` — stops + removes the host service (and the historian sidecar if installed). ## Logging -Serilog with rolling-daily file sinks. Each service writes to -`%ProgramData%\OtOpcUa\-*.log` plus stdout (NSSM-friendly). +Serilog with rolling-daily file sinks. Each host writes to `logs/otopcua-*.log` plus stdout (NSSM/systemd-friendly). Per-environment log level overrides go in `appsettings.{Environment}.json`. + +## Depth reference + +For the full host-architecture rationale (why fused vs. split, role-gating tradeoffs, multi-node deployment shapes), see `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §3-4. diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md index 1359be5..55a60ed 100644 --- a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md +++ b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md @@ -464,56 +464,173 @@ Commit: `feat(configdb): persist DataProtection keys in ConfigDb`. --- -### Task 14: EF migration — drop `ConfigGeneration` and `ClusterNode.RedundancyRole`, add new tables +### Tasks 14a-14f: Entity-model rewrite + V2HostingAlignment migration + +> **Plan rewrite, 2026-05-26**: the original single Task 14 (5-min EF migration) was +> under-scoped — it only listed the schema drops/adds without addressing the 13+ entities +> whose foreign keys + indexes are keyed on `GenerationId`. The design doc (§ live-edit +> model) requires removing `GenerationId` from `Equipment`, `Driver`, `DriverInstance`, +> `Namespace`, `UnsArea`, `UnsLine`, `Device`, `Tag`, `PollGroup`, `NodeAcl`, `Script`, +> `VirtualTag`, `ScriptedAlarm` and adding `RowVersion` columns for last-write-wins +> stale-write detection. That cascades into `GenerationApplier`/`GenerationDiff`/ +> `GenerationSealedCache` and the legacy Server/Admin CRUD services. Policy decision +> (recorded with the user): the legacy `OtOpcUa.Server` + `OtOpcUa.Admin` projects are +> allowed to fail-to-compile between Task 14c and Task 56 — only the new v2 projects need +> to stay green. + +#### Task 14a: Add `RowVersion` to live-edit entities + +**Classification:** standard +**Estimated implement time:** ~10 min +**Parallelizable with:** none (foundation for 14b) + +**Files:** every live-edit entity class — `Equipment`, `DriverInstance`, `Device`, `Tag`, +`PollGroup`, `Namespace`, `UnsArea`, `UnsLine`, `NodeAcl`, `Script`, `VirtualTag`, +`ScriptedAlarm`. Add `public byte[] RowVersion { get; set; } = Array.Empty();` and a +`e.Property(x => x.RowVersion).IsRowVersion();` mapping in `OtOpcUaConfigDbContext`. + +Commit: `feat(configdb): add RowVersion to live-edit entities for last-write-wins detection`. + +--- + +#### Task 14b: Decouple live-edit entities from `ConfigGeneration` **Classification:** high-risk +**Estimated implement time:** ~30 min +**Parallelizable with:** none + +Remove `GenerationId` property, `Generation` navigation property, and the +`HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId)` mapping from each +of the 13 live-edit entities listed above. Rewrite the `UX__Generation_LogicalId` +indexes to drop the `GenerationId` column (logical IDs become globally unique). Drop +`UX_*_Generation_*` filtered indexes where the filter referenced generation scope. + +Will break `OtOpcUa.Server` + `OtOpcUa.Admin` compilation — that is accepted (Task 56 +deletes them). + +Commit: `refactor(configdb): drop GenerationId FK from live-edit entities`. + +--- + +#### Task 14c: Mark `GenerationApplier` / `GenerationDiff` / `GenerationSealedCache` obsolete + +**Classification:** high-risk +**Estimated implement time:** ~20 min +**Parallelizable with:** none + +`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/` contains `GenerationApplier.cs`, +`GenerationDiff.cs`, `ApplyCallbacks.cs`, `ChangeKind.cs`, `IGenerationApplier.cs`. These +implement the v1 draft/publish lifecycle that v2 replaces with `AdminOperationsActor` + +`ConfigComposer`. + +Inventory callers via `grep -rln 'GenerationApplier\|GenerationDiff' src tests`. Either: +- Mark types `[Obsolete("Replaced by AdminOperationsActor in v2", error: true)]` so + surviving call sites become hard build errors (cleaner; surfaces the Server-breakage), +- Or delete the files and accept the Server-side build break. + +Sweep `GenerationSealedCache` similarly. Keep the LiteDb cache concept (it's repurposed +in Task 39 for stale-config fallback) but rename references to use `DeploymentArtifact`. + +Commit: `refactor(configdb): obsolete GenerationApplier/Diff/SealedCache (replaced by AdminOperationsActor)`. + +--- + +#### Task 14d: Drop `RedundancyRole` from `ClusterNode` + +**Classification:** standard **Estimated implement time:** ~5 min -**Parallelizable with:** none (depends on Tasks 10-13) +**Parallelizable with:** none + +Remove `ClusterNode.RedundancyRole` property + the +`e.Property(x => x.RedundancyRole).HasConversion()` mapping + the +`UX_ClusterNode_Primary_Per_Cluster` filtered unique index from +`OtOpcUaConfigDbContext.ConfigureClusterNode`. Akka cluster leader-of-driver-role becomes +the source of truth (Phase 5, Task 35). + +Commit: `refactor(configdb): drop ClusterNode.RedundancyRole (replaced by Akka leader)`. + +--- + +#### Task 14e: Delete `ConfigGeneration` + `ClusterNodeGenerationState` + +**Classification:** small +**Estimated implement time:** ~5 min +**Parallelizable with:** none (depends on 14b clearing the FKs) + +Delete `Entities/ConfigGeneration.cs` and `Entities/ClusterNodeGenerationState.cs`. Remove +the corresponding `DbSet<>` entries and `Configure*` methods from +`OtOpcUaConfigDbContext`. Drop `GenerationStatus` and `NodeApplyStatus` enums. + +Commit: `refactor(configdb): delete ConfigGeneration + ClusterNodeGenerationState`. + +--- + +#### Task 14f: Generate `V2HostingAlignment` EF migration + +**Classification:** high-risk +**Estimated implement time:** ~15 min +**Parallelizable with:** none (consolidates 14a-14e) **Files:** - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/_V2HostingAlignment.cs` - Create: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/_V2HostingAlignment.Designer.cs` -- Modify: `OtOpcUaConfigDbContext.cs` — remove `DbSet` and `DbSet`; remove `ClusterNode.RedundancyRole` property -- Delete: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs` -- Delete: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs` +- Modify: `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs` — update + the `expected` table list (remove ConfigGeneration + ClusterNodeGenerationState; add + Deployment + NodeDeploymentState + ConfigEdit + DataProtectionKeys). **Step 1: Generate migration** -Run: `dotnet ef migrations add V2HostingAlignment --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host` +```bash +dotnet ef migrations add V2HostingAlignment \ + --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration \ + --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host +``` If `dotnet-ef` isn't installed: `dotnet tool install --global dotnet-ef --version 10.0.7`. **Step 2: Audit the generated migration** — it should: -- `DropTable("ConfigGeneration")` -- `DropTable("ClusterNodeGenerationState")` +- `DropTable("ConfigGeneration")` and `DropTable("ClusterNodeGenerationState")` - `DropColumn("RedundancyRole", "ClusterNode")` -- `CreateTable("Deployment", ...)` -- `CreateTable("NodeDeploymentState", ...)` -- `CreateTable("ConfigEdit", ...)` -- `CreateTable("DataProtectionKeys", ...)` +- For each of the 13 live-edit tables: `DropForeignKey` on `GenerationId`, + `DropIndex` on `UX_*_Generation_LogicalId` (and any `UX_*_Generation_*`), `DropColumn` on + `GenerationId`, `AddColumn("RowVersion", "rowversion")`, `CreateIndex` on the new + globally-unique logical-id pattern. +- `CreateTable("Deployment", ...)`, `CreateTable("NodeDeploymentState", ...)`, + `CreateTable("ConfigEdit", ...)`, `CreateTable("DataProtectionKeys", ...)`. -If extra changes appear (e.g., column-type drift), reconcile by editing the entity classes — do not edit the migration directly. +If extra changes appear (e.g., column-type drift), reconcile by editing the entity classes +— do not edit the migration directly. -**Step 3: Verify on a scratch SQL Server** +**Step 3: Verify on a scratch SQL Server** (per CLAUDE.md, Docker is on the shared host +`10.100.0.35`, not local). ```bash -docker run --rm -d --name v2-migration-test -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Pass@word123" -p 14333:1433 mcr.microsoft.com/mssql/server:2022-latest +# from this Mac dev: +ssh dohertj2@10.100.0.35 'docker run --rm -d --name v2-migration-test \ + -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Pass@word123" \ + -p 14333:1433 mcr.microsoft.com/mssql/server:2022-latest' # Wait ~10s for SQL Server to start -ConnectionStrings__ConfigDb="Server=localhost,14333;Database=OtOpcUaV2Test;User Id=sa;Password=Pass@word123;TrustServerCertificate=true" \ - dotnet ef database update --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host +ConnectionStrings__ConfigDb="Server=10.100.0.35,14333;Database=OtOpcUaV2Test;User Id=sa;Password=Pass@word123;TrustServerCertificate=true" \ + dotnet ef database update \ + --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration \ + --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host +ssh dohertj2@10.100.0.35 'docker exec v2-migration-test /opt/mssql-tools/bin/sqlcmd \ + -S localhost -U sa -P Pass@word123 -d OtOpcUaV2Test \ + -Q "SELECT name FROM sys.tables ORDER BY name"' +ssh dohertj2@10.100.0.35 'docker stop v2-migration-test' ``` -Expected: completes without error. Verify with `docker exec v2-migration-test /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Pass@word123 -d OtOpcUaV2Test -Q "SELECT name FROM sys.tables ORDER BY name"`. +Expected: migration completes; sys.tables contains the 4 new tables and not the 2 dropped +ones; live-edit tables have `RowVersion` column. -**Step 4: Tear down** - -`docker stop v2-migration-test`. +**Step 4: Update `SchemaComplianceTests`** so its `expected` array matches the new schema. **Step 5: Commit** ```bash -git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ -git commit -m "feat(configdb): V2HostingAlignment migration — drop ConfigGeneration, add Deployment+NodeDeploymentState+ConfigEdit" +git add src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ \ + tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +git commit -m "feat(configdb): V2HostingAlignment migration — drop generation lifecycle, add deploy tables" ``` --- @@ -1888,7 +2005,12 @@ After Task 65: | 11 | NodeDeploymentState entity | standard | 5m | 10,12,13 | | 12 | ConfigEdit entity | small | 4m | 10,11,13 | | 13 | DataProtection keys | small | 3m | 10-12 | -| 14 | V2 migration | high-risk | 5m | — | +| 14a | RowVersion on live-edit entities | standard | 10m | — | +| 14b | Drop GenerationId FK from entities | high-risk | 30m | — | +| 14c | Obsolete GenerationApplier/Diff/Cache | high-risk | 20m | — | +| 14d | Drop ClusterNode.RedundancyRole | standard | 5m | — | +| 14e | Delete ConfigGeneration + ClusterNodeGenerationState | small | 5m | — | +| 14f | V2HostingAlignment migration (consolidator) | high-risk | 15m | — | | 15 | Migrate-To-V2.ps1 | standard | 5m | 16-18 | | 16 | Common types | standard | 5m | 17,18 | | 17 | Message contracts | standard | 5m | 16,18 | diff --git a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json index f5f6a1e..639022e 100644 --- a/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json +++ b/docs/plans/2026-05-26-akka-hosting-alignment-plan.md.tasks.json @@ -4,71 +4,98 @@ "designDoc": "docs/plans/2026-05-26-akka-hosting-alignment-design.md", "lastUpdated": "2026-05-26T00:00:00Z", "tasks": [ - {"id": 0, "subject": "Task 0: Create branch and central package management", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": []}, - {"id": 1, "subject": "Task 1: Create OtOpcUa.Commons project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [2,3,4,5,6,7,8], "blockedBy": [0]}, - {"id": 2, "subject": "Task 2: Create OtOpcUa.Cluster project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,3,4,5,6,7,8], "blockedBy": [0]}, - {"id": 3, "subject": "Task 3: Create OtOpcUa.Security project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,4,5,6,7,8], "blockedBy": [0]}, - {"id": 4, "subject": "Task 4: Create OtOpcUa.ControlPlane project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,5,6,7,8], "blockedBy": [0]}, - {"id": 5, "subject": "Task 5: Create OtOpcUa.Runtime project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,6,7,8], "blockedBy": [0]}, - {"id": 6, "subject": "Task 6: Create OtOpcUa.OpcUaServer project", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,7,8], "blockedBy": [0]}, - {"id": 7, "subject": "Task 7: Create OtOpcUa.AdminUI Razor class library", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,6,8], "blockedBy": [0]}, - {"id": 8, "subject": "Task 8: Create OtOpcUa.Host Web SDK project", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": [1,2,3,4,5,6,7], "blockedBy": [0]}, - {"id": 9, "subject": "Task 9: Build green smoke check", "status": "pending", "classification": "trivial", "estMinutes": 2, "parallelizableWith": [], "blockedBy": [1,2,3,4,5,6,7,8]}, - {"id": 10, "subject": "Task 10: Add Deployment entity", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [11,12,13], "blockedBy": [9]}, - {"id": 11, "subject": "Task 11: Add NodeDeploymentState entity", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [10,12,13], "blockedBy": [9]}, - {"id": 12, "subject": "Task 12: Add ConfigEdit audit entity", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [10,11,13], "blockedBy": [9]}, - {"id": 13, "subject": "Task 13: Add DataProtection keys table", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [10,11,12], "blockedBy": [9]}, - {"id": 14, "subject": "Task 14: EF migration V2HostingAlignment", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [10,11,12,13]}, - {"id": 15, "subject": "Task 15: Migrate-To-V2.ps1 idempotent script", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,17,18], "blockedBy": [14]}, - {"id": 16, "subject": "Task 16: Common types (CorrelationId, ExecutionId, NodeId, ...)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [17,18], "blockedBy": [9]}, - {"id": 17, "subject": "Task 17: Akka message contracts", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,18], "blockedBy": [16]}, - {"id": 18, "subject": "Task 18: Common interfaces", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [16,17], "blockedBy": [16]}, - {"id": 19, "subject": "Task 19: HOCON config", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [20,21,22], "blockedBy": [2]}, - {"id": 20, "subject": "Task 20: AkkaHostedService implementation", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,21,22], "blockedBy": [2,18]}, - {"id": 21, "subject": "Task 21: Role parser from OTOPCUA_ROLES env", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [19,20,22], "blockedBy": [2]}, - {"id": 22, "subject": "Task 22: ClusterRoleInfo implementation", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,20,21], "blockedBy": [18,20]}, - {"id": 23, "subject": "Task 23: Cluster test project + tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [19,20,21,22]}, - {"id": 24, "subject": "Task 24: Move LdapAuthService into OtOpcUa.Security", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [25], "blockedBy": [3]}, - {"id": 25, "subject": "Task 25: JwtTokenService", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [24], "blockedBy": [3]}, - {"id": 26, "subject": "Task 26: Cookie+JWT hybrid AddOtOpcUaAuth extension", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [27,28], "blockedBy": [13,24,25]}, - {"id": 27, "subject": "Task 27: /auth/login, /auth/ping, /auth/token endpoints", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [26,28], "blockedBy": [24,25]}, - {"id": 28, "subject": "Task 28: CookieAuthenticationStateProvider for Blazor", "status": "pending", "classification": "small", "estMinutes": 4, "parallelizableWith": [26,27], "blockedBy": [25]}, - {"id": 29, "subject": "Task 29: Security test project + tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [24,25,26,27,28]}, - {"id": 30, "subject": "Task 30: ConfigPublishCoordinator happy path", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [4,17,18,10,11]}, - {"id": 31, "subject": "Task 31: Coordinator timeout + failover recovery", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [30]}, - {"id": 32, "subject": "Task 32: AdminOperationsActor + StartDeployment", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,33,34,35], "blockedBy": [4,17,18,10,12]}, - {"id": 33, "subject": "Task 33: AuditWriterActor batched idempotent insert", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,34,35], "blockedBy": [4,17]}, - {"id": 34, "subject": "Task 34: FleetStatusBroadcaster", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,33,35], "blockedBy": [4,17]}, - {"id": 35, "subject": "Task 35: RedundancyStateActor + ServiceLevelCalculator", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [30,31,32,33,34], "blockedBy": [4,17,18]}, - {"id": 36, "subject": "Task 36: Singleton registration extension (admin role)", "status": "pending", "classification": "standard", "estMinutes": 4, "parallelizableWith": [], "blockedBy": [30,31,32,33,34,35]}, - {"id": 37, "subject": "Task 37: DriverHostActor scaffolding + PreStart recovery", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [5,17,18,11]}, - {"id": 38, "subject": "Task 38: DriverHostActor DispatchDeployment handler", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [37]}, - {"id": 39, "subject": "Task 39: DriverHostActor stale-config fallback", "status": "pending", "classification": "standard", "estMinutes": 4, "parallelizableWith": [41,42,43,44], "blockedBy": [38]}, - {"id": 40, "subject": "Task 40: Runtime test project bootstrap", "status": "pending", "classification": "small", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [37,38,39]}, - {"id": 41, "subject": "Task 41: DriverInstanceActor state machine", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [42,43,44], "blockedBy": [5,17,40]}, - {"id": 42, "subject": "Task 42: VirtualTagActor", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,43,44], "blockedBy": [5,17,40]}, - {"id": 43, "subject": "Task 43: ScriptedAlarmActor", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,42,44], "blockedBy": [5,17,40]}, - {"id": 44, "subject": "Task 44: OpcUaPublishActor on synchronized dispatcher", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43], "blockedBy": [5,6,17,19,40]}, - {"id": 45, "subject": "Task 45: HistorianAdapter + PeerOpcUaProbe + DbHealthProbe actors", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [37,40]}, - {"id": 46, "subject": "Task 46: Extract OpcUaApplicationHost + Phase7Composer", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [6]}, - {"id": 47, "subject": "Task 47: Phase7Composer purity + property tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [48,49,50,51,52], "blockedBy": [46]}, - {"id": 48, "subject": "Task 48: Move Blazor components into AdminUI library", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [47], "blockedBy": [7]}, - {"id": 49, "subject": "Task 49: Move SignalR hubs and rewire to FleetStatusBroadcaster", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [50,51,52], "blockedBy": [34,48]}, - {"id": 50, "subject": "Task 50: IAdminOperationsClient via ClusterSingletonProxy", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,51,52], "blockedBy": [18,32,48]}, - {"id": 51, "subject": "Task 51: Replace DriverDiagnosticsClient with IFleetDiagnosticsClient", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,52], "blockedBy": [18,48]}, - {"id": 52, "subject": "Task 52: Drift indicator + Deploy button UI", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,51], "blockedBy": [50,48]}, - {"id": 53, "subject": "Task 53: Host Program.cs role-gated startup", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [54,55], "blockedBy": [8,15,20,21,22,26,36,40,45,46,48,49]}, - {"id": 54, "subject": "Task 54: Health endpoints + appsettings layout", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,55], "blockedBy": [8,22]}, - {"id": 55, "subject": "Task 55: Mac dev mode + DEV-STUB drivers", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,54], "blockedBy": [41]}, - {"id": 56, "subject": "Task 56: Delete OtOpcUa.Server + OtOpcUa.Admin projects", "status": "pending", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [53,54,55]}, - {"id": 57, "subject": "Task 57: Build & test green check", "status": "pending", "classification": "trivial", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [56]}, - {"id": 58, "subject": "Task 58: 2-node integration test harness", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [57]}, - {"id": 59, "subject": "Task 59: Deploy + failover integration tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [60], "blockedBy": [58]}, + {"id": 0, "subject": "Task 0: Create branch and central package management", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [], "commit": "2b81147"}, + {"id": 1, "subject": "Task 1: Create OtOpcUa.Commons project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [2,3,4,5,6,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 2, "subject": "Task 2: Create OtOpcUa.Cluster project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,3,4,5,6,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 3, "subject": "Task 3: Create OtOpcUa.Security project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,4,5,6,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 4, "subject": "Task 4: Create OtOpcUa.ControlPlane project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,5,6,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 5, "subject": "Task 5: Create OtOpcUa.Runtime project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,6,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 6, "subject": "Task 6: Create OtOpcUa.OpcUaServer project", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,7,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 7, "subject": "Task 7: Create OtOpcUa.AdminUI Razor class library", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [1,2,3,4,5,6,8], "blockedBy": [0], "commit": "30a2104"}, + {"id": 8, "subject": "Task 8: Create OtOpcUa.Host Web SDK project", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [1,2,3,4,5,6,7], "blockedBy": [0], "commit": "30a2104"}, + {"id": 9, "subject": "Task 9: Build green smoke check", "status": "completed", "classification": "trivial", "estMinutes": 2, "parallelizableWith": [], "blockedBy": [1,2,3,4,5,6,7,8], "commit": "30a2104"}, + {"id": 10, "subject": "Task 10: Add Deployment entity", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [11,12,13], "blockedBy": [9], "commit": "8e2c4f2"}, + {"id": 11, "subject": "Task 11: Add NodeDeploymentState entity", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [10,12,13], "blockedBy": [9], "commit": "8e2c4f2"}, + {"id": 12, "subject": "Task 12: Add ConfigEdit audit entity", "status": "completed", "classification": "small", "estMinutes": 4, "parallelizableWith": [10,11,13], "blockedBy": [9], "commit": "8e2c4f2"}, + {"id": 13, "subject": "Task 13: Add DataProtection keys table", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [10,11,12], "blockedBy": [9], "commit": "8e2c4f2"}, + {"id": "14a", "subject": "Task 14a: Add RowVersion to live-edit entities", "status": "completed", "classification": "standard", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [13], "commit": "4bb4ad8"}, + {"id": "14b", "subject": "Task 14b: Decouple live-edit entities from ConfigGeneration", "status": "completed", "classification": "high-risk", "estMinutes": 30, "parallelizableWith": [], "blockedBy": ["14a"], "commit": "13d3aea"}, + {"id": "14c", "subject": "Task 14c: Obsolete GenerationApplier/Diff/SealedCache", "status": "completed", "classification": "high-risk", "estMinutes": 20, "parallelizableWith": [], "blockedBy": ["14b"], "commit": "1ddf8bb"}, + {"id": "14d", "subject": "Task 14d: Drop ClusterNode.RedundancyRole", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": ["14a","14b","14c"], "blockedBy": [13], "commit": "3c915e6"}, + {"id": "14e", "subject": "Task 14e: Delete ConfigGeneration + ClusterNodeGenerationState", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": ["14b","14c"], "commit": "e00f46d"}, + {"id": "14f", "subject": "Task 14f: V2HostingAlignment EF migration (consolidator)", "status": "completed", "classification": "high-risk", "estMinutes": 15, "parallelizableWith": [], "blockedBy": ["14a","14b","14c","14d","14e"], "commit": "605dbf3"}, + {"id": 15, "subject": "Task 15: Migrate-To-V2.ps1 idempotent script", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,17,18], "blockedBy": ["14f"], "commit": "c168c1c"}, + {"id": 16, "subject": "Task 16: Common types (CorrelationId, ExecutionId, NodeId, ...)", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [17,18], "blockedBy": [9], "commit": "fee4a8c"}, + {"id": 17, "subject": "Task 17: Akka message contracts", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [16,18], "blockedBy": [16], "commit": "5d3a5a4"}, + {"id": 18, "subject": "Task 18: Common interfaces", "status": "completed", "classification": "small", "estMinutes": 4, "parallelizableWith": [16,17], "blockedBy": [16], "commit": "136234e"}, + {"id": 19, "subject": "Task 19: HOCON config", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [20,21,22], "blockedBy": [2], "commit": "3d0f4dc"}, + {"id": 20, "subject": "Task 20: AkkaHostedService implementation", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,21,22], "blockedBy": [2,18], "commit": "f184f8e"}, + {"id": 21, "subject": "Task 21: Role parser from OTOPCUA_ROLES env", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [19,20,22], "blockedBy": [2], "commit": "dfb0636"}, + {"id": 22, "subject": "Task 22: ClusterRoleInfo implementation", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [19,20,21], "blockedBy": [18,20], "commit": "c217c49"}, + {"id": 23, "subject": "Task 23: Cluster test project + tests", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [19,20,21,22], "commit": "e0b6d56"}, + {"id": 24, "subject": "Task 24: Move LdapAuthService into OtOpcUa.Security", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [25], "blockedBy": [3], "commit": "567b8ca"}, + {"id": 25, "subject": "Task 25: JwtTokenService", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [24], "blockedBy": [3], "commit": "93316e3"}, + {"id": 26, "subject": "Task 26: Cookie+JWT hybrid AddOtOpcUaAuth extension", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [27,28], "blockedBy": [13,24,25], "commit": "207fc6a"}, + {"id": 27, "subject": "Task 27: /auth/login, /auth/ping, /auth/token endpoints", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [26,28], "blockedBy": [24,25], "commit": "8be84ba"}, + {"id": 28, "subject": "Task 28: CookieAuthenticationStateProvider for Blazor", "status": "completed", "classification": "small", "estMinutes": 4, "parallelizableWith": [26,27], "blockedBy": [25], "commit": "e38f22e"}, + {"id": 29, "subject": "Task 29: Security test project + tests", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [24,25,26,27,28], "commit": "38ea0c5"}, + {"id": 30, "subject": "Task 30: ConfigPublishCoordinator happy path", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [4,17,18,10,11], "commit": "62e12da"}, + {"id": 31, "subject": "Task 31: Coordinator timeout + failover recovery", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [32,33,34,35], "blockedBy": [30], "commit": "f193872"}, + {"id": 32, "subject": "Task 32: AdminOperationsActor + StartDeployment", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,33,34,35], "blockedBy": [4,17,18,10,12], "commit": "ef683f5"}, + {"id": 33, "subject": "Task 33: AuditWriterActor batched idempotent insert", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,34,35], "blockedBy": [4,17], "commit": "23f669c"}, + {"id": 34, "subject": "Task 34: FleetStatusBroadcaster", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [30,31,32,33,35], "blockedBy": [4,17], "commit": "dd122c4"}, + {"id": 35, "subject": "Task 35: RedundancyStateActor + ServiceLevelCalculator", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [30,31,32,33,34], "blockedBy": [4,17,18], "commit": "6b37f99"}, + {"id": 36, "subject": "Task 36: Singleton registration extension (admin role)", "status": "completed", "classification": "standard", "estMinutes": 4, "parallelizableWith": [], "blockedBy": [30,31,32,33,34,35], "commit": "52bf4b3"}, + {"id": 37, "subject": "Task 37: DriverHostActor scaffolding + PreStart recovery", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [5,17,18,11], "commit": "ed13013"}, + {"id": 38, "subject": "Task 38: DriverHostActor DispatchDeployment handler", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43,44], "blockedBy": [37], "commit": "ed13013"}, + {"id": 39, "subject": "Task 39: DriverHostActor stale-config fallback", "status": "completed", "classification": "standard", "estMinutes": 4, "parallelizableWith": [41,42,43,44], "blockedBy": [38], "commit": "ed13013"}, + {"id": 40, "subject": "Task 40: Runtime test project bootstrap", "status": "completed", "classification": "small", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [37,38,39], "commit": "ed13013"}, + {"id": 41, "subject": "Task 41: DriverInstanceActor state machine", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [42,43,44], "blockedBy": [5,17,40], "commit": "64c627f"}, + {"id": 42, "subject": "Task 42: VirtualTagActor", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,43,44], "blockedBy": [5,17,40], "commit": "39729bf"}, + {"id": 43, "subject": "Task 43: ScriptedAlarmActor", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [41,42,44], "blockedBy": [5,17,40], "commit": "95ef533"}, + {"id": 44, "subject": "Task 44: OpcUaPublishActor on synchronized dispatcher", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [41,42,43], "blockedBy": [5,6,17,19,40], "commit": "e115f13"}, + {"id": 45, "subject": "Task 45: HistorianAdapter + PeerOpcUaProbe + DbHealthProbe actors", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [37,40], "commit": "28639cb"}, + {"id": 46, "subject": "Task 46: Extract OpcUaApplicationHost + Phase7Composer", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [6], "commit": "2877a88"}, + {"id": 47, "subject": "Task 47: Phase7Composer purity + property tests", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [48,49,50,51,52], "blockedBy": [46], "commit": "b7c117a"}, + {"id": 48, "subject": "Task 48: Move Blazor components into AdminUI library", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [47], "blockedBy": [7], "commit": "1a067e6"}, + {"id": 49, "subject": "Task 49: Move SignalR hubs and rewire to FleetStatusBroadcaster", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [50,51,52], "blockedBy": [34,48], "commit": "26d8f2f"}, + {"id": 50, "subject": "Task 50: IAdminOperationsClient via ClusterSingletonProxy", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,51,52], "blockedBy": [18,32,48], "commit": "f022499"}, + {"id": 51, "subject": "Task 51: Replace DriverDiagnosticsClient with IFleetDiagnosticsClient", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,52], "blockedBy": [18,48], "commit": "b83f099"}, + {"id": 52, "subject": "Task 52: Drift indicator + Deploy button UI", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [49,50,51], "blockedBy": [50,48], "commit": "f167808"}, + {"id": 53, "subject": "Task 53: Host Program.cs role-gated startup", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [54,55], "blockedBy": [8,15,20,21,22,26,36,40,45,46,48,49], "commit": "e2b357f"}, + {"id": 54, "subject": "Task 54: Health endpoints + appsettings layout", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,55], "blockedBy": [8,22], "commit": "fa1d685"}, + {"id": 55, "subject": "Task 55: Mac dev mode + DEV-STUB drivers", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [53,54], "blockedBy": [41], "commit": "8b4de80"}, + {"id": 56, "subject": "Task 56: Delete OtOpcUa.Server + OtOpcUa.Admin projects", "status": "completed", "classification": "high-risk", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [53,54,55], "commit": "76310b8"}, + {"id": 57, "subject": "Task 57: Build & test green check", "status": "completed", "classification": "trivial", "estMinutes": 3, "parallelizableWith": [], "blockedBy": [56], "commit": "76310b8"}, + {"id": 58, "subject": "Task 58: 2-node integration test harness", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [57], "commit": "d6fac2d", "deviation": "Also consolidated to a single Akka.Hosting ActorSystem — Program.cs ran two competing ActorSystems (custom AkkaHostedService + Akka.Hosting AddAkka). Cluster singletons landed on the bare one. Fixed in this commit; AkkaHostedService.cs deleted. docker-compose.yml (SQL+OpenLDAP for real local runs) deferred — harness uses EF in-memory."}, + {"id": 59, "subject": "Task 59: Deploy + failover integration tests", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [60], "blockedBy": [58], "commit": "5cfbe8b", "deviation": "Happy-path + idempotency landed. Failover scenarios (kill-mid-apply, split-brain, restart-during-deploy) deferred as F22 — they need node-down/restart primitives on the harness. Two production bugs fixed in this commit: (1) coordinator missing DPS subscription for ACKs, (2) NodeId collision on shared loopback host."}, {"id": 60, "subject": "Task 60: OPC UA dual-endpoint + ServiceLevel tests", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [59], "blockedBy": [58]}, - {"id": 61, "subject": "Task 61: E2E test infrastructure + GitHub Actions CI", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [59,60]}, - {"id": 62, "subject": "Task 62: Rewrite Install-Services.ps1", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [63,64,65], "blockedBy": [53]}, - {"id": 63, "subject": "Task 63: Traefik config + docker-dev compose", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,64,65], "blockedBy": [53]}, - {"id": 64, "subject": "Task 64: Update existing docs (Redundancy, ServiceHosting, security)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,65], "blockedBy": [57]}, - {"id": 65, "subject": "Task 65: New v2 docs (Architecture-v2, Cluster, ControlPlane, Runtime)", "status": "pending", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,64], "blockedBy": [57]} + {"id": 61, "subject": "Task 61: E2E test infrastructure + GitHub Actions CI", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [59,60], "commit": "253fb60", "deviation": "CI workflow files landed but E2E test project (tests/Server/ZB.MOM.WW.OtOpcUa.E2ETests) deferred — it lands when F10/F11/F12 wire enough engine for an end-to-end round-trip to be meaningful. The E2E workflow runs against the docker-dev fleet but its --filter Category=E2E currently matches zero tests."}, + {"id": 62, "subject": "Task 62: Rewrite Install-Services.ps1", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [63,64,65], "blockedBy": [53], "commit": "e40615d"}, + {"id": 63, "subject": "Task 63: Traefik config + docker-dev compose", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,64,65], "blockedBy": [53], "commit": "7e3b56c", "deviation": "Untested on macOS (no local Docker). Compose file should work — exercise + adjust on first run against a real Docker host."}, + {"id": 64, "subject": "Task 64: Update existing docs (Redundancy, ServiceHosting, security)", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,65], "blockedBy": [57], "commit": "3c3fef9", "deviation": "Redundancy.md + ServiceHosting.md full rewrites. security.md v2 banner only — full per-section rewrite waits for F15 (Admin pages migration) since security.md references many pages that will move. README.md platform-overview updated."}, + {"id": 65, "subject": "Task 65: New v2 docs (Architecture-v2, Cluster, ControlPlane, Runtime)", "status": "completed", "classification": "standard", "estMinutes": 5, "parallelizableWith": [62,63,64], "blockedBy": [57], "commit": "1689901"}, + {"id": "F1", "subject": "Follow-up: AuthEndpoints integration tests against fused Host", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F2"], "blockedBy": [53], "commit": "463512d", "origin": "Deviation from Task 29 (commit 38ea0c5) — deferred until Task 53 wires AddOtOpcUaAuth/MapOtOpcUaAuth in Program. Add WebApplicationFactory tests for /auth/login (204/401/503), /auth/ping (401/200), /auth/token (200+JWT), /auth/logout (204+cookie clear) using a stub ILdapAuthService.", "deviation": "Used HostBuilder + TestServer directly (Security.Tests/AuthEndpointsIntegrationTests) instead of WebApplicationFactory — Host needs Akka cluster bootstrap that's out of scope for this contract test. Cluster-mode auth coverage belongs in Task 58."}, + {"id": "F2", "subject": "Follow-up: Replace JwtBearer BuildServiceProvider antipattern with IPostConfigureOptions", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F1"], "blockedBy": [], "commit": "45a8c79", "origin": "Deviation from Task 26 (commit 207fc6a) — AddOtOpcUaAuth uses services.BuildServiceProvider().CreateScope() inside .AddJwtBearer lambda (ASP0000). Refactor to IPostConfigureOptions so validation parameters resolve lazily from the real request provider."}, + {"id": "F3", "subject": "Follow-up: Add EventId unique column to ConfigAuditLog for cross-restart audit idempotency", "status": "completed", "classification": "small", "estMinutes": 15, "parallelizableWith": ["F4"], "blockedBy": [], "commit": "f57f61d", "origin": "Deviation from Task 33 — AuditWriterActor only dedups in-buffer; ConfigAuditLog lacks EventId column so a duplicate AuditEvent that arrives after a flush becomes a duplicate row. Add nullable EventId Guid + filtered unique index, migration, and refactor AuditWriterActor.WrapDetails away."}, + {"id": "F4", "subject": "Follow-up: Harden AuditWriterActor.WrapDetails JSON synthesis with System.Text.Json", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "commit": "f57f61d", "deviation": "Moot — F3 deleted WrapDetails entirely (EventId/CorrelationId now live in dedicated columns).", "origin": "Self-review of Task 33 — WrapDetails uses string concat; malformed caller DetailsJson would produce invalid JSON and trip the CK_ConfigAuditLog_DetailsJson_IsJson constraint, killing the entire flush batch. Discard this task if F3 lands first (F3 removes WrapDetails entirely)."}, + {"id": "F5", "subject": "Follow-up: ConfigPublishCoordinator multi-node happy-path test", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "5cfbe8b", "deviation": "Delivered by Task 59 — DeployHappyPathTests.StartDeployment_seals_after_both_nodes_apply exercises the exact 'dispatch to N driver nodes, all ack, seals' flow via the real 2-node TwoNodeClusterHarness rather than a multi-system TestKit. Cleaner because it tests the production code path end-to-end.", "origin": "Self-review of Task 30 — single-ActorSystem TestKit can't simulate the plan's 'dispatch to N driver nodes, all ack, seals' happy path because DiscoverDriverNodes() needs real cluster membership. Add a multi-system test (two ActorSystems joined into one cluster, driver-role on the second)."}, + {"id": "F6", "subject": "Follow-up: RedundancyStateActor publisher abstraction so tests don't need DPS bootstrap", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "commit": "dfc143c", "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action broadcast so tests can replace it with a probe; un-skip both tests."}, + {"id": "F7", "subject": "Follow-up: DriverInstanceActor full engine wiring (subscriptions, writes, ApplyDelta diff)", "status": "completed", "classification": "standard", "estMinutes": 45, "parallelizableWith": [], "blockedBy": [44], "origin": "Self-review of Task 41 — subscription publishing, ApplyDelta diffing, bad-quality-on-disconnect, write path, and supervisor backoff are stubbed. Wire after OpcUaPublishActor lands.", "shipped": "All three pieces landed: (1) spawn lifecycle in DriverHostActor (DriverSpawnPlanner + IDriverFactory seam) — da14149, (2) ISubscribable wiring + OPC UA status-code → OpcUaQuality severity-bit mapping + DetachSubscription on disconnect/PostStop, (3) IWritable.WriteAsync write path with 5s timeout, status-code bubble-up, and AttributeValuePublished published to parent on every OnDataChange — both shipped in the F7-residual batch. Host DI binding (DriverFactoryBootstrap registers AbCip/AbLegacy/FOCAS/Galaxy/Modbus/S7/TwinCAT factories) lives in src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/."}, + {"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "partial", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers.", "shipped": "(1) IVirtualTagEvaluator seam + NullVirtualTagEvaluator default. VirtualTagActor calls evaluator on DependencyValueChanged, dedupes unchanged results, emits EvaluationResult to parent, publishes Warning ScriptLogEntry on failure. (2) DependencyMuxActor in Runtime fans out DriverInstanceActor.AttributeValuePublished from DriverHostActor through to interested VirtualTagActor subscribers. VirtualTagActor takes dependencyRefs + mux ActorRef in Props, registers interest in PreStart, unregisters in PostStop. WithOtOpcUaRuntimeActors spawns the mux + threads it into DriverHostActor. Production binding to Core.VirtualTags.VirtualTagEngine (expression compile + dep extraction) still TODO — split as F8b."}, + {"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "partial", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 43 — AlarmConditionService not called; PreRestart persistence to ScriptedAlarmState DB not wired; HistorianAdapter rows not emitted.", "shipped": "(1) IScriptedAlarmEvaluator seam + NullScriptedAlarmEvaluator default. ScriptedAlarmActor takes AlarmConfig (id/name/path/severity/predicate), evaluates on DependencyValueChanged, publishes AlarmTransitionEvent + ScriptLogEntry on every transition. (2) IAlarmActorStateStore seam in Commons.Engines + NullAlarmActorStateStore default + EfAlarmActorStateStore production adapter over the ScriptedAlarmState entity. ScriptedAlarmActor PreStart loads + restores; every Transition fires a fire-and-forget save with lastAckUser. Predicate binding to Core.ScriptedAlarms.ScriptedAlarmEngine still TODO — split as F9b."}, + {"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "partial", "classification": "high-risk", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [47], "origin": "Self-review of Task 44 — SDK calls stubbed; counters only. Wire after Phase 7 OpcUaServer extraction.", "shipped": "(1) IOpcUaAddressSpaceSink + IServiceLevelPublisher seams in Commons.OpcUa with Null* defaults. OpcUaPublishActor routes through the sink, dedupes ServiceLevelChanged, subscribes to redundancy-state DPS topic, maps redundancy snapshot to a coarse ServiceLevel (Primary+leader=240, Primary=200, Secondary=100, Detached=0). (2) OtOpcUaNodeManager (CustomNodeManager2) + OtOpcUaSdkServer (StandardServer subclass) + SdkAddressSpaceSink in OpcUaServer — lazy variable creation on first WriteValue, WriteAlarmState shape, RebuildAddressSpace tear-down. Variable updates propagate via ClearChangeMasks so subscribed OPC UA clients see them. Tests boot a real StandardServer + verify sink writes show up in the manager. Production wiring through OpcUaApplicationHost.StartAsync (default server = OtOpcUaSdkServer) + IServiceLevelPublisher SDK binding + #109 OpcUaPublishActor→Phase7Applier integration are the remaining pieces."}, + {"id": "F11", "subject": "Follow-up: HistorianAdapterActor named-pipe IPC + SqliteStoreAndForwardSink wiring", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "6861381", "deviationNotes": "Reshaped HistorianAdapterActor around the existing IAlarmHistorianSink abstraction (alarm-event shape, not the original tag-history-row stub). Defaults to NullAlarmHistorianSink; production deployments wire SqliteStoreAndForwardSink + WonderwareHistorianClient via AddOtOpcUaRuntime overrides. Actor now exposes GetStatus returning HistorianSinkStatus for diagnostics. Named-pipe transport implementation lives unchanged in src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs — the actor is intentionally just a fire-and-forget bridge.", "origin": "Self-review of Task 45 — stub buffers in-memory; named-pipe + SQLite store-and-forward not wired."}, + {"id": "F12", "subject": "Follow-up: PeerOpcUaProbeActor real opc.tcp ping (replace Ok=true stub)", "status": "completed", "classification": "small", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "b06e3ae", "deviation": "TCP-connect probe rather than full OPC UA Hello/Acknowledge handshake. Enough for the redundancy calc; deeper liveness signals can layer on later without changing the actor's contract.", "origin": "Self-review of Task 45 — RunProbe always returns Ok=true; replace with OPC UA Client connect."}, + {"id": "F13", "subject": "Follow-up: Full OpcUaApplicationHost extraction (security/alarms/history/observability)", "status": "partial", "classification": "high-risk", "estMinutes": 120, "parallelizableWith": [], "blockedBy": [], "commit": "36c4751-partial", "deviationNotes": "F13a (cert auto-creation) shipped in 36c4751. Remaining: endpoint-security wiring (SecurityProfileResolver into ServerConfiguration.SecurityPolicies), LDAP user-token validator (the OPC UA UserNameToken path; HTTP-layer LDAP auth is separate and already in OtOpcUa.Security), scripted-alarm node manager creation, history backend wiring, observability hooks (OpenTelemetry metrics + traces). These are gated by F10's OpcUaPublishActor SDK integration — until F10 lands, nothing instantiates OpcUaApplicationHost so the missing wiring is dead weight.", "origin": "Self-review of Task 46 — facade only boots ApplicationInstance + StandardServer. Legacy 391-line file pulls Server.Security/Alarms/History/Observability. Pull those into thin OpcUaServer interfaces."}, + {"id": "F14", "subject": "Follow-up: Migrate side-effecting Phase7Composer (EquipmentNodeWalker, trace logs, node cache)", "status": "partial", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 47 — pure version covers the projection. Walker + alarm sink registration + cache mutation stay in legacy until split into Phase7Applier.", "shipped": "Phase7Plan + Phase7Planner.Compute (pure diff over EquipmentNodes/DriverInstancePlans/ScriptedAlarmPlans by stable id, with Added/Removed/Changed lists). Phase7Applier consumes plan + IOpcUaAddressSpaceSink: drives RebuildAddressSpace on Equipment/Alarm topology change, writes inactive AlarmState for removed nodes, catches + logs sink faults. Driver-only changes correctly skip the rebuild (DriverHostActor's spawn-plan in Runtime handles those). Walker integration with the real SDK NodeManager is the remaining piece — split as F14b (consumes the existing EquipmentNodeWalker once F10b lands an SDK builder)."}, + {"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "completed", "classification": "high-risk", "estMinutes": 180, "commit": "Phase A-D (read views) + F15.2 batches 1-4 (live-edit CRUD) + F15.3 (live alerts/script-log/CSV import/Monaco)", "deviationNotes": "All 4 phases of read-only views shipped: Phase A (shell/auth/fleet/hosts), B (cluster CRUD + Overview/Redundancy), C (Equipment/UNS/Namespaces/Drivers/Tags/ACLs), D (Audit/VirtualTags/ScriptedAlarms/Scripts/RoleGrants/Certificates/Reservations/AlarmsHistorian). Per Q1–Q5 of docs/v2/AdminUI-rebuild-plan.md: typed driver editors deferred, top-level VirtualTags/ScriptedAlarms kept (Q2 reversed for cross-cluster discoverability), routes-not-tabs adopted, fleet-wide LDAP→role map only, generic login errors. Live-edit forms (F15.2) and ScriptLog page (depends on F16 ScriptLogHub) are explicit follow-ups.", "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 48 — only MapAdminUI scaffold + 1 new page (Deployments). 47 pages stay in legacy Admin (accepted-broken) until Task 56."}, + {"id": "F16", "subject": "Follow-up: Bridge FleetStatusBroadcaster → SignalR hubs (FleetStatusHub / AlertHub / ScriptLogHub)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "f18c285", "deviation": "FleetStatusHub bridge landed. AlertHub + ScriptLogHub deferred — they need upstream message contracts that aren't defined yet (alerts emerge from F9 ScriptedAlarmActor, script logs from F8 VirtualTagActor).", "origin": "Self-review of Task 49 — hubs are passive Hub subclasses; the bridge from FleetStatusBroadcaster.broadcast → IHubContext is not wired."}, + {"id": "F17", "subject": "Follow-up: FleetDiagnosticsClient real Akka ActorSelection round-trip (GetDiagnosticsRequest)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "8f32b89", "origin": "Self-review of Task 51 — client returns an empty snapshot stub. Add GetDiagnosticsRequest contract + DriverHostActor handler + real Ask/Reply."}, + {"id": "F18", "subject": "Follow-up: Thread HttpContext.User.Identity.Name into Deployments page (createdBy)", "status": "completed", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "commit": "b266f63", "origin": "Self-review of Task 52 — Deployments.razor hardcodes createdBy=\"(current user)\"; needs @inject AuthenticationStateProvider."}, + {"id": "F19", "subject": "Follow-up: RuntimeStartup extension for driver-role node-actor spawn", "status": "completed", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "commit": "09d6676", "origin": "Self-review of Task 53 — only admin-role singletons are wired via WithOtOpcUaControlPlaneSingletons. Driver-role nodes need a parallel WithOtOpcUaRuntimeActors that spawns DriverHostActor."}, + {"id": "F20", "subject": "Follow-up: Wire DriverInstanceActor.ShouldStub() into DriverHostActor child spawn", "status": "completed", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F7"], "blockedBy": [], "origin": "Self-review of Task 55 — ShouldStub helper exists but nothing calls it. Folds into F7 when DriverHostActor learns to spawn DriverInstanceActor children.", "shipped": "DriverHostActor.SpawnChild now calls DriverInstanceActor.ShouldStub(type, _localRoles) and routes Windows-only driver types to the stub path on non-Windows / dev-role hosts. Verified by DriverHostActorReconcileTests.Galaxy_on_non_windows_is_stubbed_by_ShouldStub_check."}, + {"id": "F21", "subject": "Follow-up: docker-compose.yml for Host.IntegrationTests (real SQL Server + OpenLDAP)", "status": "completed", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "commit": "b0a2bb0", "deviationNotes": "Stack shipped (SQL on 14331, OpenLDAP on 3894). HarnessMode reads OTOPCUA_HARNESS_USE_SQL=1 / USE_LDAP=1 from env; SQL mode uses per-harness unique DB via EnsureCreated. Compose itself not local-validated — DESKTOP-6JL3KKO has no Docker per CLAUDE.md; CI on Linux will exercise the real path. The xunit test-trait split was punted — env vars are simpler and cover the same use case (one suite, two modes, no test-class duplication).", "origin": "Deviation from Task 58 — TwoNodeClusterHarness uses EF InMemoryDatabase + StubLdapAuthService. For Mac-friendly local runs against real SQL constraints + LDAP, add tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/docker-compose.yml (SQL Server + OpenLDAP), wire EF SqlServer provider behind an env var (OTOPCUA_HARNESS_USE_SQL=1), and add a test trait so CI can run both modes."}, + {"id": "F22", "subject": "Follow-up: failover scenario integration tests (kill-mid-apply, split-brain, restart-during-deploy)", "status": "completed", "classification": "standard", "estMinutes": 60, "parallelizableWith": [], "blockedBy": [], "commit": "cd5540c", "deviationNotes": "Shipped 3 scenarios on the existing 2-node harness: stop-shrinks, restart-rejoins-same-port, deploy-with-one-node-down. Split-brain via simulated partition deferred — Akka.Hosting + xunit don't expose transport-level interference cleanly. The graceful-shutdown + rejoin path is what production actually exercises; ungraceful kill-mid-apply non-deterministic under SBR's 15s stable-after.", "origin": "Deviation from Task 59 — happy-path + idempotency landed but design §8 cases 3-7 need controlled node-down primitives on TwoNodeClusterHarness (StopNodeAsync, RestartNodeAsync, PartitionBetweenAsync). Add those + 5 scenario tests."} ] } diff --git a/docs/security.md b/docs/security.md index e8d4bb3..ab2a450 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,5 +1,19 @@ # Security +> **v2 status (2026-05-26).** The four security concerns below are unchanged in v2. +> Paths + project names moved: `OtOpcUa.Server/Security/` → `OtOpcUa.Security/` +> (`Ldap/`, `Jwt/`, `Endpoints/AuthEndpoints.cs`), `OtOpcUa.Admin` is gone (its +> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies +> register in `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth` rather than in a +> separate Admin process. The v2 `Security:Jwt` section adds JWT bearer auth +> alongside the existing cookie scheme (`AddJwtBearer` wired via +> `IPostConfigureOptions` in `OtOpcUa.Security`). DataProtection +> keys persist to the shared `ConfigDb.DataProtectionKeys` table so cookies +> survive failover between admin-role nodes. +> +> See `docs/plans/2026-05-26-akka-hosting-alignment-design.md` §5 for the v2 +> auth + DataProtection rationale. + OtOpcUa has four independent security concerns. This document covers all four: 1. **Transport security** — OPC UA secure channel (signing, encryption, X.509 trust). diff --git a/docs/v2/AdminUI-rebuild-plan.md b/docs/v2/AdminUI-rebuild-plan.md new file mode 100644 index 0000000..b5236ed --- /dev/null +++ b/docs/v2/AdminUI-rebuild-plan.md @@ -0,0 +1,265 @@ +# Admin UI rebuild plan (F15) + +**Status:** UX kickoff — proposals to react to before any per-page rebuild starts. +**Last updated:** 2026-05-26 on `v2-akka-fuse`. + +## Why this isn't a straight port + +The v1 Admin UI was built around `ConfigGeneration` draft → publish: +operators edited a **draft** generation, the system computed a **diff** against the +last published one, and a manual **Publish** sealed it. Six full pages +(`DraftEditor`, `DiffViewer`, `DiffSection`, `Generations`, plus the per-tab +"viewing draft N" header) lived to make this workflow legible. + +v2 replaces that with **live-edit + snapshot-deploy** (decisions #14a–#14e on this +branch). Edits write directly to live tables guarded by `RowVersion` +concurrency; deploying is a single click that snapshots the current live state +and dispatches it via Akka. Drift between "current live" and "last sealed +deployment" surfaces as a one-line indicator on the +[Deployments](../../src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor) +page. + +That collapses **six pages → zero** before we ship a line of new Razor. The +remaining ~41 legacy pages map to ~30 v2 pages once redundant fleet-wide views +fold into their cluster-tab equivalents. + +## Inventory: 47 legacy pages → v2 disposition + +Source: `git show 76310b8^ -- 'src/Server/ZB.MOM.WW.OtOpcUa.Admin/**/*.razor'`. + +### Site shell (5 files) — port + +| Legacy | v2 status | Notes | +|---|---|---| +| `App.razor`, `Routes.razor`, `_Imports.razor` | Port | Boilerplate; minor render-mode tweaks | +| `Layout/MainLayout.razor` | ✅ Already in v2 | Done in Task 48 | +| `Components/Pages/Login.razor`, `Account.razor` | Port | Auth endpoints changed (cookie+JWT hybrid, Task 26); login form posts to `/auth/login` now | + +### Shared widgets (5 files) — port + +| Legacy | v2 status | +|---|---| +| `StatusBadge.razor` | ✅ Already in v2 | +| `LoadingSpinner.razor` | ✅ Already in v2 | +| `ToastNotification.razor` | ✅ Already in v2 | +| `ClusterAuthorizeView.razor`, `RedirectToLogin.razor` | Port — adjust for v2 `IUserAuthenticator` | + +### Fleet (1 file) — reshape + +| Legacy | v2 strategy | +|---|---| +| `Fleet.razor` | **Reshape.** Drop the v1 "poller reads central DB" data source. v2 reads `NodeDeploymentState` (Applied / Failed / Stale per node) + subscribes to `FleetStatusHub` for live `ServiceLevel` updates (already wired in F16) + queries `IFleetDiagnosticsClient.GetDiagnostics` (F17) for per-node driver health. Single page, similar shape to v1. | + +### Cluster CRUD (3 files) — port + +| Legacy | v2 strategy | +|---|---| +| `ClustersList.razor` | Port | +| `NewCluster.razor` | Port | +| `ClusterDetail.razor` | **Port — drop draft/publish chrome.** No "New draft" button; no "current published" sidebar. Replace with "Last deployed" badge + a "Deploy" button (already a SignalR-aware widget on the Deployments page; this becomes a cluster-scoped variant). | + +### Draft/publish workflow (4 files) — **drop entirely** + +| Legacy | v2 strategy | +|---|---| +| `DraftEditor.razor` | **Drop.** No drafts in v2. | +| `DiffViewer.razor` | **Drop.** Drift indicator replaces it on Deployments page. | +| `DiffSection.razor` | **Drop.** | +| `Generations.razor` | **Drop — replaced by `Deployments.razor`** (already shipped in v2 ahead of F15). | + +### Cluster tabs (11 files) — port as live-edit forms + +Each becomes a live-edit surface: load the entity, bind to a form, save with +`RowVersion` concurrency check (409 on conflict → toast + reload). No "viewing +draft N" header; no per-tab snapshot view. + +| Legacy tab | v2 strategy | +|---|---| +| `EquipmentTab.razor` | Port — UNS path tree picker stays | +| `UnsTab.razor` | Port — same | +| `NamespacesTab.razor` | Port | +| `DriversTab.razor` | Port — **driver-type-specific editors are a separate question (see below)** | +| `TagsTab.razor` | Port | +| `AclsTab.razor` | Port — wire to v2 LDAP group → role mapping (see RoleGrants question) | +| `RedundancyTab.razor` | Port — surface v2 `ServiceLevel` calc (Task 35) instead of v1 redundancy state machine | +| `ScriptedAlarmsTab.razor` | Port | +| `ScriptsTab.razor` | Port | +| `VirtualTagsTab.razor` | Port | +| `AuditTab.razor` | Port — wire to v2 `ConfigAuditLog` (post-F3 schema: `EventId`, `CorrelationId` columns) | + +### Cluster-scoped editors (3 files) — port as reusable inputs + +| Legacy | v2 strategy | +|---|---| +| `IdentificationFields.razor` | Port | +| `ImportEquipment.razor` | Port | +| `ScriptEditor.razor` | Port | + +### Cross-cluster pages (8 files) — mixed + +| Legacy | v2 strategy | +|---|---| +| `Hosts.razor` | Port — reshape to "Akka cluster members" (showing `host:port` NodeIds, roles, redundancy state) | +| `Certificates.razor` | Port — F13a's `PkiStoreRoot` becomes the data source | +| `Reservations.razor` | Port | +| `RoleGrants.razor` | **Reshape.** v1 was cluster-scoped role grants; v2 uses LDAP group → role mapping (see Q4 below) | +| `AlarmsHistorian.razor` | Port — wire to F11's `HistorianAdapterActor.GetStatus` (queue depth + drain state) | +| `ScriptLog.razor` | Port — needs SignalR hub bridge (F16 deferred ScriptLogHub) | +| `ScriptedAlarms.razor` (top-level) | **Possibly drop** (see Q2 below) | +| `VirtualTags.razor` (top-level) | **Possibly drop** (see Q2 below) | + +### Driver-typed editors (5 files) — sequencing decision needed + +| Legacy | v2 strategy | +|---|---| +| `Drivers/FocasDetail.razor` | Defer — JSON editor in `DriversTab` covers the same config initially | +| `Modbus/ModbusOptionsEditor.razor` | Same | +| `Modbus/ModbusAddressEditor.razor` | Same | +| `Modbus/ModbusAddressPreview.razor` | Same | +| `Modbus/ModbusDiagnostics.razor` | Port — separate from the config editor, this is operational telemetry | + +### Account (1 file) — port + +| Legacy | v2 strategy | +|---|---| +| `Account.razor` | Port — minor reshape for JWT (token expiry UI, refresh button) | + +## Summary by disposition + +| Disposition | Count | +|---|---| +| Already in v2 | 5 | +| Port as-is | 22 | +| Port + reshape | 7 | +| **Drop (replaced by live-edit / Deployments page)** | **5** | +| Drop (redundant with cluster tab) | 2 (pending Q2) | +| Defer (driver-typed editors) | 4 | +| **Total active rebuild** | ~30 pages | + +## Open design questions + +These need answers before per-page sequencing starts. They drive how many +phases the rebuild takes and what gets cut. + +### Q1 — Driver-typed editors: ship now or defer? + +**Context.** v1 had typed editors for Modbus + FOCAS driver config. They sat +behind a generic JSON editor for the other six driver types. The typed editors +caught operator typos that the JSON editor missed (port ranges, slave-ID +collisions, address-map overlaps). + +**Options.** +- **Defer all typed editors.** Ship `DriversTab` with a JSON editor first; add + typed editors per-driver as field requests come in. Saves ~1 day on F15. +- **Port the existing two.** Modbus + FOCAS were already validated against + field use. The other six driver types stay JSON-only. +- **Ship all eight typed editors.** Most work, best UX. ~3 extra days on F15. + +**Recommendation:** Defer. The OPC UA dual-endpoint tests + driver +engine wiring (F7-F10) are higher-leverage and need attention first. + +### Q2 — Top-level `ScriptedAlarms.razor` and `VirtualTags.razor`: keep or drop? + +**Context.** In v1, these were fleet-wide views of every scripted alarm and +virtual tag across every cluster. The cluster tabs let you edit them; the +top-level pages let you find them across clusters. + +**Options.** +- **Drop.** Fleet-wide view is rare; cluster scope covers 95% of use. +- **Keep as read-only.** Cross-cluster search + drill-down to the per-cluster tab. + +**Recommendation:** Drop, but expose a global search on the top nav that +matches cluster + alarm/tag names if operators ask. + +### Q3 — ClusterDetail: 10 tabs or split routes? + +**Context.** v1 had 10 nav-tabs inside `ClusterDetail.razor`. Some are very +heavy (Tags can be 10k rows; AuditTab streams). All 10 share render state. + +**Options.** +- **Keep tabs.** Familiar; one URL per cluster. +- **Split into routes.** `/clusters/{id}/equipment`, `/clusters/{id}/tags`, + etc. Better deep-linking, better load (one tab's data per page), easier auth + scoping. + +**Recommendation:** Split into routes. The v1 monolith was already groaning +under the live-update SignalR fan-in; routes let each surface manage its own +subscription lifecycle. + +### Q4 — RoleGrants: cluster-scoped table or LDAP group → role map? + +**Context.** v1 had a per-cluster `RoleGrants` table where you mapped users to +cluster-scoped roles (ClusterAdmin, ClusterOperator, etc.). v2 introduced +LDAP-driven auth: LDAP group membership maps to OPC UA permissions +(`ReadOnly`, `WriteOperate`, `WriteTune`, `WriteConfigure`, `AlarmAck`) +fleet-wide. + +**Options.** +- **Keep v1 model.** Cluster-scoped grants survive; LDAP just provides the + username. +- **Replace with fleet-wide LDAP-group → role mapping.** v2's `LdapOptions` + already has a `GroupToRole` dictionary; surface that in a single fleet-level + page. +- **Both.** LDAP map for fleet-wide defaults; per-cluster overrides for + scoping. + +**Recommendation:** Fleet-wide LDAP-group → role map only. Per-cluster scoping +adds combinatorial complexity that v2's redundancy model doesn't need +(every driver-role node runs every driver in the fleet). + +### Q5 — Login UI: backed by `/auth/login` (cookie+JWT hybrid) — what about LDAP error UX? + +**Context.** v2's `/auth/login` does an LDAP bind. Failures come back as +specific reasons (invalid creds vs. service-account misconfig vs. server +unreachable). The default behavior is to lump them all into "Login failed." + +**Options.** +- **Generic "Login failed."** Safer; doesn't leak whether the username exists. +- **Specific error categories.** Helps operators diagnose deploy issues. + +**Recommendation:** Generic for production deployments, specific when +`Authentication:Ldap:AllowInsecureLdap=true` (dev mode signal). + +## Proposed sequencing (4 phases) + +Each phase is independently mergeable. The branch ships when Phase A is in; +Phases B–D can follow as smaller PRs. + +### Phase A — Shell + auth + fleet (minimum-viable Admin) +~½–1 day. Ships a working admin surface with no config editing. +- Port `App.razor`, `Routes.razor`, `_Imports.razor` +- Port `Login.razor` (post Q5) +- Port `Account.razor` +- Reshape `Fleet.razor` against v2 data sources +- Port `Hosts.razor` reshape + +### Phase B — Cluster CRUD + Overview/Redundancy tabs +~1 day. Adds cluster browse + readonly redundancy view. +- Port `ClustersList`, `NewCluster`, `ClusterDetail` (Overview tab only) +- Port `RedundancyTab` (read-only — surfaces v2 `ServiceLevel`) +- Split into routes if Q3 = split + +### Phase C — Config editor tabs +~2 days. The big chunk — the live-edit config surface. +- `EquipmentTab`, `UnsTab`, `NamespacesTab` +- `DriversTab` (JSON-only initially per Q1) +- `TagsTab` +- `AclsTab` post Q4 reshape +- `ImportEquipment`, `IdentificationFields` + +### Phase D — Logic + ops pages +~1 day. +- `VirtualTagsTab`, `ScriptedAlarmsTab`, `ScriptsTab`, `ScriptEditor` +- `AuditTab` against new ConfigAuditLog schema +- `RoleGrants` post Q4 reshape +- `Certificates` +- `Reservations` +- `AlarmsHistorian`, `ScriptLog` (depends on F16 ScriptLogHub deferred) + +## Out of scope for F15 + +- Typed driver editors (Q1, deferred unless reversed) +- Top-level fleet-wide ScriptedAlarms / VirtualTags pages (Q2, recommended drop) +- Per-cluster RoleGrants (Q4, recommended drop) +- ScriptLogHub SignalR bridge (F16 deferred — only needed for Phase D's + ScriptLog page; can move to a separate F16-extension follow-up) diff --git a/docs/v2/Architecture-v2.md b/docs/v2/Architecture-v2.md new file mode 100644 index 0000000..1f35833 --- /dev/null +++ b/docs/v2/Architecture-v2.md @@ -0,0 +1,127 @@ +# OtOpcUa v2 Architecture + +Single-page tour of the v2 layout. For decision history + tradeoffs, see [`2026-05-26-akka-hosting-alignment-design.md`](../plans/2026-05-26-akka-hosting-alignment-design.md). + +## Big picture + +``` + ┌─────────────────────────────────────────────┐ + │ OtOpcUa.Host │ (fused binary) + │ │ + │ reads OTOPCUA_ROLES env, mounts: │ + │ ┌─────────────────────────────────────┐ │ + │ │ admin → Blazor + auth + control- │ │ + │ │ plane singletons │ │ + │ │ driver → OPC UA endpoint + │ │ + │ │ per-node actors │ │ + │ └─────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ + │ + │ joins + ▼ + ┌─────────────────────────────────────────────┐ + │ Akka.NET cluster │ + │ (split-brain resolver: keep-oldest, 15s) │ + └─────────────────────────────────────────────┘ + +shared by every node: ┌─────────────────┐ + │ ConfigDb (SQL) │ live-edit + Deployment artifacts + audit + └─────────────────┘ +``` + +The v1 setup was two separate Windows services (`OtOpcUa.Server` + `OtOpcUa.Admin`) talking through the DB. v2 collapses them into one binary with role gating, and adds an Akka cluster so admin singletons can drive deploys and the redundancy story is automatic. + +## Project layout + +``` +src/Core/ shared abstractions, no Server deps + ZB.MOM.WW.OtOpcUa.Commons types + Akka message contracts + interfaces + ZB.MOM.WW.OtOpcUa.Cluster HOCON, AkkaClusterOptions, IClusterRoleInfo + ZB.MOM.WW.OtOpcUa.Configuration EF Core DbContext + entities + +src/Server/ server-side projects + ZB.MOM.WW.OtOpcUa.Security cookie+JWT auth, LDAP, JwtTokenService + ZB.MOM.WW.OtOpcUa.ControlPlane admin-role cluster singletons + ZB.MOM.WW.OtOpcUa.Runtime driver-role per-node actors + ZB.MOM.WW.OtOpcUa.OpcUaServer OPC UA endpoint facade + Phase7Composer + ZB.MOM.WW.OtOpcUa.AdminUI Blazor Razor class library + ZB.MOM.WW.OtOpcUa.Host fused binary (Program.cs) +``` + +| Project | Role | Doc | +|---|---|---| +| Cluster | Bootstrap + cluster topology view | [Cluster.md](Cluster.md) | +| ControlPlane | Admin singletons (deploy, audit, fleet, redundancy) | [ControlPlane.md](ControlPlane.md) | +| Runtime | Driver-role actor tree | [Runtime.md](Runtime.md) | +| Security | Cookie+JWT auth, LDAP, /auth/* endpoints | [../security.md](../security.md) | +| OpcUaServer | OPC UA endpoint host + composer | [../OpcUaServer.md](../OpcUaServer.md) | +| Host | Role-gated DI graph + Program.cs | [../ServiceHosting.md](../ServiceHosting.md) | + +## Role gating + +`Program.cs` reads `OTOPCUA_ROLES` once (per process) and decides what to wire: + +```csharp +var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES")); +var hasAdmin = roles.Contains("admin"); +var hasDriver = roles.Contains("driver"); + +builder.Services.AddOtOpcUaConfigDb(builder.Configuration); +builder.Services.AddOtOpcUaCluster(builder.Configuration); + +builder.Services.AddAkka("otopcua", (ab, sp) => +{ + ab.WithOtOpcUaClusterBootstrap(sp); // HOCON + remote + cluster options + if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); + if (hasDriver) ab.WithOtOpcUaRuntimeActors(); +}); + +if (hasAdmin) +{ + builder.Services.AddOtOpcUaAuth(builder.Configuration); + builder.Services.AddAdminUI(); + // SignalR, AdminOpsClient, etc. +} + +builder.Services.AddOtOpcUaHealth(); +``` + +There is a **single** ActorSystem. Cluster singletons + per-node actors share it via the `Akka.Hosting` registry. This was a v2 fix (the initial Phase 9 wiring ran two ActorSystems by mistake; see commit `d6fac2d`). + +## Live-edit vs draft/publish + +v1 had `ConfigGeneration(Draft|Published)` with every live-edit entity FK'd to a generation. Edits accumulated in a Draft until Publish promoted them. + +v2 removes that entirely: + +- No `ConfigGeneration` table, no `GenerationId` columns. +- Every live-edit entity has a `RowVersion` (`IsRowVersion()`) for last-write-wins. +- Audit goes to `ConfigEdit` (per-row delta) and `ConfigAuditLog` (event-level). +- Deploys snapshot the *current* DB state into an immutable `Deployment.ArtifactBlob` + its `RevisionHash`. That artifact is what driver nodes apply. + +See [ControlPlane.md § Deploy flow](ControlPlane.md#deploy-flow) for the end-to-end dispatch + ACK + seal sequence. + +## NodeId + +Each cluster member has a `NodeId` derived as `{PublicHostname}:{Port}` of the Akka remote endpoint. `ClusterRoleInfo.LocalNode` + `ConfigPublishCoordinator.DiscoverDriverNodes()` use the same formula so they always agree. The port suffix makes loopback test deployments distinguishable (commit `5cfbe8b`); in production the hostname alone is already unique. + +## Health endpoints + +| Path | Returns 200 when… | +|---|---| +| `/healthz` | Process is alive (no checks). | +| `/health/ready` | DB reachable + this node is `Up` in the cluster. | +| `/health/active` | This node is the admin role-leader (used by Traefik/HA-LB to pin traffic). | + +## What lives where (quick map) + +| Concern | Project | Entry point | +|---|---|---| +| Read OTOPCUA_ROLES | `Cluster.RoleParser` | static `Parse(string?)` | +| Cluster lifecycle | `Cluster.WithOtOpcUaClusterBootstrap` | extension on `AkkaConfigurationBuilder` | +| Local node identity | `Cluster.IClusterRoleInfo.LocalNode` | DI singleton | +| Admin singletons | `ControlPlane.WithOtOpcUaControlPlaneSingletons` | extension on `AkkaConfigurationBuilder` | +| Driver actors | `Runtime.WithOtOpcUaRuntimeActors` | extension on `AkkaConfigurationBuilder` | +| Auth pipeline | `Security.AddOtOpcUaAuth` + `MapOtOpcUaAuth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` | +| OPC UA facade | `OpcUaServer.OpcUaApplicationHost` | runtime host, started by driver-role startup | +| Health endpoints | `Host.Health.AddOtOpcUaHealth` + `MapOtOpcUaHealth` | extensions on `IServiceCollection` / `IEndpointRouteBuilder` | diff --git a/docs/v2/Cluster.md b/docs/v2/Cluster.md new file mode 100644 index 0000000..beed2cb --- /dev/null +++ b/docs/v2/Cluster.md @@ -0,0 +1,102 @@ +# OtOpcUa.Cluster + +Akka.NET cluster bootstrap + topology view. Used by every other server-side project to talk to the live cluster. + +Path: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/` + +## Public surface + +| Type | Role | +|---|---| +| `AkkaClusterOptions` | DI-bound options from `appsettings.json::Cluster`. Hostname/Port/PublicHostname/SeedNodes/Roles. | +| `IClusterRoleInfo` (interface in Commons) | Live view of cluster membership + role-leader topology. Thread-safe + event-raising. | +| `ClusterRoleInfo` | Implementation. Subscribes to `ClusterEvent.IMemberEvent` + `RoleLeaderChanged` + `LeaderChanged`. | +| `HoconLoader.LoadBaseConfig()` | Reads the embedded `Resources/akka.conf`. | +| `RoleParser.Parse(string?)` | Parses `OTOPCUA_ROLES` env var into a deduped `string[]`. | +| `ServiceCollectionExtensions.AddOtOpcUaCluster(configuration)` | Binds options + registers `IClusterRoleInfo` singleton. **Does not** start an ActorSystem. | +| `WithOtOpcUaClusterBootstrap(serviceProvider)` | Extension on `AkkaConfigurationBuilder`. Loads embedded HOCON + applies `WithRemoting(...)` + `WithClustering(...)` from options. | + +## Bootstrap flow + +```csharp +// Program.cs +builder.Services.AddOtOpcUaCluster(builder.Configuration); + +builder.Services.AddAkka("otopcua", (ab, sp) => +{ + ab.WithOtOpcUaClusterBootstrap(sp); // HOCON + remote + cluster + // …singletons + node actors layered on +}); +``` + +Order matters: `AddOtOpcUaCluster` must come before `AddAkka` so the options binding has run by the time the `AddAkka` lambda fires. Inside the lambda, `WithOtOpcUaClusterBootstrap` resolves `IOptions` from `sp` and writes them into the Akka builder. + +The single ActorSystem this produces is what every other v2 piece runs on. There is no second Akka instance — that was a Phase 9 bug (commit `d6fac2d` consolidated). + +## Embedded HOCON + +`src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf` contains: + +| Setting | Value | Why | +|---|---|---| +| `akka.actor.provider` | `cluster` | Required for `Cluster.Get(system)` to work. | +| `akka.cluster.split-brain-resolver.active-strategy` | `keep-oldest` | Smaller/younger side downs itself on partition. | +| `akka.cluster.split-brain-resolver.stable-after` | `15s` | Time before SBR acts. | +| `akka.cluster.failure-detector.threshold` | `10.0` | Higher than default (8.0) for GC-pause tolerance. | +| `opcua-synchronized-dispatcher.type` | `PinnedDispatcher` | Dedicated thread for `OpcUaPublishActor` so SDK calls stay marshalled. | + +The Cluster.Tests project verifies these key values stay correct (`HoconLoaderTests`). + +## Configuration + +```json +{ + "Cluster": { + "Hostname": "0.0.0.0", + "Port": 4053, + "PublicHostname": "node-a.lan", + "SeedNodes": ["akka.tcp://otopcua@node-a.lan:4053"], + "Roles": ["admin", "driver"] + } +} +``` + +- `Hostname`: interface to bind. `0.0.0.0` listens on every interface. +- `Port`: TCP port for cluster gossip. Default 4053. +- `PublicHostname`: address advertised in cluster gossip. Must be reachable by every other node. +- `SeedNodes`: where new nodes go to join. List one (or two) stable nodes. First node bootstraps the cluster from its own address. +- `Roles`: free-form tags Akka gossip propagates. v2 uses `admin` + `driver`; per-role wiring in `Program.cs` reads `OTOPCUA_ROLES` env var, not this list — these two should stay in sync. + +## IClusterRoleInfo + +Anywhere in the host that needs the local node's identity or a view of who-else-is-in-the-cluster, inject `IClusterRoleInfo`: + +```csharp +public sealed class MyService(IClusterRoleInfo cluster) +{ + public NodeId Self => cluster.LocalNode; + public IReadOnlyList Drivers => cluster.MembersWithRole("driver"); + public NodeId? AdminLeader => cluster.RoleLeader("admin"); + + public MyService(IClusterRoleInfo cluster) + { + cluster.RoleLeaderChanged += (_, e) => + Console.WriteLine($"role={e.Role}: {e.PreviousLeader} → {e.NewLeader}"); + } +} +``` + +`LocalNode` is `{PublicHostname}:{Port}` (the port suffix lets loopback test deployments stay distinct; production hostnames are already unique). `ConfigPublishCoordinator` uses the same `{host}:{port}` formula so the expected-ack set and the driver self-identification agree (commit `5cfbe8b`). + +## Lifecycle + +Akka.Hosting owns the lifecycle: `IHostedService` starts the ActorSystem at host start, runs `CoordinatedShutdown.ClusterLeavingReason` on host stop. The Cluster project does not register its own `IHostedService` (the v1 `AkkaHostedService` was deleted in commit `d6fac2d`). + +## Tests + +`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/` covers: + +- `HoconLoaderTests` — embedded resource loads + key settings parse correctly. +- `RoleParserTests` — comma-split + dedup + trim semantics. + +Cross-project integration is in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/` (cluster formation, deploy round-trip). diff --git a/docs/v2/ControlPlane.md b/docs/v2/ControlPlane.md new file mode 100644 index 0000000..9dbd30b --- /dev/null +++ b/docs/v2/ControlPlane.md @@ -0,0 +1,99 @@ +# OtOpcUa.ControlPlane + +Five admin-role cluster singletons that drive the v2 deploy, audit, fleet, and redundancy stories. Path: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/`. + +## Singletons + +| Actor | File | Marker key | Role | +|---|---|---|---| +| `ConfigPublishCoordinator` | `Coordinators/ConfigPublishCoordinator.cs` | `ConfigPublishCoordinatorKey` | Dispatches `DispatchDeployment`, collects `ApplyAck`s, seals/fails/times-out. | +| `AdminOperationsActor` | `AdminOperations/AdminOperationsActor.cs` | `AdminOperationsActorKey` | Receives `StartDeployment` from the UI, snapshots ConfigDb via `ConfigComposer`, persists `Deployment` row + `ConfigEdit` marker, tells the coordinator to dispatch. | +| `AuditWriterActor` | `Audit/AuditWriterActor.cs` | `AuditWriterActorKey` | Batched `ConfigAuditLog` writer. Flushes every 500 events or 5 s. In-buffer dedup; cross-restart dedup tracked as F3. | +| `FleetStatusBroadcaster` | `Fleet/FleetStatusBroadcaster.cs` | `FleetStatusBroadcasterKey` | Aggregates per-node `FleetNodeStatus` heartbeats; publishes `FleetStatusChanged` on the `fleet-status` DPS topic (SignalR bridge tracked as F16). | +| `RedundancyStateActor` | `Redundancy/RedundancyStateActor.cs` | `RedundancyStateActorKey` | Cluster-event subscriber; debounces 250 ms; publishes `RedundancyStateChanged` on the `redundancy-state` DPS topic. | + +All five register via `WithOtOpcUaControlPlaneSingletons()` (extension on `AkkaConfigurationBuilder`). Each uses `ClusterSingletonOptions { Role = "admin" }` so the singleton runs on the admin role-leader and migrates to the next admin node on failover. + +```csharp +// Program.cs (admin role only) +builder.Services.AddAkka("otopcua", (ab, sp) => +{ + ab.WithOtOpcUaClusterBootstrap(sp); + if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); + if (hasDriver) ab.WithOtOpcUaRuntimeActors(); +}); +``` + +Resolve from anywhere via `IRequiredActor` or the `ActorRegistry`: + +```csharp +public sealed class AdminOperationsClient(ActorRegistry registry) : IAdminOperationsClient +{ + private readonly IActorRef _proxy = registry.Get(); + // ... +} +``` + +## Deploy flow + +``` +UI → IAdminOperationsClient.StartDeploymentAsync(createdBy) + │ Ask the AdminOperationsActor singleton proxy + ▼ +AdminOperationsActor + │ ConfigComposer.SnapshotAndFlattenAsync(db) → ConfigArtifact(blob, revHash) + │ insert Deployment(Dispatching) + ConfigEdit marker + │ Tell coordinator → DispatchDeployment + ▼ +ConfigPublishCoordinator + │ DiscoverDriverNodes() → expected ACK set (host:port per member) + │ insert NodeDeploymentState(Applying) per driver + │ Publish DispatchDeployment on "deployments" topic + │ Start apply-deadline timer (2 min default) + ▼ DistributedPubSub +DriverHostActor (on each driver node — subscribed to "deployments") + │ PreStart subscribed; current state Steady(rev) + │ if currentRev == msg.rev → immediate ApplyAck(Applied) (idempotent) + │ else Become(Applying) → write NodeDeploymentStatus → ApplyAck + ▼ via "deployment-acks" topic +ConfigPublishCoordinator (subscribed to "deployment-acks" in PreStart) + │ PersistNodeAck + collect + │ all-Applied → Sealed + │ any-Failed → PartiallyFailed + │ deadline → TimedOut +``` + +The dedicated `deployment-acks` topic + coordinator subscription was added in commit `5cfbe8b`. Before that, ACKs were published back on `deployments` and the coordinator (not subscribed) silently dropped them — deployments hung at `AwaitingApplyAcks` forever in multi-node tests. + +### Failover recovery + +If the admin singleton fails over mid-deploy, the new instance's `PreStart` queries `NodeDeploymentState` for any `Dispatching`/`AwaitingApplyAcks` row, rebuilds `_expectedAcks` + `_acks` from persisted state, and resumes the deadline timer. See `Coordinators/ConfigPublishCoordinator.cs::PreStart`. + +## ConfigComposer + +Pure function `SnapshotAndFlattenAsync(db) → ConfigArtifact(byte[], string)`: + +1. Reads every live-edit table. +2. Serialises to a stable byte[] (deterministic ordering). +3. Computes SHA-256 over the bytes → 64-hex `RevisionHash`. + +Same DB state → same artifact + same hash. That's what makes the `NoChanges` outcome work (AdminOperations compares the proposed hash to the last sealed deployment's hash). + +## ServiceLevelCalculator + +Pure function exposed at `Redundancy/ServiceLevelCalculator.Compute(NodeHealthInputs)`. Returns the OPC UA `ServiceLevel` byte per the truth table in [Redundancy.md](../Redundancy.md#servicelevel-tiers-part-5-65). No side effects; trivially unit-testable. + +## DPS topics + +| Topic | Publisher | Subscribers | +|---|---|---| +| `deployments` | ConfigPublishCoordinator | DriverHostActor (per-node) | +| `deployment-acks` | DriverHostActor | ConfigPublishCoordinator | +| `fleet-status` | FleetStatusBroadcaster | (SignalR bridge — F16) | +| `redundancy-state` | RedundancyStateActor | (per-node ServiceLevel calc — F10) | + +## Tests + +`tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/` — 29 tests covering coordinator (happy path, timeout, failover recovery), AdminOps (StartDeployment outcomes), AuditWriter (batching, dedup), FleetStatusBroadcaster (heartbeat staleness), RedundancyStateActor (debounce, snapshot), ConfigComposer (purity), ServiceLevelCalculator (truth table). + +Multi-node tests (cross-ActorSystem) are in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/`. diff --git a/docs/v2/Runtime.md b/docs/v2/Runtime.md new file mode 100644 index 0000000..40bf820 --- /dev/null +++ b/docs/v2/Runtime.md @@ -0,0 +1,126 @@ +# OtOpcUa.Runtime + +Driver-role actor tree — one set per node. Path: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/`. + +## Actor tree + +``` + DriverHostActor (per node) + │ state machine: Steady ⇄ Applying ⇄ Stale + │ + ├──▶ DriverInstanceActor (per configured DriverInstance row) + │ state: Connecting → Connected → Reconnecting (or Stubbed) + │ + ├──▶ VirtualTagActor (per VirtualTag row) + │ compiles + evaluates expression, publishes derived value + │ + ├──▶ ScriptedAlarmActor (per ScriptedAlarm row) + │ state: Inactive ⇄ Active ⇄ Acknowledged + │ + ├──▶ OpcUaPublishActor (per node, pinned dispatcher) + │ marshalled OPC UA SDK writes + RebuildAddressSpace + │ + ├──▶ HistorianAdapterActor (per node) + │ pipe IPC to Wonderware historian sidecar + │ + ├──▶ PeerOpcUaProbeActor (per peer node) + │ opc.tcp ping → redundancy-state DPS topic + │ + └──▶ DbHealthProbeActor (per node) + cached SELECT 1; consumed by /health/ready + redundancy calc +``` + +## Public surface + +| Type | File | +|---|---| +| `WithOtOpcUaRuntimeActors()` | `ServiceCollectionExtensions.cs` — extension on `AkkaConfigurationBuilder`. Spawns `DriverHostActor` + `DbHealthProbeActor` on the host's ActorSystem. | +| `DriverHostActor` | `Drivers/DriverHostActor.cs` | +| `DriverInstanceActor` | `Drivers/DriverInstanceActor.cs` | +| `VirtualTagActor` | `VirtualTags/VirtualTagActor.cs` | +| `ScriptedAlarmActor` | `ScriptedAlarms/ScriptedAlarmActor.cs` | +| `OpcUaPublishActor` | `OpcUa/OpcUaPublishActor.cs` | +| `HistorianAdapterActor` | `Historian/HistorianAdapterActor.cs` | +| `PeerOpcUaProbeActor` | `Health/PeerOpcUaProbeActor.cs` | +| `DbHealthProbeActor` | `Health/DbHealthProbeActor.cs` | + +Marker keys for registry lookup: `DriverHostActorKey`, `DbHealthProbeActorKey`. + +## DriverHostActor + +Per-node supervisor with three Become states: + +| State | Meaning | +|---|---| +| `Steady(rev)` | Caught up. `DispatchDeployment` with `msg.rev == currentRev` → immediate `ApplyAck(Applied)` (idempotent). New rev → `Become(Applying)`. | +| `Applying(id)` | Apply in progress. Further `DispatchDeployment` for in-flight ID → debug-log + ignore. For new ID → defer via `Self.Forward`. | +| `Stale` | ConfigDb unreachable on bootstrap. Periodic `RetryConfigDbConnection` tries to advance to `Steady`. | + +`PreStart`: + +1. Subscribe to `deployments` DPS topic. +2. Read most-recent `NodeDeploymentState` for this node from ConfigDb. +3. If `Applied` → restore `_currentRevision`, `Become(Steady)`. +4. If `Applying` (orphan from crash) → replay apply (idempotent). +5. If `Failed` → `Become(Steady)` at last known rev. +6. DB unreachable → `Become(Stale)`, start retry timer. + +ACK publishing: when no `_coordinatorOverride` is set (production), `SendAck` publishes on the dedicated `deployment-acks` DPS topic which the coordinator subscribes to (commit `5cfbe8b`). + +## DriverInstanceActor + +Per-driver-instance child. State machine: + +- `Connecting` → first attempt to reach the underlying driver +- `Connected` → subscriptions active, reads/writes flow +- `Reconnecting` → temporary disconnect; backoff retry +- `Stubbed` → DEV-STUB mode for Windows-only drivers (Galaxy, Wonderware Historian) on non-Windows or when `roles` contains `dev` + +`ShouldStub(driverType, roles)` returns `true` for `"Galaxy" | "Historian.Wonderware"` on non-Windows; the actor goes straight to `Stubbed` and returns deterministic success without touching real hardware. Wiring this into the DriverHost child-spawn path is follow-up F20 (folds into F7). + +Engine wiring (subscription publishing, ApplyDelta diff, bad-quality-on-disconnect, write path, supervisor backoff) is stubbed — tracked as F7. Tests exercise message contracts, not engine behaviour. + +## VirtualTagActor / ScriptedAlarmActor + +Skeleton state machines + message handlers. Engine work: + +- `VirtualTagEngine.Evaluate()` not yet called from `VirtualTagActor.DependencyValueChanged` (F8). +- `AlarmConditionService` not yet called from `ScriptedAlarmActor` (F9). +- `ScriptedAlarmState` DB persistence on `PreRestart` not wired (F9). + +## OpcUaPublishActor + +The only actor on the **pinned dispatcher** (`opcua-synchronized-dispatcher` from `akka.conf`). All OPC UA SDK address-space writes go through it so the SDK's threading model isn't violated. + +Message contracts are defined; actual SDK calls are stubbed (counters only). Real address-space writes + `ServiceLevel` Variable updates + `RebuildAddressSpace` after a deploy land in F10 (gated on F13 — full `OpcUaApplicationHost` extraction). + +## HistorianAdapterActor, PeerOpcUaProbeActor + +Both have message contracts wired. Engine integration deferred: + +- `HistorianAdapterActor` — named-pipe IPC to the Wonderware historian sidecar + `SqliteStoreAndForwardSink` (F11). +- `PeerOpcUaProbeActor` — real `opc.tcp://peer:4840` ping (F12). Current stub always returns `Ok=true`. + +## DbHealthProbeActor + +`Ask` returns cached state (refreshed every 5 s by an internal `SELECT 1`). Consumed by `/health/ready` and `RedundancyStateActor`. + +## Lifecycle wiring + +```csharp +// Program.cs (driver role only) +builder.Services.AddAkka("otopcua", (ab, sp) => +{ + ab.WithOtOpcUaClusterBootstrap(sp); + if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); + if (hasDriver) ab.WithOtOpcUaRuntimeActors(); +}); +``` + +`WithOtOpcUaRuntimeActors` resolves `IDbContextFactory` + `IClusterRoleInfo` from DI, then spawns `DbHealthProbeActor` and `DriverHostActor` as top-level `/user/` actors. Both register marker keys in `ActorRegistry` so the registry lookup works from anywhere. + +## Tests + +`tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` — 16 tests covering DriverHostActor (Steady ack, Applying transitions, Stale recovery), DriverInstanceActor (state machine, stub mode), VirtualTagActor + ScriptedAlarmActor (message contracts), OpcUaPublishActor (props + message acceptance), DbHealthProbe + PeerOpcUaProbe (probe loop), and the `WithOtOpcUaRuntimeActors` registration round-trip. + +End-to-end deploy from admin → driver via the cluster is in `tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployHappyPathTests.cs`. diff --git a/scripts/install/Install-Services.ps1 b/scripts/install/Install-Services.ps1 index 15a5b0a..32e6bc3 100644 --- a/scripts/install/Install-Services.ps1 +++ b/scripts/install/Install-Services.ps1 @@ -1,46 +1,63 @@ <# .SYNOPSIS - Registers the v2 Windows services on a node: OtOpcUa (main server, net10) and - optionally OtOpcUaWonderwareHistorian (Wonderware historian sidecar). + Registers the v2 Windows service on a node: OtOpcUaHost (fused binary, .NET 10) + and optionally OtOpcUaWonderwareHistorian (Wonderware historian sidecar, net48 x86). .DESCRIPTION - PR 7.2 retired the legacy out-of-process OtOpcUaGalaxyHost service alongside the - GalaxyProxyDriver / GalaxyHost / GalaxyShared projects. Galaxy access now flows - through the in-process GalaxyDriver talking gRPC to a separately-installed - mxaccessgw. The mxaccessgw server runs out of its own repo - (`c:\Users\dohertj2\Desktop\mxaccessgw\`) — see - `docs/v2/Galaxy.ParityRig.md` for the gw setup recipe. + v2 consolidates the legacy OtOpcUa + OtOpcUaAdmin services into a single role-gated + OtOpcUaHost binary. The -Roles parameter sets the OTOPCUA_ROLES service env so + Program.cs decides what to mount (admin / driver / both). The Wonderware historian + sidecar logic is unchanged from v1; install it with -InstallWonderwareHistorian. + + Galaxy access flows through the mxaccessgw sibling repo (separate service); see + docs/v2/Galaxy.ParityRig.md for the gateway setup. .PARAMETER InstallRoot - Where the binaries live (typically C:\Program Files\OtOpcUa). + Where the binaries live (typically C:\Program Files\OtOpcUa). The OtOpcUaHost + service runs OtOpcUa.Host.exe from this directory; publish the Host project there + with `dotnet publish -c Release -r win-x64 --self-contained` first. .PARAMETER ServiceAccount - Service account SID or DOMAIN\name. The OtOpcUa service runs under this account. + Service account SID or DOMAIN\name. The OtOpcUaHost service runs under this account. + +.PARAMETER Roles + Comma-separated cluster roles for this node. One of: + - "admin,driver" — single-node dev or all-in-one production node + - "admin" — admin-only HA pair member (Blazor + control-plane singletons) + - "driver" — driver-only node (OPC UA endpoint + per-node actors) + Written to the service env as OTOPCUA_ROLES. + +.PARAMETER HttpPort + HTTP port for the AdminUI + auth endpoints. Default 9000. Written as ASPNETCORE_URLS. + Ignored on driver-only nodes (no Blazor surface). .PARAMETER InstallWonderwareHistorian - Gate the OtOpcUaWonderwareHistorian sidecar install. Off by default; set when - the deployment uses the Wonderware historian for history reads + alarm-event - persistence. + Gate the OtOpcUaWonderwareHistorian sidecar install. Off by default; set when the + deployment uses the Wonderware historian for history reads + alarm-event persistence. .PARAMETER HistorianSharedSecret - Per-process secret passed to the Historian sidecar via env var. Generated - freshly per install when not supplied. + Per-process secret passed to the historian sidecar via env var. Generated freshly + per install when not supplied. .EXAMPLE - .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua' + .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' ` + -ServiceAccount 'OTOPCUA\svc-otopcua' -Roles 'admin,driver' .EXAMPLE - .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua' ` + .\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' ` + -ServiceAccount 'OTOPCUA\svc-otopcua' -Roles 'driver' ` -InstallWonderwareHistorian #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$InstallRoot, [Parameter(Mandatory)] [string]$ServiceAccount, + [Parameter(Mandatory)] [ValidateSet('admin', 'driver', 'admin,driver', 'driver,admin')] + [string]$Roles, + [int]$HttpPort = 9000, - # PR 3.W — Wonderware historian sidecar. Optional; gates the - # OtOpcUaWonderwareHistorian service. Secret + pipe defaults match the server's - # Historian:Wonderware appsettings block. + # Wonderware historian sidecar. Optional; gates the OtOpcUaWonderwareHistorian + # service. Secret + pipe defaults match the server's Historian:Wonderware appsettings. [switch]$InstallWonderwareHistorian, [string]$HistorianSharedSecret, [string]$HistorianPipeName = 'OtOpcUaWonderwareHistorian', @@ -51,18 +68,19 @@ param( $ErrorActionPreference = 'Stop' -if (-not (Test-Path "$InstallRoot\OtOpcUa.Server.exe")) { - Write-Error "OtOpcUa.Server.exe not found at $InstallRoot — copy the publish output first" +if (-not (Test-Path "$InstallRoot\OtOpcUa.Host.exe")) { + Write-Error "OtOpcUa.Host.exe not found at $InstallRoot — copy the publish output first" exit 1 } -# Generate fresh shared secrets per install if not supplied. function New-SharedSecret { $bytes = New-Object byte[] 32 [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) return [Convert]::ToBase64String($bytes) } -if ($InstallWonderwareHistorian -and -not $HistorianSharedSecret) { $HistorianSharedSecret = New-SharedSecret } +if ($InstallWonderwareHistorian -and -not $HistorianSharedSecret) { + $HistorianSharedSecret = New-SharedSecret +} if ($InstallWonderwareHistorian -and -not (Test-Path "$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe")) { Write-Error "OtOpcUa.Driver.Historian.Wonderware.exe not found at $InstallRoot\WonderwareHistorian — copy the publish output first" @@ -76,10 +94,7 @@ $sid = if ($ServiceAccount.StartsWith('S-1-')) { (New-Object System.Security.Principal.NTAccount $ServiceAccount).Translate([System.Security.Principal.SecurityIdentifier]).Value } -# --- Install OtOpcUaWonderwareHistorian (PR 3.W) — separate sidecar that exposes the -# Wonderware Historian SDK via a named-pipe protocol consumed by the .NET 10 server. -# Optional: only installed when -InstallWonderwareHistorian is supplied. Depends on the -# hard AVEVA services that host the historian SDK runtime path. +# --- OtOpcUaWonderwareHistorian sidecar (optional, unchanged from v1) ------- $historianDepend = $null if ($InstallWonderwareHistorian) { $historianEnv = @( @@ -87,14 +102,10 @@ if ($InstallWonderwareHistorian) { "OTOPCUA_ALLOWED_SID=$sid" "OTOPCUA_HISTORIAN_SECRET=$HistorianSharedSecret" "OTOPCUA_HISTORIAN_ENABLED=true" - # Default-on when the historian sidecar is installed; flip to false for a - # read-only deployment that still loads aahClientManaged for reads but - # rejects WriteAlarmEvents frames. "OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED=true" "OTOPCUA_HISTORIAN_SERVER=$HistorianServer" "OTOPCUA_HISTORIAN_PORT=$HistorianPort" - ) -join "`0" - $historianEnv += "`0`0" + ) Write-Host "Installing OtOpcUaWonderwareHistorian..." & sc.exe create OtOpcUaWonderwareHistorian binPath= "`"$InstallRoot\WonderwareHistorian\OtOpcUa.Driver.Historian.Wonderware.exe`"" ` @@ -105,36 +116,59 @@ if ($InstallWonderwareHistorian) { & sc.exe config OtOpcUaWonderwareHistorian start= delayed-auto | Out-Null $svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaWonderwareHistorian" - $envValue = $historianEnv.Split("`0") | Where-Object { $_ -ne '' } - Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue + Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $historianEnv + + & sc.exe failure OtOpcUaWonderwareHistorian reset= 86400 actions= restart/5000/restart/30000/restart/60000 | Out-Null $historianDepend = 'OtOpcUaWonderwareHistorian' } -# --- Install OtOpcUa. Galaxy access flows through GalaxyDriver → mxaccessgw (gRPC), -# so OtOpcUa no longer depends on a sibling service for Galaxy connectivity. The -# mxaccessgw is installed separately. When the Wonderware sidecar is installed, -# depend on it for startup ordering. -$otOpcUaDepends = @() -if ($historianDepend) { $otOpcUaDepends += $historianDepend } +# --- OtOpcUaHost (the fused v2 binary) -------------------------------------- +$normalisedRoles = ($Roles -split ',' | ForEach-Object { $_.Trim() } | Sort-Object -Unique) -join ',' -Write-Host "Installing OtOpcUa..." +$hasAdmin = $normalisedRoles -split ',' -contains 'admin' + +$hostEnv = @( + "OTOPCUA_ROLES=$normalisedRoles", + 'DOTNET_ENVIRONMENT=Production' +) +if ($hasAdmin) { + $hostEnv += "ASPNETCORE_URLS=http://+:$HttpPort" +} + +$hostDepends = @() +if ($historianDepend) { $hostDepends += $historianDepend } + +Write-Host "Installing OtOpcUaHost (roles=$normalisedRoles)..." $createArgs = @( - 'create', 'OtOpcUa', - 'binPath=', "`"$InstallRoot\OtOpcUa.Server.exe`"", - 'DisplayName=', 'OtOpcUa Server', + 'create', 'OtOpcUaHost', + 'binPath=', "`"$InstallRoot\OtOpcUa.Host.exe`"", + 'DisplayName=', "OtOpcUa Host ($normalisedRoles)", 'start=', 'auto', 'obj=', $ServiceAccount ) -if ($otOpcUaDepends.Count -gt 0) { - $createArgs += @('depend=', ($otOpcUaDepends -join '/')) +if ($hostDepends.Count -gt 0) { + $createArgs += @('depend=', ($hostDepends -join '/')) } & sc.exe @createArgs | Out-Null +# Env block via registry MultiString (sc.exe doesn't take env directly). +$svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaHost" +Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $hostEnv + +# Restart-on-failure: 5s, 30s, 60s; reset counter after a clean 24h run. +& sc.exe failure OtOpcUaHost reset= 86400 actions= restart/5000/restart/30000/restart/60000 | Out-Null + Write-Host "" -Write-Host "Installed. Start with:" +Write-Host "Installed OtOpcUaHost:" +Write-Host " Roles: $normalisedRoles" +if ($hasAdmin) { Write-Host " HTTP port: $HttpPort" } +Write-Host " Binary: $InstallRoot\OtOpcUa.Host.exe" +Write-Host " Account: $ServiceAccount" +Write-Host "" +Write-Host "Start with:" if ($InstallWonderwareHistorian) { Write-Host " sc.exe start OtOpcUaWonderwareHistorian" } -Write-Host " sc.exe start OtOpcUa" +Write-Host " sc.exe start OtOpcUaHost" if ($InstallWonderwareHistorian) { Write-Host "" Write-Host "Wonderware historian shared secret (configure into appsettings.json Historian:Wonderware:SharedSecret):" @@ -142,5 +176,5 @@ if ($InstallWonderwareHistorian) { } Write-Host "" Write-Host "NOTE: Galaxy access flows through mxaccessgw — install + run that separately" -Write-Host " per docs/v2/Galaxy.ParityRig.md. OtOpcUa connects via the Galaxy.Gateway" -Write-Host " section of appsettings.json (default endpoint http://localhost:5120)." +Write-Host " per docs/v2/Galaxy.ParityRig.md. OtOpcUaHost connects via the" +Write-Host " Galaxy.Gateway section of appsettings.json (default http://localhost:5120)." diff --git a/scripts/install/Install-Traefik.ps1 b/scripts/install/Install-Traefik.ps1 new file mode 100644 index 0000000..49cd817 --- /dev/null +++ b/scripts/install/Install-Traefik.ps1 @@ -0,0 +1,68 @@ +<# +.SYNOPSIS + Installs Traefik as a Windows service that routes admin HTTP traffic to whichever + OtOpcUa.Host node holds the admin role-leader (via /health/active). + +.DESCRIPTION + Downloads the Traefik Windows binary into $InstallRoot, drops traefik.yml + + traefik-dynamic.yml from this directory next to it, and registers Traefik as a + Windows service via sc.exe with restart-on-failure. + + Companion to Install-Services.ps1. Run on the box that fronts the admin HTTP + traffic (typically a separate node from OtOpcUaHost, or co-located on the + primary admin node). + +.PARAMETER InstallRoot + Where the Traefik binary + config land. Default 'C:\Program Files\Traefik'. + +.PARAMETER TraefikVersion + Traefik version to download. Default 'v3.1.6'. + +.EXAMPLE + .\Install-Traefik.ps1 -InstallRoot 'C:\Program Files\Traefik' +#> +[CmdletBinding()] +param( + [string]$InstallRoot = 'C:\Program Files\Traefik', + [string]$TraefikVersion = 'v3.1.6' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InstallRoot)) { + New-Item -ItemType Directory -Path $InstallRoot | Out-Null +} + +$zip = Join-Path $env:TEMP "traefik-$TraefikVersion.zip" +$url = "https://github.com/traefik/traefik/releases/download/$TraefikVersion/traefik_${TraefikVersion}_windows_amd64.zip" + +Write-Host "Downloading Traefik $TraefikVersion..." +Invoke-WebRequest -Uri $url -OutFile $zip +Expand-Archive -Path $zip -DestinationPath $InstallRoot -Force +Remove-Item $zip + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Copy-Item -Force (Join-Path $scriptDir 'traefik.yml') $InstallRoot +Copy-Item -Force (Join-Path $scriptDir 'traefik-dynamic.yml') (Join-Path $InstallRoot 'dynamic.yml') + +# Traefik reads dynamic.yml from /etc/traefik on Linux; on Windows place it next to the +# binary and point the file provider at it. Edit traefik.yml's `filename:` if you want +# to change the location. +(Get-Content -Raw (Join-Path $InstallRoot 'traefik.yml')) ` + -replace '/etc/traefik/dynamic.yml', (Join-Path $InstallRoot 'dynamic.yml').Replace('\', '/') ` + | Set-Content (Join-Path $InstallRoot 'traefik.yml') + +Write-Host "Installing Traefik Windows service..." +& sc.exe create OtOpcUaTraefik binPath= "`"$InstallRoot\traefik.exe`" --configFile=`"$InstallRoot\traefik.yml`"" ` + DisplayName= 'OtOpcUa Traefik (admin HTTP front door)' ` + start= auto | Out-Null + +& sc.exe failure OtOpcUaTraefik reset= 86400 actions= restart/5000/restart/30000/restart/60000 | Out-Null + +Write-Host "" +Write-Host "Installed OtOpcUaTraefik. Edit:" +Write-Host " $InstallRoot\dynamic.yml (router + service definitions)" +Write-Host "Start with:" +Write-Host " sc.exe start OtOpcUaTraefik" +Write-Host "" +Write-Host "Traefik dashboard: http://localhost:8080 (turn off api.insecure in production)" diff --git a/scripts/install/Refresh-Services.ps1 b/scripts/install/Refresh-Services.ps1 index 1b7a62e..bd81e10 100644 --- a/scripts/install/Refresh-Services.ps1 +++ b/scripts/install/Refresh-Services.ps1 @@ -43,11 +43,11 @@ function Test-NssmService([string]$Name) { # Step 1: Stop in reverse dependency order # ------------------------------------------------------------------------ -Step "Stopping services (OtOpcUa → OtOpcUaWonderwareHistorian → MxAccessGw)" +Step "Stopping services (OtOpcUaHost > OtOpcUaWonderwareHistorian > MxAccessGw)" -foreach ($name in @('OtOpcUa', 'OtOpcUaWonderwareHistorian', 'MxAccessGw')) { +foreach ($name in @('OtOpcUaHost', 'OtOpcUaWonderwareHistorian', 'MxAccessGw')) { if (Test-NssmService $name) { - Run { nssm stop $name } "stop $name" + Run { Stop-Service $name -Force -ErrorAction SilentlyContinue } "stop $name" } else { Write-Host " ($name not installed; skipping)" -ForegroundColor DarkGray @@ -56,7 +56,7 @@ foreach ($name in @('OtOpcUa', 'OtOpcUaWonderwareHistorian', 'MxAccessGw')) { if (-not $WhatIf) { Start-Sleep -Seconds 3 - Get-Process MxGateway.Server, MxGateway.Worker, OtOpcUa.Server, OtOpcUa.Driver.Historian.Wonderware -ErrorAction SilentlyContinue | + Get-Process MxGateway.Server, MxGateway.Worker, OtOpcUa.Host, OtOpcUa.Driver.Historian.Wonderware -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " killing residual process $($_.ProcessName) (PID=$($_.Id))" -ForegroundColor DarkYellow Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue @@ -109,14 +109,14 @@ Run { # Step 4: Refresh OtOpcUa + Wonderware historian sidecar # ------------------------------------------------------------------------ -Step "Publishing OtOpcUa server + Wonderware historian sidecar from $RepoRoot" +Step "Publishing OtOpcUa.Host + Wonderware historian sidecar from $RepoRoot" Run { - & dotnet publish "$RepoRoot\src\Server\ZB.MOM.WW.OtOpcUa.Server" ` + & dotnet publish "$RepoRoot\src\Server\ZB.MOM.WW.OtOpcUa.Host" ` -c Release -o (Join-Path $PublishRoot "lmxopcua") | Out-Null & dotnet publish "$RepoRoot\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware" ` -c Release -o (Join-Path $PublishRoot "lmxopcua\WonderwareHistorian") | Out-Null -} "dotnet publish (Server + sidecar)" +} "dotnet publish (Host + sidecar)" # ------------------------------------------------------------------------ # Step 5: Service env block — ensure OTOPCUA_HISTORIAN_ALARM_WRITE_ENABLED @@ -143,16 +143,16 @@ if (Test-NssmService 'OtOpcUaWonderwareHistorian') { # Step 6: Start in forward dependency order # ------------------------------------------------------------------------ -Step "Starting services (MxAccessGw → OtOpcUaWonderwareHistorian → OtOpcUa)" +Step "Starting services (MxAccessGw > OtOpcUaWonderwareHistorian > OtOpcUaHost)" foreach ($pair in @( @{ Name = 'MxAccessGw'; Wait = 4 }, @{ Name = 'OtOpcUaWonderwareHistorian'; Wait = 4 }, - @{ Name = 'OtOpcUa'; Wait = 8 } + @{ Name = 'OtOpcUaHost'; Wait = 8 } )) { $name = $pair.Name if (Test-NssmService $name) { - Run { nssm start $name } "start $name" + Run { Start-Service $name } "start $name" if (-not $WhatIf) { Start-Sleep -Seconds $pair.Wait } } else { @@ -167,7 +167,7 @@ foreach ($pair in @( Step "Smoke verification" if (-not $WhatIf) { - foreach ($name in @('MxAccessGw', 'OtOpcUaWonderwareHistorian', 'OtOpcUa')) { + foreach ($name in @('MxAccessGw', 'OtOpcUaWonderwareHistorian', 'OtOpcUaHost')) { if (Test-NssmService $name) { $status = (Get-Service $name).Status $color = if ($status -eq 'Running') { 'Green' } else { 'Red' } diff --git a/scripts/install/Uninstall-Services.ps1 b/scripts/install/Uninstall-Services.ps1 index f5c8206..2fd172b 100644 --- a/scripts/install/Uninstall-Services.ps1 +++ b/scripts/install/Uninstall-Services.ps1 @@ -3,16 +3,17 @@ Stops + removes the v2 services. Mirrors Install-Services.ps1. .DESCRIPTION - PR 7.2 retired the legacy OtOpcUaGalaxyHost service. Galaxy access now flows - through the in-process GalaxyDriver against a separately-installed mxaccessgw. - OtOpcUaGalaxyHost is included in the cleanup loop below so this script safely - removes it from any rig still carrying the legacy service from a pre-7.2 - install. + Removes the v2 OtOpcUaHost service plus the optional OtOpcUaWonderwareHistorian + sidecar. Also cleans up legacy service names from prior installs: + - OtOpcUa (v1 server) — replaced by OtOpcUaHost in v2 + - OtOpcUaAdmin (v1 admin) — fused into OtOpcUaHost in v2 + - OtOpcUaGalaxyHost (pre-7.2 Galaxy host) — long-retired #> [CmdletBinding()] param() $ErrorActionPreference = 'Continue' -foreach ($svc in 'OtOpcUa', 'OtOpcUaWonderwareHistorian', 'OtOpcUaGalaxyHost') { +foreach ($svc in 'OtOpcUaHost', 'OtOpcUaWonderwareHistorian', + 'OtOpcUa', 'OtOpcUaAdmin', 'OtOpcUaGalaxyHost') { if (Get-Service $svc -ErrorAction SilentlyContinue) { Write-Host "Stopping $svc..." Stop-Service $svc -Force -ErrorAction SilentlyContinue diff --git a/scripts/install/traefik-dynamic.yml b/scripts/install/traefik-dynamic.yml new file mode 100644 index 0000000..00fe687 --- /dev/null +++ b/scripts/install/traefik-dynamic.yml @@ -0,0 +1,24 @@ +# Dynamic (file-provider) Traefik config for the OtOpcUa admin HTTP routing. +# Picked up by traefik.yml's file provider (with watch: true) so router/service +# edits hot-reload without a Traefik restart. + +http: + routers: + otopcua-admin: + entryPoints: ["web"] + rule: "HostRegexp(`otopcua.*`)" + service: otopcua-admin + + services: + otopcua-admin: + loadBalancer: + servers: + - url: "http://admin-a:9000" + - url: "http://admin-b:9000" + healthCheck: + path: /health/active + interval: 5s + timeout: 2s + # Default expected status is 2xx. Followers return 503 from + # /health/active so Traefik will drop them from the balancer + # within the next interval after a leadership change. diff --git a/scripts/install/traefik.yml b/scripts/install/traefik.yml new file mode 100644 index 0000000..cc0bd46 --- /dev/null +++ b/scripts/install/traefik.yml @@ -0,0 +1,30 @@ +# Traefik static configuration for the OtOpcUa fleet HTTP front door. +# +# Routes admin-role HTTP traffic (Blazor + auth + SignalR + /auth/*) to whichever +# OtOpcUa.Host node currently holds the admin role-leader. Uses the /health/active +# endpoint as the active-leader signal: a node returns 200 only when it is the +# Akka admin role-leader; followers return 503 and Traefik routes around them. +# +# OPC UA traffic is NOT routed through Traefik — clients connect directly to +# opc.tcp://node:4840 on every driver node and use the standard ServiceLevel +# heuristic for failover. + +entryPoints: + web: + address: ":80" + +providers: + file: + filename: /etc/traefik/dynamic.yml + watch: true + +api: + insecure: true + dashboard: true + +log: + level: INFO + format: common + +accessLog: + format: common diff --git a/scripts/migration/Migrate-To-V2.ps1 b/scripts/migration/Migrate-To-V2.ps1 new file mode 100644 index 0000000..cbd512e --- /dev/null +++ b/scripts/migration/Migrate-To-V2.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Idempotent migration runner that takes the OtOpcUaConfig database from the v1 schema + (with ConfigGeneration / ClusterNodeGenerationState) to the v2 hosting-aligned schema + (with Deployment / NodeDeploymentState / ConfigEdit / DataProtectionKeys). + +.DESCRIPTION + Backs the database up, applies the idempotent EF migration script, then validates that + expected tables exist and legacy tables are gone. Safe to re-run — the EF script itself + is idempotent, and the backup picks a unique filename per invocation. + +.PARAMETER ConnectionString + Mandatory. Full ADO.NET connection string with permissions to BACKUP DATABASE and + apply DDL on the target ConfigDb. + +.PARAMETER BackupPath + Optional. Full path for the backup file. Defaults to a timestamped path under $env:TEMP. + +.EXAMPLE + .\Migrate-To-V2.ps1 -ConnectionString "Server=sql01;Database=OtOpcUaConfig;Trusted_Connection=True;TrustServerCertificate=True" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ConnectionString, + [string] $BackupPath = "$env:TEMP\OtOpcUa-V1-Backup-$(Get-Date -Format yyyyMMddHHmmss).bak" +) + +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command Invoke-Sqlcmd -ErrorAction SilentlyContinue)) { + throw "Invoke-Sqlcmd not available. Install module: Install-Module SqlServer -Scope CurrentUser" +} + +Write-Host "Step 1/4 — Backup ConfigDb to $BackupPath" -ForegroundColor Cyan +Invoke-Sqlcmd -ConnectionString $ConnectionString ` + -Query "BACKUP DATABASE [OtOpcUaConfig] TO DISK = '$BackupPath' WITH FORMAT, COMPRESSION" + +Write-Host "Step 2/4 — Row counts (before)" -ForegroundColor Cyan +$beforeCounts = Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\count-rows.sql" +$beforeCounts | Format-Table + +Write-Host "Step 3/4 — Apply Migrate-To-V2.sql" -ForegroundColor Cyan +Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\Migrate-To-V2.sql" -QueryTimeout 1800 + +Write-Host "Step 4/4 — Row counts (after) + validation" -ForegroundColor Cyan +$afterCounts = Invoke-Sqlcmd -ConnectionString $ConnectionString -InputFile "$PSScriptRoot\count-rows.sql" +$afterCounts | Format-Table + +$tablesNow = (Invoke-Sqlcmd -ConnectionString $ConnectionString ` + -Query "SELECT name FROM sys.tables ORDER BY name").name + +foreach ($t in 'Deployment','NodeDeploymentState','ConfigEdit','DataProtectionKeys') { + if ($tablesNow -notcontains $t) { throw "Expected v2 table $t missing." } +} +foreach ($t in 'ConfigGeneration','ClusterNodeGenerationState') { + if ($tablesNow -contains $t) { throw "Legacy v1 table $t still present." } +} + +Write-Host "Migration complete. Backup at $BackupPath" -ForegroundColor Green diff --git a/scripts/migration/Migrate-To-V2.sql b/scripts/migration/Migrate-To-V2.sql new file mode 100644 index 0000000..f3cc25d --- /dev/null +++ b/scripts/migration/Migrate-To-V2.sql @@ -0,0 +1,3259 @@ +IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL +BEGIN + CREATE TABLE [__EFMigrationsHistory] ( + [MigrationId] nvarchar(150) NOT NULL, + [ProductVersion] nvarchar(32) NOT NULL, + CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) + ); +END; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ConfigAuditLog] ( + [AuditId] bigint NOT NULL IDENTITY, + [Timestamp] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [Principal] nvarchar(128) NOT NULL, + [EventType] nvarchar(64) NOT NULL, + [ClusterId] nvarchar(64) NULL, + [NodeId] nvarchar(64) NULL, + [GenerationId] bigint NULL, + [DetailsJson] nvarchar(max) NULL, + CONSTRAINT [PK_ConfigAuditLog] PRIMARY KEY ([AuditId]), + CONSTRAINT [CK_ConfigAuditLog_DetailsJson_IsJson] CHECK (DetailsJson IS NULL OR ISJSON(DetailsJson) = 1) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ExternalIdReservation] ( + [ReservationId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [Kind] nvarchar(16) NOT NULL, + [Value] nvarchar(64) NOT NULL, + [EquipmentUuid] uniqueidentifier NOT NULL, + [ClusterId] nvarchar(64) NOT NULL, + [FirstPublishedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [FirstPublishedBy] nvarchar(128) NOT NULL, + [LastPublishedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [ReleasedAt] datetime2(3) NULL, + [ReleasedBy] nvarchar(128) NULL, + [ReleaseReason] nvarchar(512) NULL, + CONSTRAINT [PK_ExternalIdReservation] PRIMARY KEY ([ReservationId]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ServerCluster] ( + [ClusterId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [Enterprise] nvarchar(32) NOT NULL, + [Site] nvarchar(32) NOT NULL, + [NodeCount] tinyint NOT NULL, + [RedundancyMode] nvarchar(16) NOT NULL, + [Enabled] bit NOT NULL, + [Notes] nvarchar(1024) NULL, + [CreatedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [CreatedBy] nvarchar(128) NOT NULL, + [ModifiedAt] datetime2(3) NULL, + [ModifiedBy] nvarchar(128) NULL, + CONSTRAINT [PK_ServerCluster] PRIMARY KEY ([ClusterId]), + CONSTRAINT [CK_ServerCluster_RedundancyMode_NodeCount] CHECK (((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ClusterNode] ( + [NodeId] nvarchar(64) NOT NULL, + [ClusterId] nvarchar(64) NOT NULL, + [RedundancyRole] nvarchar(16) NOT NULL, + [Host] nvarchar(255) NOT NULL, + [OpcUaPort] int NOT NULL, + [DashboardPort] int NOT NULL, + [ApplicationUri] nvarchar(256) NOT NULL, + [ServiceLevelBase] tinyint NOT NULL, + [DriverConfigOverridesJson] nvarchar(max) NULL, + [Enabled] bit NOT NULL, + [LastSeenAt] datetime2(3) NULL, + [CreatedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [CreatedBy] nvarchar(128) NOT NULL, + CONSTRAINT [PK_ClusterNode] PRIMARY KEY ([NodeId]), + CONSTRAINT [FK_ClusterNode_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ConfigGeneration] ( + [GenerationId] bigint NOT NULL IDENTITY, + [ClusterId] nvarchar(64) NOT NULL, + [Status] nvarchar(16) NOT NULL, + [ParentGenerationId] bigint NULL, + [PublishedAt] datetime2(3) NULL, + [PublishedBy] nvarchar(128) NULL, + [Notes] nvarchar(1024) NULL, + [CreatedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [CreatedBy] nvarchar(128) NOT NULL, + CONSTRAINT [PK_ConfigGeneration] PRIMARY KEY ([GenerationId]), + CONSTRAINT [FK_ConfigGeneration_ConfigGeneration_ParentGenerationId] FOREIGN KEY ([ParentGenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION, + CONSTRAINT [FK_ConfigGeneration_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ClusterNodeCredential] ( + [CredentialId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [NodeId] nvarchar(64) NOT NULL, + [Kind] nvarchar(32) NOT NULL, + [Value] nvarchar(512) NOT NULL, + [Enabled] bit NOT NULL, + [RotatedAt] datetime2(3) NULL, + [CreatedAt] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [CreatedBy] nvarchar(128) NOT NULL, + CONSTRAINT [PK_ClusterNodeCredential] PRIMARY KEY ([CredentialId]), + CONSTRAINT [FK_ClusterNodeCredential_ClusterNode_NodeId] FOREIGN KEY ([NodeId]) REFERENCES [ClusterNode] ([NodeId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [ClusterNodeGenerationState] ( + [NodeId] nvarchar(64) NOT NULL, + [CurrentGenerationId] bigint NULL, + [LastAppliedAt] datetime2(3) NULL, + [LastAppliedStatus] nvarchar(16) NULL, + [LastAppliedError] nvarchar(2048) NULL, + [LastSeenAt] datetime2(3) NULL, + CONSTRAINT [PK_ClusterNodeGenerationState] PRIMARY KEY ([NodeId]), + CONSTRAINT [FK_ClusterNodeGenerationState_ClusterNode_NodeId] FOREIGN KEY ([NodeId]) REFERENCES [ClusterNode] ([NodeId]) ON DELETE NO ACTION, + CONSTRAINT [FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId] FOREIGN KEY ([CurrentGenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [Device] ( + [DeviceRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [DeviceId] nvarchar(64) NULL, + [DriverInstanceId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [Enabled] bit NOT NULL, + [DeviceConfig] nvarchar(max) NOT NULL, + CONSTRAINT [PK_Device] PRIMARY KEY ([DeviceRowId]), + CONSTRAINT [CK_Device_DeviceConfig_IsJson] CHECK (ISJSON(DeviceConfig) = 1), + CONSTRAINT [FK_Device_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [DriverInstance] ( + [DriverInstanceRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [DriverInstanceId] nvarchar(64) NULL, + [ClusterId] nvarchar(64) NOT NULL, + [NamespaceId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [DriverType] nvarchar(32) NOT NULL, + [Enabled] bit NOT NULL, + [DriverConfig] nvarchar(max) NOT NULL, + CONSTRAINT [PK_DriverInstance] PRIMARY KEY ([DriverInstanceRowId]), + CONSTRAINT [CK_DriverInstance_DriverConfig_IsJson] CHECK (ISJSON(DriverConfig) = 1), + CONSTRAINT [FK_DriverInstance_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION, + CONSTRAINT [FK_DriverInstance_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [Equipment] ( + [EquipmentRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [EquipmentId] nvarchar(64) NULL, + [EquipmentUuid] uniqueidentifier NOT NULL, + [DriverInstanceId] nvarchar(64) NOT NULL, + [DeviceId] nvarchar(64) NULL, + [UnsLineId] nvarchar(64) NOT NULL, + [Name] nvarchar(32) NOT NULL, + [MachineCode] nvarchar(64) NOT NULL, + [ZTag] nvarchar(64) NULL, + [SAPID] nvarchar(64) NULL, + [Manufacturer] nvarchar(64) NULL, + [Model] nvarchar(64) NULL, + [SerialNumber] nvarchar(64) NULL, + [HardwareRevision] nvarchar(32) NULL, + [SoftwareRevision] nvarchar(32) NULL, + [YearOfConstruction] smallint NULL, + [AssetLocation] nvarchar(256) NULL, + [ManufacturerUri] nvarchar(512) NULL, + [DeviceManualUri] nvarchar(512) NULL, + [EquipmentClassRef] nvarchar(128) NULL, + [Enabled] bit NOT NULL, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([EquipmentRowId]), + CONSTRAINT [FK_Equipment_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [Namespace] ( + [NamespaceRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [NamespaceId] nvarchar(64) NULL, + [ClusterId] nvarchar(64) NOT NULL, + [Kind] nvarchar(32) NOT NULL, + [NamespaceUri] nvarchar(256) NOT NULL, + [Enabled] bit NOT NULL, + [Notes] nvarchar(1024) NULL, + CONSTRAINT [PK_Namespace] PRIMARY KEY ([NamespaceRowId]), + CONSTRAINT [FK_Namespace_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION, + CONSTRAINT [FK_Namespace_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [NodeAcl] ( + [NodeAclRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [NodeAclId] nvarchar(64) NULL, + [ClusterId] nvarchar(64) NOT NULL, + [LdapGroup] nvarchar(256) NOT NULL, + [ScopeKind] nvarchar(16) NOT NULL, + [ScopeId] nvarchar(64) NULL, + [PermissionFlags] int NOT NULL, + [Notes] nvarchar(512) NULL, + CONSTRAINT [PK_NodeAcl] PRIMARY KEY ([NodeAclRowId]), + CONSTRAINT [FK_NodeAcl_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [PollGroup] ( + [PollGroupRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [PollGroupId] nvarchar(64) NULL, + [DriverInstanceId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [IntervalMs] int NOT NULL, + CONSTRAINT [PK_PollGroup] PRIMARY KEY ([PollGroupRowId]), + CONSTRAINT [CK_PollGroup_IntervalMs_Min] CHECK (IntervalMs >= 50), + CONSTRAINT [FK_PollGroup_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [Tag] ( + [TagRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [TagId] nvarchar(64) NULL, + [DriverInstanceId] nvarchar(64) NOT NULL, + [DeviceId] nvarchar(64) NULL, + [EquipmentId] nvarchar(64) NULL, + [Name] nvarchar(128) NOT NULL, + [FolderPath] nvarchar(512) NULL, + [DataType] nvarchar(32) NOT NULL, + [AccessLevel] nvarchar(16) NOT NULL, + [WriteIdempotent] bit NOT NULL, + [PollGroupId] nvarchar(64) NULL, + [TagConfig] nvarchar(max) NOT NULL, + CONSTRAINT [PK_Tag] PRIMARY KEY ([TagRowId]), + CONSTRAINT [CK_Tag_TagConfig_IsJson] CHECK (ISJSON(TagConfig) = 1), + CONSTRAINT [FK_Tag_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [UnsArea] ( + [UnsAreaRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [UnsAreaId] nvarchar(64) NULL, + [ClusterId] nvarchar(64) NOT NULL, + [Name] nvarchar(32) NOT NULL, + [Notes] nvarchar(512) NULL, + CONSTRAINT [PK_UnsArea] PRIMARY KEY ([UnsAreaRowId]), + CONSTRAINT [FK_UnsArea_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION, + CONSTRAINT [FK_UnsArea_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE TABLE [UnsLine] ( + [UnsLineRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [UnsLineId] nvarchar(64) NULL, + [UnsAreaId] nvarchar(64) NOT NULL, + [Name] nvarchar(32) NOT NULL, + [Notes] nvarchar(512) NULL, + CONSTRAINT [PK_UnsLine] PRIMARY KEY ([UnsLineRowId]), + CONSTRAINT [FK_UnsLine_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_ClusterNode_ApplicationUri] ON [ClusterNode] ([ApplicationUri]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ClusterNode_Primary_Per_Cluster] ON [ClusterNode] ([ClusterId]) WHERE [RedundancyRole] = ''Primary'''); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ClusterNodeCredential_NodeId] ON [ClusterNodeCredential] ([NodeId], [Enabled]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ClusterNodeCredential_Value] ON [ClusterNodeCredential] ([Kind], [Value]) WHERE [Enabled] = 1'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ClusterNodeGenerationState_Generation] ON [ClusterNodeGenerationState] ([CurrentGenerationId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ConfigAuditLog_Cluster_Time] ON [ConfigAuditLog] ([ClusterId], [Timestamp] DESC); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE INDEX [IX_ConfigAuditLog_Generation] ON [ConfigAuditLog] ([GenerationId]) WHERE [GenerationId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ConfigGeneration_Cluster_Published] ON [ConfigGeneration] ([ClusterId], [Status], [GenerationId] DESC) INCLUDE ([PublishedAt]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ConfigGeneration_ParentGenerationId] ON [ConfigGeneration] ([ParentGenerationId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ConfigGeneration_Draft_Per_Cluster] ON [ConfigGeneration] ([ClusterId]) WHERE [Status] = ''Draft'''); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Device_Generation_Driver] ON [Device] ([GenerationId], [DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Device_Generation_LogicalId] ON [Device] ([GenerationId], [DeviceId]) WHERE [DeviceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_DriverInstance_ClusterId] ON [DriverInstance] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_DriverInstance_Generation_Cluster] ON [DriverInstance] ([GenerationId], [ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_DriverInstance_Generation_Namespace] ON [DriverInstance] ([GenerationId], [NamespaceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_DriverInstance_Generation_LogicalId] ON [DriverInstance] ([GenerationId], [DriverInstanceId]) WHERE [DriverInstanceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Equipment_Generation_Driver] ON [Equipment] ([GenerationId], [DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Equipment_Generation_Line] ON [Equipment] ([GenerationId], [UnsLineId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Equipment_Generation_MachineCode] ON [Equipment] ([GenerationId], [MachineCode]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Equipment_Generation_SAPID] ON [Equipment] ([GenerationId], [SAPID]) WHERE [SAPID] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Equipment_Generation_ZTag] ON [Equipment] ([GenerationId], [ZTag]) WHERE [ZTag] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_Equipment_Generation_LinePath] ON [Equipment] ([GenerationId], [UnsLineId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Equipment_Generation_LogicalId] ON [Equipment] ([GenerationId], [EquipmentId]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_Equipment_Generation_Uuid] ON [Equipment] ([GenerationId], [EquipmentUuid]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ExternalIdReservation_Equipment] ON [ExternalIdReservation] ([EquipmentUuid]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ExternalIdReservation_KindValue_Active] ON [ExternalIdReservation] ([Kind], [Value]) WHERE [ReleasedAt] IS NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Namespace_ClusterId] ON [Namespace] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Namespace_Generation_Cluster] ON [Namespace] ([GenerationId], [ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_Namespace_Generation_Cluster_Kind] ON [Namespace] ([GenerationId], [ClusterId], [Kind]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Namespace_Generation_LogicalId] ON [Namespace] ([GenerationId], [NamespaceId]) WHERE [NamespaceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Namespace_Generation_LogicalId_Cluster] ON [Namespace] ([GenerationId], [NamespaceId], [ClusterId]) WHERE [NamespaceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_Namespace_Generation_NamespaceUri] ON [Namespace] ([GenerationId], [NamespaceUri]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_NodeAcl_Generation_Cluster] ON [NodeAcl] ([GenerationId], [ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_NodeAcl_Generation_Group] ON [NodeAcl] ([GenerationId], [LdapGroup]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE INDEX [IX_NodeAcl_Generation_Scope] ON [NodeAcl] ([GenerationId], [ScopeKind], [ScopeId]) WHERE [ScopeId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_NodeAcl_Generation_GroupScope] ON [NodeAcl] ([GenerationId], [ClusterId], [LdapGroup], [ScopeKind], [ScopeId]) WHERE [ScopeId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_NodeAcl_Generation_LogicalId] ON [NodeAcl] ([GenerationId], [NodeAclId]) WHERE [NodeAclId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_PollGroup_Generation_Driver] ON [PollGroup] ([GenerationId], [DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_PollGroup_Generation_LogicalId] ON [PollGroup] ([GenerationId], [PollGroupId]) WHERE [PollGroupId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_ServerCluster_Site] ON [ServerCluster] ([Site]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_ServerCluster_Name] ON [ServerCluster] ([Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_Tag_Generation_Driver_Device] ON [Tag] ([GenerationId], [DriverInstanceId], [DeviceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Tag_Generation_Equipment] ON [Tag] ([GenerationId], [EquipmentId]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_Generation_EquipmentPath] ON [Tag] ([GenerationId], [EquipmentId], [Name]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_Generation_FolderPath] ON [Tag] ([GenerationId], [DriverInstanceId], [FolderPath], [Name]) WHERE [EquipmentId] IS NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_Generation_LogicalId] ON [Tag] ([GenerationId], [TagId]) WHERE [TagId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_UnsArea_ClusterId] ON [UnsArea] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_UnsArea_Generation_Cluster] ON [UnsArea] ([GenerationId], [ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_UnsArea_Generation_ClusterName] ON [UnsArea] ([GenerationId], [ClusterId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_UnsArea_Generation_LogicalId] ON [UnsArea] ([GenerationId], [UnsAreaId]) WHERE [UnsAreaId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE INDEX [IX_UnsLine_Generation_Area] ON [UnsLine] ([GenerationId], [UnsAreaId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + CREATE UNIQUE INDEX [UX_UnsLine_Generation_AreaName] ON [UnsLine] ([GenerationId], [UnsAreaId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_UnsLine_Generation_LogicalId] ON [UnsLine] ([GenerationId], [UnsLineId]) WHERE [UnsLineId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417212220_InitialSchema' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417212220_InitialSchema', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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()); + + -- Build DetailsJson via STRING_ESCAPE so a @Status containing a double-quote/backslash cannot + -- produce malformed JSON (which would fail CK_ConfigAuditLog_DetailsJson_IsJson and abort the + -- transaction) or inject extra JSON structure into the audit record. + INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson) + VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId, + CONCAT('{"status":"', STRING_ESCAPE(@Status, 'json'), '"}')); + END + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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; + + -- Transaction-nesting awareness: if a caller (e.g. sp_RollbackToGeneration) already + -- holds a transaction, we use SAVE TRANSACTION so our failure path rolls back only to + -- the savepoint instead of issuing a bare ROLLBACK that wipes the caller's transaction + -- (which sets @@TRANCOUNT = 0 and causes error 3902 on the caller's subsequent COMMIT). + DECLARE @OwnsTxn bit = 0; + DECLARE @SaveName nvarchar(32) = N'sp_PublishGeneration'; + + IF @@TRANCOUNT = 0 + BEGIN + BEGIN TRANSACTION; + SET @OwnsTxn = 1; + END + ELSE + BEGIN + SAVE TRANSACTION sp_PublishGeneration; + END + + 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); + IF @OwnsTxn = 1 ROLLBACK; + ELSE ROLLBACK TRANSACTION sp_PublishGeneration; + RETURN; + END + + -- sp_ValidateDraft signals every rejection with RAISERROR(..., 16, 1) — a severity-16 error is + -- NOT batch-aborting and SET XACT_ABORT ON does not abort the transaction for it, so without a + -- TRY/CATCH control would return here and the draft would publish despite failed validation. + -- Catch the validation error, roll back the publish transaction (only to our savepoint when a + -- caller owns the outer transaction), and re-raise so the caller sees the real validation failure. + BEGIN TRY + EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId; + END TRY + BEGIN CATCH + IF @OwnsTxn = 1 ROLLBACK; + ELSE ROLLBACK TRANSACTION sp_PublishGeneration; + THROW; + END CATCH + + 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 in Draft status (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId); + IF @OwnsTxn = 1 ROLLBACK; + ELSE ROLLBACK TRANSACTION sp_PublishGeneration; + RETURN; + END + + INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId) + VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId); + + IF @OwnsTxn = 1 COMMIT; + END + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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; + + -- @TargetGenerationId is a bigint, but build the JSON value via an explicit numeric CONVERT so + -- the emitted token is always a bare JSON number — never reliant on implicit string coercion. + INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson) + VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId, + CONCAT('{"rolledBackTo":', CONVERT(nvarchar(20), CONVERT(bigint, @TargetGenerationId)), '}')); + + COMMIT; + END + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + + 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 + + -- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the + -- JSON document or inject additional structure into the audit record. + INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson) + VALUES (SUSER_SNAME(), 'ExternalIdReleased', + CONCAT('{"kind":"', STRING_ESCAPE(@Kind, 'json'), + '","value":"', STRING_ESCAPE(@Value, 'json'), '"}')); + END + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417215224_StoredProcedures' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417215224_StoredProcedures', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417220857_AuthorizationGrants' +) +BEGIN + + IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL + CREATE ROLE OtOpcUaNode; + IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL + CREATE ROLE OtOpcUaAdmin; + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417220857_AuthorizationGrants' +) +BEGIN + + 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; + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260417220857_AuthorizationGrants' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260417220857_AuthorizationGrants', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418193608_AddDriverHostStatus' +) +BEGIN + CREATE TABLE [DriverHostStatus] ( + [NodeId] nvarchar(64) NOT NULL, + [DriverInstanceId] nvarchar(64) NOT NULL, + [HostName] nvarchar(256) NOT NULL, + [State] nvarchar(16) NOT NULL, + [StateChangedUtc] datetime2(3) NOT NULL, + [LastSeenUtc] datetime2(3) NOT NULL, + [Detail] nvarchar(1024) NULL, + CONSTRAINT [PK_DriverHostStatus] PRIMARY KEY ([NodeId], [DriverInstanceId], [HostName]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418193608_AddDriverHostStatus' +) +BEGIN + CREATE INDEX [IX_DriverHostStatus_LastSeen] ON [DriverHostStatus] ([LastSeenUtc]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418193608_AddDriverHostStatus' +) +BEGIN + CREATE INDEX [IX_DriverHostStatus_Node] ON [DriverHostStatus] ([NodeId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260418193608_AddDriverHostStatus' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260418193608_AddDriverHostStatus', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419124034_AddDriverInstanceResilienceStatus' +) +BEGIN + CREATE TABLE [DriverInstanceResilienceStatus] ( + [DriverInstanceId] nvarchar(64) NOT NULL, + [HostName] nvarchar(256) NOT NULL, + [LastCircuitBreakerOpenUtc] datetime2(3) NULL, + [ConsecutiveFailures] int NOT NULL, + [CurrentBulkheadDepth] int NOT NULL, + [LastRecycleUtc] datetime2(3) NULL, + [BaselineFootprintBytes] bigint NOT NULL, + [CurrentFootprintBytes] bigint NOT NULL, + [LastSampledUtc] datetime2(3) NOT NULL, + CONSTRAINT [PK_DriverInstanceResilienceStatus] PRIMARY KEY ([DriverInstanceId], [HostName]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419124034_AddDriverInstanceResilienceStatus' +) +BEGIN + CREATE INDEX [IX_DriverResilience_LastSampled] ON [DriverInstanceResilienceStatus] ([LastSampledUtc]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419124034_AddDriverInstanceResilienceStatus' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419124034_AddDriverInstanceResilienceStatus', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419131444_AddLdapGroupRoleMapping' +) +BEGIN + CREATE TABLE [LdapGroupRoleMapping] ( + [Id] uniqueidentifier NOT NULL, + [LdapGroup] nvarchar(512) NOT NULL, + [Role] nvarchar(32) NOT NULL, + [ClusterId] nvarchar(64) NULL, + [IsSystemWide] bit NOT NULL, + [CreatedAtUtc] datetime2(3) NOT NULL, + [Notes] nvarchar(512) NULL, + CONSTRAINT [PK_LdapGroupRoleMapping] PRIMARY KEY ([Id]), + CONSTRAINT [FK_LdapGroupRoleMapping_ServerCluster_ClusterId] FOREIGN KEY ([ClusterId]) REFERENCES [ServerCluster] ([ClusterId]) ON DELETE CASCADE + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419131444_AddLdapGroupRoleMapping' +) +BEGIN + CREATE INDEX [IX_LdapGroupRoleMapping_ClusterId] ON [LdapGroupRoleMapping] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419131444_AddLdapGroupRoleMapping' +) +BEGIN + CREATE INDEX [IX_LdapGroupRoleMapping_Group] ON [LdapGroupRoleMapping] ([LdapGroup]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419131444_AddLdapGroupRoleMapping' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_LdapGroupRoleMapping_Group_Cluster] ON [LdapGroupRoleMapping] ([LdapGroup], [ClusterId]) WHERE [ClusterId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419131444_AddLdapGroupRoleMapping' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419131444_AddLdapGroupRoleMapping', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419161932_AddDriverInstanceResilienceConfig' +) +BEGIN + ALTER TABLE [DriverInstance] ADD [ResilienceConfig] nvarchar(max) NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419161932_AddDriverInstanceResilienceConfig' +) +BEGIN + EXEC(N'ALTER TABLE [DriverInstance] ADD CONSTRAINT [CK_DriverInstance_ResilienceConfig_IsJson] CHECK (ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1)'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419161932_AddDriverInstanceResilienceConfig' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419161932_AddDriverInstanceResilienceConfig', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419185124_AddEquipmentImportBatch' +) +BEGIN + CREATE TABLE [EquipmentImportBatch] ( + [Id] uniqueidentifier NOT NULL, + [ClusterId] nvarchar(64) NOT NULL, + [CreatedBy] nvarchar(128) NOT NULL, + [CreatedAtUtc] datetime2(3) NOT NULL, + [RowsStaged] int NOT NULL, + [RowsAccepted] int NOT NULL, + [RowsRejected] int NOT NULL, + [FinalisedAtUtc] datetime2(3) NULL, + CONSTRAINT [PK_EquipmentImportBatch] PRIMARY KEY ([Id]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419185124_AddEquipmentImportBatch' +) +BEGIN + CREATE TABLE [EquipmentImportRow] ( + [Id] uniqueidentifier NOT NULL, + [BatchId] uniqueidentifier NOT NULL, + [LineNumberInFile] int NOT NULL, + [IsAccepted] bit NOT NULL, + [RejectReason] nvarchar(512) NULL, + [ZTag] nvarchar(128) NOT NULL, + [MachineCode] nvarchar(128) NOT NULL, + [SAPID] nvarchar(128) NOT NULL, + [EquipmentId] nvarchar(64) NOT NULL, + [EquipmentUuid] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [UnsAreaName] nvarchar(64) NOT NULL, + [UnsLineName] nvarchar(64) NOT NULL, + [Manufacturer] nvarchar(256) NULL, + [Model] nvarchar(256) NULL, + [SerialNumber] nvarchar(256) NULL, + [HardwareRevision] nvarchar(64) NULL, + [SoftwareRevision] nvarchar(64) NULL, + [YearOfConstruction] nvarchar(8) NULL, + [AssetLocation] nvarchar(512) NULL, + [ManufacturerUri] nvarchar(512) NULL, + [DeviceManualUri] nvarchar(512) NULL, + CONSTRAINT [PK_EquipmentImportRow] PRIMARY KEY ([Id]), + CONSTRAINT [FK_EquipmentImportRow_EquipmentImportBatch_BatchId] FOREIGN KEY ([BatchId]) REFERENCES [EquipmentImportBatch] ([Id]) ON DELETE CASCADE + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419185124_AddEquipmentImportBatch' +) +BEGIN + CREATE INDEX [IX_EquipmentImportBatch_Creator_Finalised] ON [EquipmentImportBatch] ([CreatedBy], [FinalisedAtUtc]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419185124_AddEquipmentImportBatch' +) +BEGIN + CREATE INDEX [IX_EquipmentImportRow_Batch] ON [EquipmentImportRow] ([BatchId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260419185124_AddEquipmentImportBatch' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260419185124_AddEquipmentImportBatch', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420000001_ExtendComputeGenerationDiffWithNodeAcl' +) +BEGIN + + CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff + @FromGenerationId bigint, + @ToGenerationId bigint + AS + BEGIN + SET NOCOUNT ON; + + CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), 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(128), 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(128), 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(128), 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(128), 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; + + -- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff + -- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes) + -- from a scope move (which surfaces as Added + Removed of different logical ids). + WITH f AS ( + SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId, + CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig + FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId), + t AS ( + SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId, + CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig + FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId) + INSERT #diff + SELECT 'NodeAcl', 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420000001_ExtendComputeGenerationDiffWithNodeAcl' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260420000001_ExtendComputeGenerationDiffWithNodeAcl', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE TABLE [Script] ( + [ScriptRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [ScriptId] nvarchar(64) NULL, + [Name] nvarchar(128) NOT NULL, + [SourceCode] nvarchar(max) NOT NULL, + [SourceHash] nvarchar(64) NOT NULL, + [Language] nvarchar(16) NOT NULL, + CONSTRAINT [PK_Script] PRIMARY KEY ([ScriptRowId]), + CONSTRAINT [FK_Script_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE TABLE [ScriptedAlarm] ( + [ScriptedAlarmRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [ScriptedAlarmId] nvarchar(64) NULL, + [EquipmentId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [AlarmType] nvarchar(32) NOT NULL, + [Severity] int NOT NULL, + [MessageTemplate] nvarchar(1024) NOT NULL, + [PredicateScriptId] nvarchar(64) NOT NULL, + [HistorizeToAveva] bit NOT NULL, + [Retain] bit NOT NULL, + [Enabled] bit NOT NULL, + CONSTRAINT [PK_ScriptedAlarm] PRIMARY KEY ([ScriptedAlarmRowId]), + CONSTRAINT [CK_ScriptedAlarm_AlarmType] CHECK (AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')), + CONSTRAINT [CK_ScriptedAlarm_Severity_Range] CHECK (Severity BETWEEN 1 AND 1000), + CONSTRAINT [FK_ScriptedAlarm_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE TABLE [ScriptedAlarmState] ( + [ScriptedAlarmId] nvarchar(64) NOT NULL, + [EnabledState] nvarchar(16) NOT NULL, + [AckedState] nvarchar(16) NOT NULL, + [ConfirmedState] nvarchar(16) NOT NULL, + [ShelvingState] nvarchar(16) NOT NULL, + [ShelvingExpiresUtc] datetime2(3) NULL, + [LastAckUser] nvarchar(128) NULL, + [LastAckComment] nvarchar(1024) NULL, + [LastAckUtc] datetime2(3) NULL, + [LastConfirmUser] nvarchar(128) NULL, + [LastConfirmComment] nvarchar(1024) NULL, + [LastConfirmUtc] datetime2(3) NULL, + [CommentsJson] nvarchar(max) NOT NULL, + [UpdatedAtUtc] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + CONSTRAINT [PK_ScriptedAlarmState] PRIMARY KEY ([ScriptedAlarmId]), + CONSTRAINT [CK_ScriptedAlarmState_CommentsJson_IsJson] CHECK (ISJSON(CommentsJson) = 1) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE TABLE [VirtualTag] ( + [VirtualTagRowId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [GenerationId] bigint NOT NULL, + [VirtualTagId] nvarchar(64) NULL, + [EquipmentId] nvarchar(64) NOT NULL, + [Name] nvarchar(128) NOT NULL, + [DataType] nvarchar(32) NOT NULL, + [ScriptId] nvarchar(64) NOT NULL, + [ChangeTriggered] bit NOT NULL, + [TimerIntervalMs] int NULL, + [Historize] bit NOT NULL, + [Enabled] bit NOT NULL, + CONSTRAINT [PK_VirtualTag] PRIMARY KEY ([VirtualTagRowId]), + CONSTRAINT [CK_VirtualTag_TimerInterval_Min] CHECK (TimerIntervalMs IS NULL OR TimerIntervalMs >= 50), + CONSTRAINT [CK_VirtualTag_Trigger_AtLeastOne] CHECK (ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL), + CONSTRAINT [FK_VirtualTag_ConfigGeneration_GenerationId] FOREIGN KEY ([GenerationId]) REFERENCES [ConfigGeneration] ([GenerationId]) ON DELETE NO ACTION + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE INDEX [IX_Script_Generation_SourceHash] ON [Script] ([GenerationId], [SourceHash]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Script_Generation_LogicalId] ON [Script] ([GenerationId], [ScriptId]) WHERE [ScriptId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE INDEX [IX_ScriptedAlarm_Generation_Script] ON [ScriptedAlarm] ([GenerationId], [PredicateScriptId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE UNIQUE INDEX [UX_ScriptedAlarm_Generation_EquipmentPath] ON [ScriptedAlarm] ([GenerationId], [EquipmentId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ScriptedAlarm_Generation_LogicalId] ON [ScriptedAlarm] ([GenerationId], [ScriptedAlarmId]) WHERE [ScriptedAlarmId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE INDEX [IX_VirtualTag_Generation_Script] ON [VirtualTag] ([GenerationId], [ScriptId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + CREATE UNIQUE INDEX [UX_VirtualTag_Generation_EquipmentPath] ON [VirtualTag] ([GenerationId], [EquipmentId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_VirtualTag_Generation_LogicalId] ON [VirtualTag] ([GenerationId], [VirtualTagId]) WHERE [VirtualTagId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420231641_AddPhase7ScriptingTables' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260420231641_AddPhase7ScriptingTables', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420232000_ExtendComputeGenerationDiffWithPhase7' +) +BEGIN + + CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff + @FromGenerationId bigint, + @ToGenerationId bigint + AS + BEGIN + SET NOCOUNT ON; + + CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), 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(128), 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(128), 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(128), 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(128), 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 CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId, + CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig + FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId), + t AS ( + SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId, + CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig + FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId) + INSERT #diff + SELECT 'NodeAcl', 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; + + -- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename + -- via Name; Language future-proofs for non-C# engines. Same Name + same Source = + -- Unchanged (identical hash). + WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId), + t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId) + INSERT #diff + SELECT 'Script', CONVERT(nvarchar(128), 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; + + -- Phase 7 — VirtualTag section. + WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId), + t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId) + INSERT #diff + SELECT 'VirtualTag', CONVERT(nvarchar(128), 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; + + -- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is + -- logical-id keyed outside the generation scope + intentionally excluded here — + -- diffing ack state between generations is semantically meaningless. + WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId), + t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId) + INSERT #diff + SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), 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 + +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260420232000_ExtendComputeGenerationDiffWithPhase7' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260420232000_ExtendComputeGenerationDiffWithPhase7', N'10.0.7'); +END; + +COMMIT; +GO + +BEGIN TRANSACTION; +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Device] DROP CONSTRAINT [FK_Device_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [DriverInstance] DROP CONSTRAINT [FK_DriverInstance_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Equipment] DROP CONSTRAINT [FK_Equipment_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Namespace] DROP CONSTRAINT [FK_Namespace_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [NodeAcl] DROP CONSTRAINT [FK_NodeAcl_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [PollGroup] DROP CONSTRAINT [FK_PollGroup_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Script] DROP CONSTRAINT [FK_Script_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [ScriptedAlarm] DROP CONSTRAINT [FK_ScriptedAlarm_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Tag] DROP CONSTRAINT [FK_Tag_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [UnsArea] DROP CONSTRAINT [FK_UnsArea_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [UnsLine] DROP CONSTRAINT [FK_UnsLine_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [VirtualTag] DROP CONSTRAINT [FK_VirtualTag_ConfigGeneration_GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP TABLE [ClusterNodeGenerationState]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP TABLE [ConfigGeneration]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_VirtualTag_Generation_Script] ON [VirtualTag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_VirtualTag_Generation_EquipmentPath] ON [VirtualTag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_VirtualTag_Generation_LogicalId] ON [VirtualTag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_UnsLine_Generation_Area] ON [UnsLine]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_UnsLine_Generation_AreaName] ON [UnsLine]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_UnsLine_Generation_LogicalId] ON [UnsLine]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_UnsArea_Generation_Cluster] ON [UnsArea]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_UnsArea_Generation_ClusterName] ON [UnsArea]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_UnsArea_Generation_LogicalId] ON [UnsArea]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Tag_Generation_Driver_Device] ON [Tag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Tag_Generation_Equipment] ON [Tag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Tag_Generation_EquipmentPath] ON [Tag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Tag_Generation_FolderPath] ON [Tag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Tag_Generation_LogicalId] ON [Tag]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_ScriptedAlarm_Generation_Script] ON [ScriptedAlarm]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_ScriptedAlarm_Generation_EquipmentPath] ON [ScriptedAlarm]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_ScriptedAlarm_Generation_LogicalId] ON [ScriptedAlarm]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Script_Generation_SourceHash] ON [Script]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Script_Generation_LogicalId] ON [Script]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_PollGroup_Generation_Driver] ON [PollGroup]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_PollGroup_Generation_LogicalId] ON [PollGroup]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_NodeAcl_Generation_Cluster] ON [NodeAcl]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_NodeAcl_Generation_Group] ON [NodeAcl]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_NodeAcl_Generation_Scope] ON [NodeAcl]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_NodeAcl_Generation_GroupScope] ON [NodeAcl]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_NodeAcl_Generation_LogicalId] ON [NodeAcl]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Namespace_Generation_Cluster] ON [Namespace]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Namespace_Generation_Cluster_Kind] ON [Namespace]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Namespace_Generation_LogicalId] ON [Namespace]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Namespace_Generation_LogicalId_Cluster] ON [Namespace]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Namespace_Generation_NamespaceUri] ON [Namespace]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Equipment_Generation_Driver] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Equipment_Generation_Line] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Equipment_Generation_MachineCode] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Equipment_Generation_SAPID] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Equipment_Generation_ZTag] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Equipment_Generation_LinePath] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Equipment_Generation_LogicalId] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Equipment_Generation_Uuid] ON [Equipment]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_DriverInstance_Generation_Cluster] ON [DriverInstance]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_DriverInstance_Generation_Namespace] ON [DriverInstance]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_DriverInstance_Generation_LogicalId] ON [DriverInstance]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [IX_Device_Generation_Driver] ON [Device]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_Device_Generation_LogicalId] ON [Device]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DROP INDEX [UX_ClusterNode_Primary_Per_Cluster] ON [ClusterNode]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var nvarchar(max); + SELECT @var = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[VirtualTag]') AND [c].[name] = N'GenerationId'); + IF @var IS NOT NULL EXEC(N'ALTER TABLE [VirtualTag] DROP CONSTRAINT ' + @var + ';'); + ALTER TABLE [VirtualTag] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var1 nvarchar(max); + SELECT @var1 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[UnsLine]') AND [c].[name] = N'GenerationId'); + IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [UnsLine] DROP CONSTRAINT ' + @var1 + ';'); + ALTER TABLE [UnsLine] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var2 nvarchar(max); + SELECT @var2 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[UnsArea]') AND [c].[name] = N'GenerationId'); + IF @var2 IS NOT NULL EXEC(N'ALTER TABLE [UnsArea] DROP CONSTRAINT ' + @var2 + ';'); + ALTER TABLE [UnsArea] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var3 nvarchar(max); + SELECT @var3 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Tag]') AND [c].[name] = N'GenerationId'); + IF @var3 IS NOT NULL EXEC(N'ALTER TABLE [Tag] DROP CONSTRAINT ' + @var3 + ';'); + ALTER TABLE [Tag] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var4 nvarchar(max); + SELECT @var4 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[ScriptedAlarm]') AND [c].[name] = N'GenerationId'); + IF @var4 IS NOT NULL EXEC(N'ALTER TABLE [ScriptedAlarm] DROP CONSTRAINT ' + @var4 + ';'); + ALTER TABLE [ScriptedAlarm] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var5 nvarchar(max); + SELECT @var5 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Script]') AND [c].[name] = N'GenerationId'); + IF @var5 IS NOT NULL EXEC(N'ALTER TABLE [Script] DROP CONSTRAINT ' + @var5 + ';'); + ALTER TABLE [Script] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var6 nvarchar(max); + SELECT @var6 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[PollGroup]') AND [c].[name] = N'GenerationId'); + IF @var6 IS NOT NULL EXEC(N'ALTER TABLE [PollGroup] DROP CONSTRAINT ' + @var6 + ';'); + ALTER TABLE [PollGroup] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var7 nvarchar(max); + SELECT @var7 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[NodeAcl]') AND [c].[name] = N'GenerationId'); + IF @var7 IS NOT NULL EXEC(N'ALTER TABLE [NodeAcl] DROP CONSTRAINT ' + @var7 + ';'); + ALTER TABLE [NodeAcl] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var8 nvarchar(max); + SELECT @var8 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Namespace]') AND [c].[name] = N'GenerationId'); + IF @var8 IS NOT NULL EXEC(N'ALTER TABLE [Namespace] DROP CONSTRAINT ' + @var8 + ';'); + ALTER TABLE [Namespace] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var9 nvarchar(max); + SELECT @var9 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Equipment]') AND [c].[name] = N'GenerationId'); + IF @var9 IS NOT NULL EXEC(N'ALTER TABLE [Equipment] DROP CONSTRAINT ' + @var9 + ';'); + ALTER TABLE [Equipment] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var10 nvarchar(max); + SELECT @var10 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[DriverInstance]') AND [c].[name] = N'GenerationId'); + IF @var10 IS NOT NULL EXEC(N'ALTER TABLE [DriverInstance] DROP CONSTRAINT ' + @var10 + ';'); + ALTER TABLE [DriverInstance] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var11 nvarchar(max); + SELECT @var11 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Device]') AND [c].[name] = N'GenerationId'); + IF @var11 IS NOT NULL EXEC(N'ALTER TABLE [Device] DROP CONSTRAINT ' + @var11 + ';'); + ALTER TABLE [Device] DROP COLUMN [GenerationId]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + DECLARE @var12 nvarchar(max); + SELECT @var12 = QUOTENAME([d].[name]) + FROM [sys].[default_constraints] [d] + INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] + WHERE ([d].[parent_object_id] = OBJECT_ID(N'[ClusterNode]') AND [c].[name] = N'RedundancyRole'); + IF @var12 IS NOT NULL EXEC(N'ALTER TABLE [ClusterNode] DROP CONSTRAINT ' + @var12 + ';'); + ALTER TABLE [ClusterNode] DROP COLUMN [RedundancyRole]; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC sp_rename N'[UnsArea].[IX_UnsArea_ClusterId]', N'IX_UnsArea_Cluster', 'INDEX'; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC sp_rename N'[Namespace].[IX_Namespace_ClusterId]', N'IX_Namespace_Cluster', 'INDEX'; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC sp_rename N'[DriverInstance].[IX_DriverInstance_ClusterId]', N'IX_DriverInstance_Cluster', 'INDEX'; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [VirtualTag] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [UnsLine] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [UnsArea] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Tag] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [ScriptedAlarm] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Script] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [PollGroup] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [NodeAcl] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Namespace] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Equipment] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [DriverInstance] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + ALTER TABLE [Device] ADD [RowVersion] rowversion NOT NULL; +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE TABLE [ConfigEdit] ( + [EditId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [EntityType] nvarchar(64) NOT NULL, + [EntityId] uniqueidentifier NOT NULL, + [FieldsJson] nvarchar(max) NOT NULL, + [ExecutionId] uniqueidentifier NULL, + [EditedBy] nvarchar(128) NOT NULL, + [EditedAtUtc] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [SourceNode] nvarchar(64) NOT NULL, + CONSTRAINT [PK_ConfigEdit] PRIMARY KEY ([EditId]), + CONSTRAINT [CK_ConfigEdit_FieldsJson_IsJson] CHECK (ISJSON(FieldsJson) = 1) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE TABLE [DataProtectionKeys] ( + [Id] int NOT NULL IDENTITY, + [FriendlyName] nvarchar(max) NULL, + [Xml] nvarchar(max) NULL, + CONSTRAINT [PK_DataProtectionKeys] PRIMARY KEY ([Id]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE TABLE [Deployment] ( + [DeploymentId] uniqueidentifier NOT NULL DEFAULT (NEWSEQUENTIALID()), + [RevisionHash] nvarchar(64) NOT NULL, + [Status] int NOT NULL, + [CreatedBy] nvarchar(128) NOT NULL, + [CreatedAtUtc] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [ArtifactBlob] varbinary(max) NOT NULL, + [RowVersion] rowversion NOT NULL, + [FailureReason] nvarchar(2048) NULL, + [SealedAtUtc] datetime2(3) NULL, + CONSTRAINT [PK_Deployment] PRIMARY KEY ([DeploymentId]) + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE TABLE [NodeDeploymentState] ( + [NodeId] nvarchar(64) NOT NULL, + [DeploymentId] uniqueidentifier NOT NULL, + [Status] int NOT NULL, + [StartedAtUtc] datetime2(3) NOT NULL DEFAULT (SYSUTCDATETIME()), + [AppliedAtUtc] datetime2(3) NULL, + [FailureReason] nvarchar(2048) NULL, + [RowVersion] rowversion NOT NULL, + CONSTRAINT [PK_NodeDeploymentState] PRIMARY KEY ([NodeId], [DeploymentId]), + CONSTRAINT [FK_NodeDeploymentState_ClusterNode_NodeId] FOREIGN KEY ([NodeId]) REFERENCES [ClusterNode] ([NodeId]) ON DELETE NO ACTION, + CONSTRAINT [FK_NodeDeploymentState_Deployment_DeploymentId] FOREIGN KEY ([DeploymentId]) REFERENCES [Deployment] ([DeploymentId]) ON DELETE CASCADE + ); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_VirtualTag_Script] ON [VirtualTag] ([ScriptId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_VirtualTag_EquipmentPath] ON [VirtualTag] ([EquipmentId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_VirtualTag_LogicalId] ON [VirtualTag] ([VirtualTagId]) WHERE [VirtualTagId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_UnsLine_Area] ON [UnsLine] ([UnsAreaId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_UnsLine_AreaName] ON [UnsLine] ([UnsAreaId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_UnsLine_LogicalId] ON [UnsLine] ([UnsLineId]) WHERE [UnsLineId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_UnsArea_ClusterName] ON [UnsArea] ([ClusterId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_UnsArea_LogicalId] ON [UnsArea] ([UnsAreaId]) WHERE [UnsAreaId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Tag_Driver_Device] ON [Tag] ([DriverInstanceId], [DeviceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Tag_Equipment] ON [Tag] ([EquipmentId]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_EquipmentPath] ON [Tag] ([EquipmentId], [Name]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_FolderPath] ON [Tag] ([DriverInstanceId], [FolderPath], [Name]) WHERE [EquipmentId] IS NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Tag_LogicalId] ON [Tag] ([TagId]) WHERE [TagId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_ScriptedAlarm_Script] ON [ScriptedAlarm] ([PredicateScriptId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_ScriptedAlarm_EquipmentPath] ON [ScriptedAlarm] ([EquipmentId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_ScriptedAlarm_LogicalId] ON [ScriptedAlarm] ([ScriptedAlarmId]) WHERE [ScriptedAlarmId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Script_SourceHash] ON [Script] ([SourceHash]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Script_LogicalId] ON [Script] ([ScriptId]) WHERE [ScriptId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_PollGroup_Driver] ON [PollGroup] ([DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_PollGroup_LogicalId] ON [PollGroup] ([PollGroupId]) WHERE [PollGroupId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_NodeAcl_Cluster] ON [NodeAcl] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_NodeAcl_Group] ON [NodeAcl] ([LdapGroup]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE INDEX [IX_NodeAcl_Scope] ON [NodeAcl] ([ScopeKind], [ScopeId]) WHERE [ScopeId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_NodeAcl_GroupScope] ON [NodeAcl] ([ClusterId], [LdapGroup], [ScopeKind], [ScopeId]) WHERE [ScopeId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_NodeAcl_LogicalId] ON [NodeAcl] ([NodeAclId]) WHERE [NodeAclId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_Namespace_Cluster_Kind] ON [Namespace] ([ClusterId], [Kind]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Namespace_LogicalId] ON [Namespace] ([NamespaceId]) WHERE [NamespaceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_Namespace_NamespaceUri] ON [Namespace] ([NamespaceUri]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Equipment_Driver] ON [Equipment] ([DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Equipment_Line] ON [Equipment] ([UnsLineId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Equipment_MachineCode] ON [Equipment] ([MachineCode]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Equipment_SAPID] ON [Equipment] ([SAPID]) WHERE [SAPID] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE INDEX [IX_Equipment_ZTag] ON [Equipment] ([ZTag]) WHERE [ZTag] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_Equipment_LinePath] ON [Equipment] ([UnsLineId], [Name]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Equipment_LogicalId] ON [Equipment] ([EquipmentId]) WHERE [EquipmentId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE UNIQUE INDEX [UX_Equipment_Uuid] ON [Equipment] ([EquipmentUuid]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_DriverInstance_Namespace] ON [DriverInstance] ([NamespaceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_DriverInstance_LogicalId] ON [DriverInstance] ([DriverInstanceId]) WHERE [DriverInstanceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Device_Driver] ON [Device] ([DriverInstanceId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE UNIQUE INDEX [UX_Device_LogicalId] ON [Device] ([DeviceId]) WHERE [DeviceId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_ClusterNode_ClusterId] ON [ClusterNode] ([ClusterId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_ConfigEdit_EditedAt] ON [ConfigEdit] ([EditedAtUtc]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_ConfigEdit_Entity] ON [ConfigEdit] ([EntityType], [EntityId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + EXEC(N'CREATE INDEX [IX_ConfigEdit_Execution] ON [ConfigEdit] ([ExecutionId]) WHERE [ExecutionId] IS NOT NULL'); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Deployment_CreatedAt] ON [Deployment] ([CreatedAtUtc]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_Deployment_Status] ON [Deployment] ([Status]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_NodeDeploymentState_Deployment] ON [NodeDeploymentState] ([DeploymentId]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + CREATE INDEX [IX_NodeDeploymentState_Status] ON [NodeDeploymentState] ([Status]); +END; + +IF NOT EXISTS ( + SELECT * FROM [__EFMigrationsHistory] + WHERE [MigrationId] = N'20260526081556_V2HostingAlignment' +) +BEGIN + INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) + VALUES (N'20260526081556_V2HostingAlignment', N'10.0.7'); +END; + +COMMIT; +GO + diff --git a/scripts/migration/count-rows.sql b/scripts/migration/count-rows.sql new file mode 100644 index 0000000..7b52c86 --- /dev/null +++ b/scripts/migration/count-rows.sql @@ -0,0 +1,26 @@ +-- Per-table row counts for pre/post-migration audit. +-- Covers every table relevant to the v1 -> v2 transition so the operator can confirm +-- live-edit data was preserved and v2 tables came up empty. + +SELECT TableName = t.name, [Rows] = SUM(p.[rows]) +FROM sys.tables t +JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0,1) +WHERE t.name IN ( + -- Live-edit configuration (rows must survive) + 'ServerCluster','ClusterNode','ClusterNodeCredential', + 'Namespace','UnsArea','UnsLine', + 'DriverInstance','Device','Equipment','Tag','PollGroup','VirtualTag', + 'NodeAcl','ExternalIdReservation', + 'Script','ScriptedAlarm','ScriptedAlarmState', + 'LdapGroupRoleMapping', + 'EquipmentImportBatch','EquipmentImportRow', + -- Status tables (rebuilt at runtime; counts informational) + 'DriverHostStatus','DriverInstanceResilienceStatus', + -- Audit (preserved) + 'ConfigAuditLog', + -- v2 deploy model (empty pre-migration, populated post) + 'Deployment','NodeDeploymentState','ConfigEdit','DataProtectionKeys' +) +GROUP BY t.name +ORDER BY t.name; +GO diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj index 5802a5b..fa00ef1 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj @@ -9,9 +9,9 @@ - - - + + + diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj index 57c7dff..4ad0202 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj index 550a4fd..1bceb59 100644 --- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj +++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj @@ -9,17 +9,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -35,4 +35,11 @@ + + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptions.cs new file mode 100644 index 0000000..89a7cc7 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/AkkaClusterOptions.cs @@ -0,0 +1,26 @@ +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +public sealed class AkkaClusterOptions +{ + public const string SectionName = "Cluster"; + + public string SystemName { get; set; } = "otopcua"; + public string Hostname { get; set; } = "0.0.0.0"; + public int Port { get; set; } = 4053; + + /// + /// Hostname advertised in cluster gossip. Must be reachable by other nodes. + /// In docker-compose this is the container DNS name; in bare metal it's the + /// host's stable LAN address. + /// + public string PublicHostname { get; set; } = "127.0.0.1"; + + public string[] SeedNodes { get; set; } = Array.Empty(); + + /// + /// Cluster roles for this node. When empty the role list comes from + /// OTOPCUA_ROLES via . Allowed values: + /// admin, driver, dev. + /// + public string[] Roles { get; set; } = Array.Empty(); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs new file mode 100644 index 0000000..ef1a145 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs @@ -0,0 +1,188 @@ +using Akka.Actor; +using Akka.Cluster; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId; + +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +/// +/// Thread-safe live view of cluster membership and role topology. Subscribes to +/// , , and +/// through an internal subscriber actor and keeps +/// a snapshot of role-to-members + role-to-leader. The CLR-facing event surface is +/// . +/// +public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable +{ + private readonly Akka.Cluster.Cluster _cluster; + private readonly ILogger _logger; + private readonly CommonsNodeId _localNode; + private readonly HashSet _localRoles; + private readonly object _lock = new(); + private readonly Dictionary _roleLeaders = new(StringComparer.Ordinal); + private readonly Dictionary> _membersByRole = new(StringComparer.Ordinal); + private IActorRef? _subscriber; + + public ClusterRoleInfo(ActorSystem system, IOptions options, ILogger logger) + { + _cluster = Akka.Cluster.Cluster.Get(system); + _logger = logger; + // NodeId encodes host:port so cluster members on shared hosts (test loopback, dev VMs + // sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port + // suffix is harmless redundancy. + _localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}"); + _localRoles = new HashSet(options.Value.Roles, StringComparer.Ordinal); + + SeedFromCurrentState(); + _subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber"); + } + + public CommonsNodeId LocalNode => _localNode; + + public IReadOnlySet LocalRoles => _localRoles; + + public bool HasRole(string role) => _localRoles.Contains(role); + + public IReadOnlyList MembersWithRole(string role) + { + lock (_lock) + { + if (!_membersByRole.TryGetValue(role, out var members)) return Array.Empty(); + return members + .Select(m => ToNodeId(m.Address)) + .ToArray(); + } + } + + public CommonsNodeId? RoleLeader(string role) + { + lock (_lock) + { + return _roleLeaders.TryGetValue(role, out var leader) && leader is not null + ? ToNodeId(leader.Address) + : (CommonsNodeId?)null; + } + } + + public event EventHandler? RoleLeaderChanged; + + private void SeedFromCurrentState() + { + var snapshot = _cluster.State; + lock (_lock) + { + foreach (var member in snapshot.Members) + foreach (var role in member.Roles) + { + if (!_membersByRole.TryGetValue(role, out var set)) + _membersByRole[role] = set = new HashSet(); + set.Add(member); + } + + foreach (var role in snapshot.Members.SelectMany(m => m.Roles).Distinct()) + { + var leaderAddr = _cluster.State.RoleLeader(role); + _roleLeaders[role] = leaderAddr is not null + ? snapshot.Members.FirstOrDefault(m => m.Address == leaderAddr) + : null; + } + } + } + + internal void HandleMemberEvent(ClusterEvent.IMemberEvent evt) + { + lock (_lock) + { + switch (evt) + { + case ClusterEvent.MemberUp up: + foreach (var role in up.Member.Roles) + { + if (!_membersByRole.TryGetValue(role, out var set)) + _membersByRole[role] = set = new HashSet(); + set.Add(up.Member); + } + break; + case ClusterEvent.MemberRemoved removed: + foreach (var role in removed.Member.Roles) + if (_membersByRole.TryGetValue(role, out var set)) + set.Remove(removed.Member); + break; + } + } + } + + internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt) + { + CommonsNodeId? previous = null; + CommonsNodeId? next = null; + var raise = false; + + lock (_lock) + { + _roleLeaders.TryGetValue(evt.Role, out var prevMember); + if (prevMember is not null) + previous = ToNodeId(prevMember.Address); + + var nextMember = evt.Leader is null + ? null + : _cluster.State.Members.FirstOrDefault(m => m.Address == evt.Leader); + + _roleLeaders[evt.Role] = nextMember; + if (nextMember is not null) + next = ToNodeId(nextMember.Address); + + raise = !Nullable.Equals(previous, next); + } + + if (!raise) return; + try + { + RoleLeaderChanged?.Invoke(this, new RoleLeaderChangedEventArgs + { + Role = evt.Role, + PreviousLeader = previous, + NewLeader = next, + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "RoleLeaderChanged subscriber threw for role {Role}", evt.Role); + } + } + + private static CommonsNodeId ToNodeId(Akka.Actor.Address address) => + CommonsNodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}"); + + public void Dispose() + { + _subscriber?.Tell(PoisonPill.Instance); + _subscriber = null; + } + + private sealed class SubscriberActor : ReceiveActor + { + public SubscriberActor(ClusterRoleInfo owner) + { + Receive(e => owner.HandleMemberEvent(e)); + Receive(e => owner.HandleRoleLeaderChanged(e)); + Receive(_ => { /* no-op for now; reserved for ServiceLevel calc */ }); + Receive(_ => { /* seeded from initial snapshot */ }); + } + + protected override void PreStart() + { + Akka.Cluster.Cluster.Get(Context.System).Subscribe( + Self, + ClusterEvent.InitialStateAsEvents, + typeof(ClusterEvent.IMemberEvent), + typeof(ClusterEvent.LeaderChanged), + typeof(ClusterEvent.RoleLeaderChanged)); + } + + protected override void PostStop() => + Akka.Cluster.Cluster.Get(Context.System).Unsubscribe(Self); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/HoconLoader.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/HoconLoader.cs new file mode 100644 index 0000000..23f1b00 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/HoconLoader.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +public static class HoconLoader +{ + private const string ResourceName = "ZB.MOM.WW.OtOpcUa.Cluster.Resources.akka.conf"; + + public static string LoadBaseConfig() + { + using var stream = typeof(HoconLoader).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"Embedded resource '{ResourceName}' not found. Verify EmbeddedResource glob in csproj."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf new file mode 100644 index 0000000..46b9711 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf @@ -0,0 +1,73 @@ +# Base Akka.NET cluster configuration for OtOpcUa fused-host nodes. +# +# Roles, seed nodes, public hostname/port, and the actor system name are overlaid +# at runtime by AkkaHostedService — see ZB.MOM.WW.OtOpcUa.Cluster/AkkaHostedService.cs. +# Everything else here is the cluster-wide tuning that should match across nodes. +# +# Tuning sourced from ScadaLink (ScadaLink.Host/Actors/AkkaHostedService.BuildHocon); +# any divergence must be deliberate and recorded in docs/v2/Architecture.md. + +akka { + extensions = [ + "Akka.Cluster.Tools.PublishSubscribe.DistributedPubSubExtensionProvider, Akka.Cluster.Tools" + ] + + actor { + provider = cluster + } + + remote { + dot-netty.tcp { + hostname = "0.0.0.0" + port = 4053 + } + transport-failure-detector { + heartbeat-interval = 2s + acceptable-heartbeat-pause = 10s + } + } + + cluster { + seed-nodes = [] + roles = [] + min-nr-of-members = 1 + + split-brain-resolver { + active-strategy = "keep-oldest" + stable-after = 15s + keep-oldest { + down-if-alone = on + } + } + + failure-detector { + heartbeat-interval = 2s + threshold = 10.0 + acceptable-heartbeat-pause = 10s + } + + down-removal-margin = 15s + run-coordinated-shutdown-when-down = on + + singleton { + singleton-name = "singleton" + } + singleton-proxy { + singleton-identification-interval = 1s + } + } + + coordinated-shutdown { + run-by-clr-shutdown-hook = on + default-phase-timeout = 30s + } +} + +# Pinned dispatcher used by OpcUaPublishActor (Task 44) so the OPC UA SDK sees +# only one thread per actor instance — its session/subscription locks expect +# strict single-threaded access. +opcua-synchronized-dispatcher { + type = "PinnedDispatcher" + executor = "thread-pool-executor" + throughput = 1 +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs new file mode 100644 index 0000000..c233a19 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/RoleParser.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +public static class RoleParser +{ + private static readonly HashSet Allowed = new(StringComparer.Ordinal) + { + "admin", "driver", "dev", + }; + + public static string[] Parse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return Array.Empty(); + + var roles = raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(r => r.ToLowerInvariant()) + .Distinct() + .ToArray(); + + foreach (var r in roles) + { + if (!Allowed.Contains(r)) + throw new ArgumentException( + $"Unknown role '{r}'. Allowed: {string.Join(", ", Allowed)}.", nameof(raw)); + } + + return roles; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..eb743d0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ServiceCollectionExtensions.cs @@ -0,0 +1,67 @@ +using Akka.Cluster.Hosting; +using Akka.Hosting; +using Akka.Remote.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +namespace ZB.MOM.WW.OtOpcUa.Cluster; + +public static class ServiceCollectionExtensions +{ + /// + /// Binds and registers . The + /// actual ActorSystem + cluster bootstrap is layered on inside the host's AddAkka(...) + /// configurator via — keeping the entire Akka graph + /// under Akka.Hosting's management so cluster singletons land on the same ActorSystem. + /// + public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(AkkaClusterOptions.SectionName)); + + services.AddSingleton(); + + return services; + } + + /// + /// Configures the Akka.Hosting builder with the embedded OtOpcUa HOCON (split-brain resolver, + /// pinned dispatcher, failure detector tuning) + remote endpoint + cluster bootstrap derived + /// from . + /// + /// Wire from Program.cs: + /// + /// services.AddAkka("otopcua", (ab, sp) => + /// { + /// ab.WithOtOpcUaClusterBootstrap(sp); + /// if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons(); + /// if (hasDriver) ab.WithOtOpcUaRuntimeActors(); + /// }); + /// + /// + public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap( + this AkkaConfigurationBuilder builder, + IServiceProvider serviceProvider) + { + var options = serviceProvider.GetRequiredService>().Value; + + builder.AddHocon(HoconLoader.LoadBaseConfig(), HoconAddMode.Append); + + builder.WithRemoting(new RemoteOptions + { + HostName = options.Hostname, + Port = options.Port, + PublicHostName = options.PublicHostname, + }); + + builder.WithClustering(new ClusterOptions + { + SeedNodes = options.SeedNodes, + Roles = options.Roles, + }); + + return builder; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ZB.MOM.WW.OtOpcUa.Cluster.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ZB.MOM.WW.OtOpcUa.Cluster.csproj new file mode 100644 index 0000000..234de21 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ZB.MOM.WW.OtOpcUa.Cluster.csproj @@ -0,0 +1,32 @@ + + + + ZB.MOM.WW.OtOpcUa.Cluster + true + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs new file mode 100644 index 0000000..d84d382 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IAlarmActorStateStore.cs @@ -0,0 +1,41 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Engines; + +/// +/// Persistence seam for ScriptedAlarmActor's in-memory state across actor restarts. +/// Captures only the slice the actor's 3-state machine needs (Inactive / Active / +/// Acknowledged + last transition + last-ack user). The fuller GxP audit trail +/// ('s Comments/Confirmed/Shelving) +/// stays in the production engine binding — this seam is the small surface the actor +/// consumes directly. +/// +public interface IAlarmActorStateStore +{ + Task LoadAsync(string alarmId, CancellationToken ct); + Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct); +} + +/// Persisted slice of ScriptedAlarmActor's state. Active is NOT persisted — +/// it re-derives from the evaluator on startup per Phase 7 decision #14. State here +/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up +/// Active and operator interaction had already happened. +/// Matches ScriptedAlarm.ScriptedAlarmId. +/// Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string. +/// When the actor last transitioned. +/// Who acknowledged most recently. Null when never acked. +public sealed record AlarmActorStateSnapshot( + string AlarmId, + string State, + DateTime LastTransitionUtc, + string? LastAckUser); + +/// No-op default. Bound when no production store is configured (tests, smoke runs). +/// Load returns null → actor boots Inactive; Save is a no-op so state doesn't leak. +public sealed class NullAlarmActorStateStore : IAlarmActorStateStore +{ + public static readonly NullAlarmActorStateStore Instance = new(); + private NullAlarmActorStateStore() { } + public Task LoadAsync(string alarmId, CancellationToken ct) => + Task.FromResult(null); + public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) => + Task.CompletedTask; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs new file mode 100644 index 0000000..b123936 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IScriptedAlarmEvaluator.cs @@ -0,0 +1,30 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Engines; + +/// +/// Abstraction over the scripted-alarm predicate engine. Production binds this to a +/// wrapper around ScriptedAlarmEngine from Core.ScriptedAlarms; default +/// binding is which keeps the alarm in its +/// current state (so an unconfigured node never spuriously alarms). +/// +public interface IScriptedAlarmEvaluator +{ + ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies); +} + +/// Result of one alarm-predicate evaluation. Active is only meaningful when +/// Success is true; on failure the caller should keep the prior state and log Reason. +public sealed record ScriptedAlarmEvalResult(bool Success, bool Active, string? Reason) +{ + public static ScriptedAlarmEvalResult Ok(bool active) => new(true, active, null); + public static ScriptedAlarmEvalResult Failure(string reason) => new(false, false, reason); +} + +/// Default that always returns Active = false, Success = true. Safe no-op: +/// no alarm fires when no real engine is bound. +public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator +{ + public static readonly NullScriptedAlarmEvaluator Instance = new(); + private NullScriptedAlarmEvaluator() { } + public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies) + => ScriptedAlarmEvalResult.Ok(active: false); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs new file mode 100644 index 0000000..ce0d15e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs @@ -0,0 +1,36 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Engines; + +/// +/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so +/// can stay free of Roslyn / scripting machinery and the +/// production wiring binds an adapter over VirtualTagEngine from +/// Core.VirtualTags. +/// +public interface IVirtualTagEvaluator +{ + /// + /// Evaluate against the snapshot in + /// . Implementations must not throw — script failures + /// are reported via . + /// + VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary dependencies); +} + +/// Result of one virtual-tag expression eval. Stash a Reason on every Failure so +/// callers can emit a useful ScriptLogEntry to operators. +public sealed record VirtualTagEvalResult(bool Success, object? Value, string? Reason) +{ + public static readonly VirtualTagEvalResult NoChange = new(true, null, "no-change"); + public static VirtualTagEvalResult Ok(object? value) => new(true, value, null); + public static VirtualTagEvalResult Failure(string reason) => new(false, null, reason); +} + +/// Returns from every call. Bound by default +/// when the production VirtualTagEngine adapter hasn't been registered (Mac dev, tests). +public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator +{ + public static readonly NullVirtualTagEvaluator Instance = new(); + private NullVirtualTagEvaluator() { } + public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary dependencies) + => VirtualTagEvalResult.NoChange; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/.gitkeep b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs new file mode 100644 index 0000000..3a09986 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IAdminOperationsClient.cs @@ -0,0 +1,13 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +/// +/// Cluster-singleton-proxy client for the AdminOperationsActor. The Blazor UI calls +/// this from any host (admin or driver role); the proxy routes the request to whichever node +/// holds the admin singleton. +/// +public interface IAdminOperationsClient +{ + Task StartDeploymentAsync(string createdBy, CancellationToken ct); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs new file mode 100644 index 0000000..29c1dc1 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IClusterRoleInfo.cs @@ -0,0 +1,20 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +/// +/// Live view of the local node's identity and the cluster's role topology. Implemented by +/// ClusterRoleInfo in OtOpcUa.Cluster; consumed by everything that needs to +/// distinguish admin-role vs driver-role members or react to role-leader changes (e.g. OPC UA +/// ServiceLevel computation). +/// +public interface IClusterRoleInfo +{ + NodeId LocalNode { get; } + IReadOnlySet LocalRoles { get; } + bool HasRole(string role); + IReadOnlyList MembersWithRole(string role); + NodeId? RoleLeader(string role); + + event EventHandler? RoleLeaderChanged; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IFleetDiagnosticsClient.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IFleetDiagnosticsClient.cs new file mode 100644 index 0000000..cab512a --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/IFleetDiagnosticsClient.cs @@ -0,0 +1,12 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +/// +/// Per-node diagnostics fetched on demand. Implemented in Phase 8 (AdminUI/Runtime wiring) +/// over an Akka request/response — the diagnostics actor lives on the target driver node. +/// +public interface IFleetDiagnosticsClient +{ + Task GetDiagnosticsAsync(NodeId nodeId, CancellationToken ct); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/NodeDiagnosticsSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/NodeDiagnosticsSnapshot.cs new file mode 100644 index 0000000..e31b62f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/NodeDiagnosticsSnapshot.cs @@ -0,0 +1,21 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +public sealed record DriverInstanceDiagnostics( + Guid DriverInstanceId, + string Name, + string State, + int ConnectedDevices, + int FaultedDevices, + DateTime LastChangeUtc); + +/// +/// Per-node diagnostics returned by IFleetDiagnosticsClient. Populated by the node's +/// local DriverHostActor via a request/response over Akka. +/// +public sealed record NodeDiagnosticsSnapshot( + NodeId NodeId, + RevisionHash? CurrentRevision, + IReadOnlyList Drivers, + DateTime AsOfUtc); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/RoleLeaderChangedEventArgs.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/RoleLeaderChangedEventArgs.cs new file mode 100644 index 0000000..50fb8f1 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Interfaces/RoleLeaderChangedEventArgs.cs @@ -0,0 +1,10 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Interfaces; + +public sealed class RoleLeaderChangedEventArgs : EventArgs +{ + public required string Role { get; init; } + public required NodeId? PreviousLeader { get; init; } + public required NodeId? NewLeader { get; init; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/.gitkeep b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeployment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeployment.cs new file mode 100644 index 0000000..797097c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeployment.cs @@ -0,0 +1,11 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; + +/// +/// Request from the admin UI to the AdminOperationsActor singleton asking it to snapshot +/// the current live-edit state and start a deployment. +/// +public sealed record StartDeployment( + string CreatedBy, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs new file mode 100644 index 0000000..8e72a5d --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/StartDeploymentResult.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; + +public enum StartDeploymentOutcome +{ + Accepted, + NoChanges, + AnotherDeploymentInFlight, + Rejected, +} + +/// +/// Reply from the AdminOperationsActor singleton. Accepted means the snapshot +/// was sealed and a Deployment row was created; the in-flight deployment can be +/// tracked through fleet-status broadcasts. +/// +public sealed record StartDeploymentResult( + StartDeploymentOutcome Outcome, + DeploymentId? DeploymentId, + RevisionHash? RevisionHash, + string? Message, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs new file mode 100644 index 0000000..53bb6ed --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Alerts/AlarmTransitionEvent.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts; + +/// +/// Live alarm transition published on the cluster alerts DistributedPubSub topic. +/// Emitted by ScriptedAlarmActor (and future native-alarm bridges) when an alarm condition +/// transitions; consumed by AlertSignalRBridge for browser fan-out and by historian +/// adapters for durable storage. +/// +/// Stable condition identity (matches ScriptedAlarm.ScriptedAlarmId for scripted alarms). +/// UNS path of the Equipment node the alarm hangs under. Doubles as the SourceNode. +/// Operator-visible alarm name. +/// Activated / Cleared / Acknowledged / Confirmed / Shelved / Unshelved / Disabled / Enabled / CommentAdded. +/// 1–1000 numeric severity (OPC UA convention). +/// Fully-rendered message text — template tokens already resolved. +/// Operator who triggered the transition. "system" for engine-driven events. +/// When the transition occurred. +public sealed record AlarmTransitionEvent( + string AlarmId, + string EquipmentPath, + string AlarmName, + string TransitionKind, + int Severity, + string Message, + string User, + DateTime TimestampUtc); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs new file mode 100644 index 0000000..ed12a26 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Audit/AuditEvent.cs @@ -0,0 +1,17 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Audit; + +/// +/// Cluster-broadcast audit event consumed by the AuditWriterActor singleton, which +/// batches and idempotently inserts into ConfigAuditLog. +/// +public sealed record AuditEvent( + Guid EventId, + string Category, + string Action, + string Actor, + DateTime OccurredAtUtc, + string? DetailsJson, + NodeId SourceNode, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs new file mode 100644 index 0000000..10ff6f7 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/ApplyAck.cs @@ -0,0 +1,15 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; + +public enum ApplyAckOutcome { Applied, Failed } + +/// +/// Per-node acknowledgment returned by DriverHostActor to the dispatching coordinator. +/// +public sealed record ApplyAck( + DeploymentId DeploymentId, + NodeId NodeId, + ApplyAckOutcome Outcome, + string? FailureReason, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentFailed.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentFailed.cs new file mode 100644 index 0000000..93878f9 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentFailed.cs @@ -0,0 +1,14 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; + +/// +/// Coordinator-published event indicating that the deployment failed and was rolled back. +/// Includes the set of nodes that NACKed or timed out so the admin UI can surface which +/// node(s) are sticky on the prior good revision. +/// +public sealed record DeploymentFailed( + DeploymentId DeploymentId, + string FailureReason, + IReadOnlyList FailedNodes, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentSealed.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentSealed.cs new file mode 100644 index 0000000..7f98333 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DeploymentSealed.cs @@ -0,0 +1,13 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; + +/// +/// Coordinator-published event indicating that every active driver node successfully applied +/// the deployment and the row in Deployment has been transitioned to Sealed. +/// +public sealed record DeploymentSealed( + DeploymentId DeploymentId, + RevisionHash RevisionHash, + DateTime SealedAtUtc, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DispatchDeployment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DispatchDeployment.cs new file mode 100644 index 0000000..616398f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Deploy/DispatchDeployment.cs @@ -0,0 +1,13 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; + +/// +/// Sent from the admin-role ConfigPublishCoordinator singleton to each driver node's +/// DriverHostActor. Tells the node to fetch the deployment artifact identified by +/// + and apply it. +/// +public sealed record DispatchDeployment( + DeploymentId DeploymentId, + RevisionHash RevisionHash, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs new file mode 100644 index 0000000..e5095e9 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/FleetStatusChanged.cs @@ -0,0 +1,21 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; + +public enum FleetNodeHealth { Healthy, Degraded, Unreachable } + +public sealed record FleetNodeStatus( + NodeId NodeId, + FleetNodeHealth Health, + RevisionHash? CurrentRevision, + DateTime LastSeenUtc); + +/// +/// Periodic fleet-wide status broadcast pushed by FleetStatusBroadcaster to admin UI +/// subscribers via SignalR. +/// +public sealed record FleetStatusChanged( + IReadOnlyList Nodes, + DeploymentId? CurrentDeployment, + DateTime AsOfUtc, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/GetDiagnostics.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/GetDiagnostics.cs new file mode 100644 index 0000000..a9efac9 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Fleet/GetDiagnostics.cs @@ -0,0 +1,10 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet; + +/// +/// Request a diagnostic snapshot from a per-node DriverHostActor. Sent by the admin UI's +/// IFleetDiagnosticsClient via ActorSelection over the cluster; the local +/// DriverHostActor responds with a NodeDiagnosticsSnapshot. +/// +public sealed record GetDiagnostics(CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs new file mode 100644 index 0000000..fe43f37 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Logging/ScriptLogEntry.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging; + +/// +/// One line of script log output published on the cluster script-logs DPS topic. +/// Emitted by VirtualTagActor + ScriptedAlarmActor when their hosted scripts call into +/// the runtime's logging facade; consumed by ScriptLogSignalRBridge for live +/// browser tail-style viewing. +/// +/// The Script row this entry came from (matches Script.ScriptId). +/// "Trace" / "Debug" / "Information" / "Warning" / "Error" / "Critical" — Serilog levels. +/// Operator-facing log message; template tokens already resolved. +/// When the script emitted the entry. +/// VirtualTag context, if logged from a virtual tag evaluation. Null otherwise. +/// ScriptedAlarm context, if logged from an alarm predicate. Null otherwise. +/// Equipment scope, if the script ran in a per-equipment context. Null for fleet-wide scripts. +public sealed record ScriptLogEntry( + string ScriptId, + string Level, + string Message, + DateTime TimestampUtc, + string? VirtualTagId, + string? AlarmId, + string? EquipmentId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs new file mode 100644 index 0000000..c1e6cca --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/NodeRedundancyState.cs @@ -0,0 +1,16 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; + +public enum RedundancyRole { Primary, Secondary, Detached } + +/// +/// Snapshot of a single node's redundancy state. Aggregated by RedundancyStateActor +/// to compute fleet-wide ServiceLevel. +/// +public sealed record NodeRedundancyState( + NodeId NodeId, + RedundancyRole Role, + bool IsClusterLeader, + bool IsRoleLeaderForDriver, + DateTime AsOfUtc); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/RedundancyStateChanged.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/RedundancyStateChanged.cs new file mode 100644 index 0000000..6b21638 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Redundancy/RedundancyStateChanged.cs @@ -0,0 +1,11 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Types; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; + +/// +/// Broadcast whenever the cluster's redundancy topology changes (node up/down, role-leader +/// change, partition heal). Subscribers compute their local OPC UA ServiceLevel from this. +/// +public sealed record RedundancyStateChanged( + IReadOnlyList Nodes, + CorrelationId CorrelationId); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs new file mode 100644 index 0000000..efb9909 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Observability; + +/// +/// Central + definitions for OtOpcUa. +/// All Akka actors, the OPC UA publish path, and the deploy coordinator emit through these +/// pre-created instruments so a single OpenTelemetry / Prometheus binding in Host +/// catches everything. No exporter is required — instruments are no-op until a listener +/// attaches, so tests and dev hosts pay nothing for instrumentation that nobody scrapes. +/// +/// Instrument names follow the OpenTelemetry semantic convention pattern +/// otopcua.<subsystem>.<event>. Subsystem is one of: deploy, driver, +/// virtualtag, scriptedalarm, opcua, redundancy. +/// +public static class OtOpcUaTelemetry +{ + public const string MeterName = "ZB.MOM.WW.OtOpcUa"; + public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa"; + + /// Singleton all counters/histograms hang off. + public static readonly Meter Meter = new(MeterName); + + /// Singleton used to start spans wrapping deploy/apply/rebuild. + public static readonly ActivitySource ActivitySource = new(ActivitySourceName); + + // ---------------- Deployment / driver-host coordination ---------------- + + /// Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject). + public static readonly Counter DeploymentApplied = + Meter.CreateCounter("otopcua.deploy.applied", unit: "{deployment}", + description: "Deployments applied by a driver-role node (outcome=ack|reject)."); + + /// Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject. + public static readonly Histogram DeploymentApplyDurationSec = + Meter.CreateHistogram("otopcua.deploy.apply.duration", unit: "s", + description: "Driver-role apply latency from DispatchDeployment → Ack/Reject."); + + /// DriverInstanceActor spawn count (added=new instance; stop=disposed). + public static readonly Counter DriverInstanceLifecycle = + Meter.CreateCounter("otopcua.driver.lifecycle", unit: "{event}", + description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault)."); + + // ---------------- VirtualTag / ScriptedAlarm engines ---------------- + + public static readonly Counter VirtualTagEval = + Meter.CreateCounter("otopcua.virtualtag.eval", unit: "{eval}", + description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip)."); + + public static readonly Counter ScriptedAlarmTransition = + Meter.CreateCounter("otopcua.scriptedalarm.transition", unit: "{transition}", + description: "Scripted-alarm state transitions (state=active|acknowledged|inactive)."); + + // ---------------- OPC UA address-space + redundancy ---------------- + + public static readonly Counter OpcUaSinkWrite = + Meter.CreateCounter("otopcua.opcua.sink.write", unit: "{write}", + description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild)."); + + public static readonly Counter ServiceLevelChange = + Meter.CreateCounter("otopcua.redundancy.service_level_change", unit: "{change}", + description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state."); + + // ---------------- Convenience helpers ---------------- + + /// + /// Starts a deploy span tagged with the deployment id. Caller disposes to close. Returns + /// null when no listener is attached so the call site stays cheap on undecorated builds. + /// + public static Activity? StartDeployApplySpan(string deploymentId) + { + var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal); + activity?.SetTag("otopcua.deployment_id", deploymentId); + return activity; + } + + /// Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply). + public static Activity? StartAddressSpaceRebuildSpan() + => ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs new file mode 100644 index 0000000..5cd8384 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs @@ -0,0 +1,34 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Wrapper that defers to an inner sink swapped in at +/// runtime. Needed because the production sink (SdkAddressSpaceSink) wraps an +/// OtOpcUaNodeManager that only exists after the SDK StandardServer has +/// started — but Akka actors resolve their sink dependency at construction time, before +/// the hosted service has booted the SDK. +/// +/// Bound as a singleton in DI on driver-role hosts; the OPC UA hosted service calls +/// once the server is up. Until that swap happens, every call is a +/// no-op against , so the actor stays safe to +/// receive messages from the moment it boots. +/// +public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink +{ + private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance; + + /// Swap in the production sink. Pass null to revert to the null sink + /// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager). + public void SetSink(IOpcUaAddressSpaceSink? sink) => + _inner = sink ?? NullOpcUaAddressSpaceSink.Instance; + + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) + => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc); + + public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) + => _inner.WriteAlarmState(alarmNodeId, active, acknowledged, sourceTimestampUtc); + + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) + => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); + + public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs new file mode 100644 index 0000000..092845c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredServiceLevelPublisher.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Late-binding adapter that holds an inner reference +/// swappable at runtime. Mirrors : Akka actors resolve +/// the publisher at DI time, but the production SdkServiceLevelPublisher only exists +/// after StandardServer.Start. The Host's hosted service swaps the inner once the SDK +/// is up; until then writes route through . +/// +public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher +{ + private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance; + + /// Swap the underlying publisher. Pass null to revert to the Null no-op. + public void SetInner(IServiceLevelPublisher? inner) => + _inner = inner ?? NullServiceLevelPublisher.Instance; + + public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs new file mode 100644 index 0000000..7082ddf --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs @@ -0,0 +1,46 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Abstraction over the OPC UA SDK's address space. OpcUaPublishActor consumes this +/// so the Runtime project doesn't reference Opc.Ua.Server directly — production +/// binds a real SDK-backed sink in the fused Host's wiring, dev/Mac binds the +/// no-op. +/// +public interface IOpcUaAddressSpaceSink +{ + /// Write a Variable node's current value + quality + source timestamp. + void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc); + + /// Write an alarm-condition Variable's active/acknowledged state. + void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc); + + /// + /// Ensure a folder node exists under the given parent. Used by Phase7Applier to + /// materialise the UNS Area/Line/Equipment hierarchy in the address space. When + /// is null the folder is parented under the namespace + /// root. Idempotent: calling twice with the same id is safe. + /// + void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); + + /// + /// Tear down + repopulate the address space. Called by OpcUaPublishActor after a + /// successful deployment apply so the node manager reflects the new config. Idempotent. + /// + void RebuildAddressSpace(); +} + +/// OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained +/// codes; the engine actors only need this 3-state classification. +public enum OpcUaQuality { Good, Uncertain, Bad } + +/// No-op sink. Bound by default so the actors are safe to run in dev / Mac / +/// integration tests without a real SDK behind them. +public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink +{ + public static readonly NullOpcUaAddressSpaceSink Instance = new(); + private NullOpcUaAddressSpaceSink() { } + public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } + public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { } + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } + public void RebuildAddressSpace() { } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IServiceLevelPublisher.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IServiceLevelPublisher.cs new file mode 100644 index 0000000..67f66e0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IServiceLevelPublisher.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +/// +/// Writes the OPC UA Server object's ServiceLevel Variable (0–255). Production binds +/// a sink that pokes the SDK's ServiceLevel node; tests + dev mode bind +/// which just records the most recently set level +/// for inspection. +/// +public interface IServiceLevelPublisher +{ + void Publish(byte serviceLevel); +} + +/// No-op default that retains the last-written ServiceLevel in +/// . Used by dev mode + verified by tests. +public sealed class NullServiceLevelPublisher : IServiceLevelPublisher +{ + public static readonly NullServiceLevelPublisher Instance = new(); + private NullServiceLevelPublisher() { } + public byte LastPublished { get; private set; } + public void Publish(byte serviceLevel) => LastPublished = serviceLevel; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/.gitkeep b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs new file mode 100644 index 0000000..da7894e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/CorrelationId.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +public readonly record struct CorrelationId(Guid Value) +{ + public static CorrelationId NewId() => new(Guid.NewGuid()); + public override string ToString() => Value.ToString("N"); + public static CorrelationId Parse(string s) => new(Guid.ParseExact(s, "N")); + public static bool TryParse(string? s, out CorrelationId id) + { + if (Guid.TryParseExact(s, "N", out var g)) { id = new CorrelationId(g); return true; } + id = default; return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeploymentId.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeploymentId.cs new file mode 100644 index 0000000..8d74194 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeploymentId.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +public readonly record struct DeploymentId(Guid Value) +{ + public static DeploymentId NewId() => new(Guid.NewGuid()); + public override string ToString() => Value.ToString("N"); + public static DeploymentId Parse(string s) => new(Guid.ParseExact(s, "N")); + public static bool TryParse(string? s, out DeploymentId id) + { + if (Guid.TryParseExact(s, "N", out var g)) { id = new DeploymentId(g); return true; } + id = default; return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/ExecutionId.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/ExecutionId.cs new file mode 100644 index 0000000..7920a66 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/ExecutionId.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +public readonly record struct ExecutionId(Guid Value) +{ + public static ExecutionId NewId() => new(Guid.NewGuid()); + public override string ToString() => Value.ToString("N"); + public static ExecutionId Parse(string s) => new(Guid.ParseExact(s, "N")); + public static bool TryParse(string? s, out ExecutionId id) + { + if (Guid.TryParseExact(s, "N", out var g)) { id = new ExecutionId(g); return true; } + id = default; return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs new file mode 100644 index 0000000..273f7a3 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/NodeId.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// Logical cluster node identifier — typically the host name configured on a fused +/// OtOpcUa.Host instance. NOT to be confused with OPC UA NodeId from the +/// Opc.Ua.Core stack. +/// +public readonly record struct NodeId(string Value) +{ + public override string ToString() => Value; + public static NodeId Parse(string s) => + string.IsNullOrWhiteSpace(s) + ? throw new ArgumentException("NodeId value cannot be empty.", nameof(s)) + : new NodeId(s); + public static bool TryParse(string? s, out NodeId id) + { + if (!string.IsNullOrWhiteSpace(s)) { id = new NodeId(s); return true; } + id = default; return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RevisionHash.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RevisionHash.cs new file mode 100644 index 0000000..30440eb --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RevisionHash.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Commons.Types; + +/// +/// SHA-256 hex digest identifying a config snapshot revision. Storage form is lowercase +/// 64-char hex (no 0x prefix). Empty hash is invalid. +/// +public readonly record struct RevisionHash(string Value) +{ + public override string ToString() => Value; + public static RevisionHash Parse(string s) => + string.IsNullOrWhiteSpace(s) + ? throw new ArgumentException("RevisionHash value cannot be empty.", nameof(s)) + : new RevisionHash(s); + public static bool TryParse(string? s, out RevisionHash hash) + { + if (!string.IsNullOrWhiteSpace(s)) { hash = new RevisionHash(s); return true; } + hash = default; return false; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj new file mode 100644 index 0000000..e92789a --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj @@ -0,0 +1,12 @@ + + + + ZB.MOM.WW.OtOpcUa.Commons + true + + + + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs deleted file mode 100644 index 585ed19..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// 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. -/// -public sealed class ApplyCallbacks -{ - public Func, CancellationToken, Task>? OnNamespace { get; init; } - public Func, CancellationToken, Task>? OnDriver { get; init; } - public Func, CancellationToken, Task>? OnDevice { get; init; } - public Func, CancellationToken, Task>? OnEquipment { get; init; } - public Func, CancellationToken, Task>? OnPollGroup { get; init; } - public Func, CancellationToken, Task>? OnTag { get; init; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs deleted file mode 100644 index 56f3618..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -public enum ChangeKind -{ - Added, - Removed, - Modified, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs deleted file mode 100644 index 982b026..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier -{ - public async Task ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct) - { - var diff = GenerationDiffer.Compute(from, to); - var errors = new List(); - - // 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 }) - { - // Honour cancellation between passes — a caller can abort the apply between Removed - // and Added phases even if individual callbacks don't observe the token themselves - // (Configuration-007). - ct.ThrowIfCancellationRequested(); - 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( - IReadOnlyList> changes, - ChangeKind kind, - Func, CancellationToken, Task>? callback, - List errors, - CancellationToken ct) - { - if (callback is null) return; - - foreach (var change in changes.Where(c => c.Kind == kind)) - { - try { await callback(change, ct); } - // Configuration-007: cancellation must propagate, not be silently recorded as an - // entity error. Distinguish caller cancellation (token signalled) from any - // OperationCanceledException raised independently of the caller's token, which we - // still want to surface as an entity error so a single misbehaving callback does - // not crash the entire apply. - catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); } - } - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs deleted file mode 100644 index 6813f62..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs +++ /dev/null @@ -1,70 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// Per-entity diff computed locally on the node. The enumerable order matches the dependency -/// order expected by : namespace → driver → device → equipment → -/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades -/// settle before new rows appear. -/// -public sealed record GenerationDiff( - IReadOnlyList> Namespaces, - IReadOnlyList> Drivers, - IReadOnlyList> Devices, - IReadOnlyList> Equipment, - IReadOnlyList> PollGroups, - IReadOnlyList> Tags); - -public sealed record EntityChange(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> DiffById( - IReadOnlyList from, IReadOnlyList to, - Func id, Func equal) - { - var fromById = from.ToDictionary(id); - var toById = to.ToDictionary(id); - var result = new List>(); - - 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; - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs deleted file mode 100644 index 257fc4b..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs +++ /dev/null @@ -1,23 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// Applies a 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 so the Configuration project stays free -/// of a Core/Server dependency (interface independence per decision #59). -/// -public interface IGenerationApplier -{ - Task ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct); -} - -public sealed record ApplyResult( - bool Succeeded, - GenerationDiff Diff, - IReadOnlyList Errors) -{ - public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []); - public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList errors) => new(false, diff, errors); -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs index f86fbb4..5c95ec9 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs @@ -1,5 +1,3 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; - namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; /// Physical OPC UA server node within a . @@ -10,8 +8,6 @@ public sealed class ClusterNode public required string ClusterId { get; set; } - public required RedundancyRole RedundancyRole { get; set; } - /// Machine hostname / IP. public required string Host { get; set; } @@ -47,5 +43,4 @@ public sealed class ClusterNode // Navigation public ServerCluster? Cluster { get; set; } public ICollection Credentials { get; set; } = []; - public ClusterNodeGenerationState? GenerationState { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs deleted file mode 100644 index f66bc73..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -/// -/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a -/// 2-node cluster track independently per decision #84. -/// -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; } - - /// Updated on every poll for liveness detection. - public DateTime? LastSeenAt { get; set; } - - public ClusterNode? Node { get; set; } - public ConfigGeneration? CurrentGeneration { get; set; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs index 35eaa89..627d5ee 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs @@ -22,4 +22,16 @@ public sealed class ConfigAuditLog public long? GenerationId { get; set; } public string? DetailsJson { get; set; } + + /// + /// Stable per-event identifier from AuditEvent.EventId. Filtered unique index on + /// this column gives cross-restart idempotency for the batched AuditWriterActor: a flush + /// that retries after a process crash can re-send the same EventId without producing a + /// duplicate row. Nullable so pre-v2 rows backfill cleanly. + /// + public Guid? EventId { get; set; } + + /// Correlation ID from AuditEvent.CorrelationId so an audit row joins to its + /// originating request/workflow. Nullable for the same backfill reason as . + public Guid? CorrelationId { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigEdit.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigEdit.cs new file mode 100644 index 0000000..237fcb1 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigEdit.cs @@ -0,0 +1,27 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Append-only audit row written by AdminOperationsActor on every mutating live-edit +/// operation. The ExecutionId optionally correlates a sequence of edits that ran inside one +/// admin transaction (e.g. an import batch that updates many Equipment rows). +/// +public sealed class ConfigEdit +{ + public Guid EditId { get; init; } = Guid.NewGuid(); + + public required string EntityType { get; init; } + + public Guid EntityId { get; init; } + + /// JSON payload of the column-name → new-value pairs touched by this edit. + public required string FieldsJson { get; init; } + + /// Optional correlation across edits inside a single admin operation. + public Guid? ExecutionId { get; init; } + + public required string EditedBy { get; init; } + + public DateTime EditedAtUtc { get; init; } = DateTime.UtcNow; + + public required string SourceNode { get; init; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs deleted file mode 100644 index eb9da1a..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs +++ /dev/null @@ -1,32 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -/// -/// Atomic, immutable snapshot of one cluster's configuration. -/// Per decision #82 — cluster-scoped, not fleet-scoped. -/// -public sealed class ConfigGeneration -{ - /// Monotonically increasing ID, generated by IDENTITY(1, 1). - 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; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs new file mode 100644 index 0000000..8a4afac --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Deployment.cs @@ -0,0 +1,30 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Immutable snapshot of a config artifact dispatched to every driver-role node by the +/// ConfigPublishCoordinator. Replaces the v1 ConfigGeneration draft/publish +/// row; the ArtifactBlob carries the SnapshotAndFlatten() output produced by +/// AdminOperationsActor. +/// +public sealed class Deployment +{ + public Guid DeploymentId { get; init; } = Guid.NewGuid(); + + public required string RevisionHash { get; init; } + + public DeploymentStatus Status { get; set; } = DeploymentStatus.Dispatching; + + public required string CreatedBy { get; init; } + + public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow; + + public byte[] ArtifactBlob { get; init; } = Array.Empty(); + + public byte[] RowVersion { get; set; } = Array.Empty(); + + public string? FailureReason { get; set; } + + public DateTime? SealedAtUtc { get; set; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs index 603005b..736cef3 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs @@ -5,8 +5,6 @@ public sealed class Device { public Guid DeviceRowId { get; set; } - public long GenerationId { get; set; } - public required string DeviceId { get; set; } /// Logical FK to . @@ -19,5 +17,6 @@ public sealed class Device /// Schemaless per-driver-type device config (host, port, unit ID, slot, etc.). public required string DeviceConfig { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs index f2168a3..3927622 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs @@ -5,8 +5,6 @@ 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; } @@ -45,6 +43,8 @@ public sealed class DriverInstance /// public string? ResilienceConfig { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); + public ServerCluster? Cluster { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs index adc68ae..d4cd5e6 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs @@ -9,8 +9,6 @@ public sealed class Equipment { public Guid EquipmentRowId { get; set; } - public long GenerationId { get; set; } - /// /// System-generated stable internal logical ID. Format: 'EQ-' + first 12 hex chars of EquipmentUuid. /// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125). @@ -60,5 +58,6 @@ public sealed class Equipment public bool Enabled { get; set; } = true; - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs index fea7459..c22b789 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs @@ -10,9 +10,7 @@ public sealed class Namespace { public Guid NamespaceRowId { get; set; } - public long GenerationId { get; set; } - - /// Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment". + /// Stable logical ID, e.g. "LINE3-OPCUA-equipment". Globally unique in v2. public required string NamespaceId { get; set; } public required string ClusterId { get; set; } @@ -26,6 +24,8 @@ public sealed class Namespace public string? Notes { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); + public ServerCluster? Cluster { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs index 57cb906..a69287b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs @@ -10,8 +10,6 @@ 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; } @@ -28,5 +26,6 @@ public sealed class NodeAcl public string? Notes { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeDeploymentState.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeDeploymentState.cs new file mode 100644 index 0000000..0a53094 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeDeploymentState.cs @@ -0,0 +1,29 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Per-(node, deployment) apply progress row owned by the DriverHostActor. Replaces the +/// v1 ClusterNodeGenerationState single-row-per-node model with a history +/// of every apply attempt so the ConfigPublishCoordinator can reconstruct in-flight state +/// after a failover. +/// +public sealed class NodeDeploymentState +{ + public required string NodeId { get; init; } + + public Guid DeploymentId { get; init; } + + public NodeDeploymentStatus Status { get; set; } = NodeDeploymentStatus.Applying; + + public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; + + public DateTime? AppliedAtUtc { get; set; } + + public string? FailureReason { get; set; } + + public byte[] RowVersion { get; set; } = Array.Empty(); + + public ClusterNode? Node { get; set; } + public Deployment? Deployment { get; set; } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs index 856fad2..5d6c99b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs @@ -5,8 +5,6 @@ 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; } @@ -15,5 +13,6 @@ public sealed class PollGroup public int IntervalMs { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs index 67174bf..056340e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs @@ -17,9 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; public sealed class Script { public Guid ScriptRowId { get; set; } - public long GenerationId { get; set; } - /// Stable logical id. Carries across generations. + /// Stable logical id. Globally unique in v2. public required string ScriptId { get; set; } /// Operator-friendly name for log filtering + Admin UI list view. @@ -34,5 +33,6 @@ public sealed class Script /// Language — always "CSharp" today; placeholder for future engines (Python/Lua). public string Language { get; set; } = "CSharp"; - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs index f99f4be..cb5d171 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs @@ -17,9 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; public sealed class ScriptedAlarm { public Guid ScriptedAlarmRowId { get; set; } - public long GenerationId { get; set; } - /// Stable logical id — drives AlarmConditionType.ConditionName. + /// Stable logical id — drives AlarmConditionType.ConditionName. Globally unique in v2. public required string ScriptedAlarmId { get; set; } /// Logical FK to — owner of this alarm. @@ -55,5 +54,6 @@ public sealed class ScriptedAlarm public bool Enabled { get; set; } = true; - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs index 08f429a..159bb79 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs @@ -38,5 +38,4 @@ public sealed class ServerCluster // Navigation public ICollection Nodes { get; set; } = []; public ICollection Namespaces { get; set; } = []; - public ICollection Generations { get; set; } = []; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs index 35f2c17..ec2f822 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs @@ -11,8 +11,6 @@ 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; } @@ -43,5 +41,6 @@ public sealed class Tag /// Register address / scaling / poll group / byte-order / etc. — schemaless per driver type. public required string TagConfig { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs index d1b0bd0..36fad95 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs @@ -5,8 +5,6 @@ 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; } @@ -16,6 +14,8 @@ public sealed class UnsArea public string? Notes { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); + public ServerCluster? Cluster { get; set; } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs index 1a41b74..d95e7d1 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs @@ -5,11 +5,9 @@ public sealed class UnsLine { public Guid UnsLineRowId { get; set; } - public long GenerationId { get; set; } - public required string UnsLineId { get; set; } - /// Logical FK to ; resolved within the same generation. + /// Logical FK to . public required string UnsAreaId { get; set; } /// UNS level 4 segment: matches ^[a-z0-9-]{1,32}$ OR equals literal _default. @@ -17,5 +15,6 @@ public sealed class UnsLine public string? Notes { get; set; } - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs index eff66a6..bf160cc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs @@ -21,9 +21,8 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; public sealed class VirtualTag { public Guid VirtualTagRowId { get; set; } - public long GenerationId { get; set; } - /// Stable logical id. + /// Stable logical id. Globally unique in v2. public required string VirtualTagId { get; set; } /// Logical FK to — owner of this virtual tag. @@ -49,5 +48,6 @@ public sealed class VirtualTag public bool Enabled { get; set; } = true; - public ConfigGeneration? Generation { get; set; } + /// Optimistic concurrency token for last-write-wins detection in the v2 live-edit model. + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DeploymentStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DeploymentStatus.cs new file mode 100644 index 0000000..e268048 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DeploymentStatus.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// Lifecycle of a deployment artifact dispatched by the v2 ConfigPublishCoordinator. +/// Replaces the v1 ConfigGeneration draft/publish lifecycle (decision tracked in the +/// v2 hosting-alignment design doc). +/// +public enum DeploymentStatus +{ + Dispatching = 0, + AwaitingApplyAcks = 1, + Sealed = 2, + PartiallyFailed = 3, + TimedOut = 4, +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs deleted file mode 100644 index 1ff8847..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -/// Generation lifecycle state. Draft → Published → Superseded | RolledBack. -public enum GenerationStatus -{ - Draft, - Published, - Superseded, - RolledBack, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs deleted file mode 100644 index 44bc0ca..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -/// Status tracked per node in . -public enum NodeApplyStatus -{ - Applied, - RolledBack, - Failed, - InProgress, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeDeploymentStatus.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeDeploymentStatus.cs new file mode 100644 index 0000000..1ce5de6 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeDeploymentStatus.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// Per-node deployment apply state. Replaces the v1 NodeApplyStatus that was attached to +/// ClusterNodeGenerationState. +/// +public enum NodeDeploymentStatus +{ + Applying = 0, + Applied = 1, + Failed = 2, +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs deleted file mode 100644 index e0e9ece..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; - -/// Per-node redundancy role within a cluster. Per decision #84. -public enum RedundancyRole -{ - Primary, - Secondary, - Standalone, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.Designer.cs new file mode 100644 index 0000000..7b1008c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.Designer.cs @@ -0,0 +1,1744 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260526081556_V2HostingAlignment")] + partial class V2HostingAlignment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("NamespaceId") + .HasDatabaseName("IX_DriverInstance_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Equipment_Driver"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_Namespace_Cluster"); + + b.HasIndex("NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_NamespaceUri"); + + b.HasIndex("ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Cluster_Kind"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b => + { + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AckedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConfirmedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("EnabledState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastAckComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastAckUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastAckUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastConfirmComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastConfirmUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastConfirmUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingExpiresUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("ScriptedAlarmId"); + + b.ToTable("ScriptedAlarmState", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_Tag_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Driver_Device"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.cs new file mode 100644 index 0000000..bbbbbd6 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526081556_V2HostingAlignment.cs @@ -0,0 +1,1562 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class V2HostingAlignment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Device_ConfigGeneration_GenerationId", + table: "Device"); + + migrationBuilder.DropForeignKey( + name: "FK_DriverInstance_ConfigGeneration_GenerationId", + table: "DriverInstance"); + + migrationBuilder.DropForeignKey( + name: "FK_Equipment_ConfigGeneration_GenerationId", + table: "Equipment"); + + migrationBuilder.DropForeignKey( + name: "FK_Namespace_ConfigGeneration_GenerationId", + table: "Namespace"); + + migrationBuilder.DropForeignKey( + name: "FK_NodeAcl_ConfigGeneration_GenerationId", + table: "NodeAcl"); + + migrationBuilder.DropForeignKey( + name: "FK_PollGroup_ConfigGeneration_GenerationId", + table: "PollGroup"); + + migrationBuilder.DropForeignKey( + name: "FK_Script_ConfigGeneration_GenerationId", + table: "Script"); + + migrationBuilder.DropForeignKey( + name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId", + table: "ScriptedAlarm"); + + migrationBuilder.DropForeignKey( + name: "FK_Tag_ConfigGeneration_GenerationId", + table: "Tag"); + + migrationBuilder.DropForeignKey( + name: "FK_UnsArea_ConfigGeneration_GenerationId", + table: "UnsArea"); + + migrationBuilder.DropForeignKey( + name: "FK_UnsLine_ConfigGeneration_GenerationId", + table: "UnsLine"); + + migrationBuilder.DropForeignKey( + name: "FK_VirtualTag_ConfigGeneration_GenerationId", + table: "VirtualTag"); + + migrationBuilder.DropTable( + name: "ClusterNodeGenerationState"); + + migrationBuilder.DropTable( + name: "ConfigGeneration"); + + migrationBuilder.DropIndex( + name: "IX_VirtualTag_Generation_Script", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "UX_VirtualTag_Generation_EquipmentPath", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "UX_VirtualTag_Generation_LogicalId", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "IX_UnsLine_Generation_Area", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "UX_UnsLine_Generation_AreaName", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "UX_UnsLine_Generation_LogicalId", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "IX_UnsArea_Generation_Cluster", + table: "UnsArea"); + + migrationBuilder.DropIndex( + name: "UX_UnsArea_Generation_ClusterName", + table: "UnsArea"); + + migrationBuilder.DropIndex( + name: "UX_UnsArea_Generation_LogicalId", + table: "UnsArea"); + + migrationBuilder.DropIndex( + name: "IX_Tag_Generation_Driver_Device", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_Tag_Generation_Equipment", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_Generation_EquipmentPath", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_Generation_FolderPath", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_Generation_LogicalId", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_ScriptedAlarm_Generation_Script", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "UX_ScriptedAlarm_Generation_EquipmentPath", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "UX_ScriptedAlarm_Generation_LogicalId", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "IX_Script_Generation_SourceHash", + table: "Script"); + + migrationBuilder.DropIndex( + name: "UX_Script_Generation_LogicalId", + table: "Script"); + + migrationBuilder.DropIndex( + name: "IX_PollGroup_Generation_Driver", + table: "PollGroup"); + + migrationBuilder.DropIndex( + name: "UX_PollGroup_Generation_LogicalId", + table: "PollGroup"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Generation_Cluster", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Generation_Group", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Generation_Scope", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "UX_NodeAcl_Generation_GroupScope", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "UX_NodeAcl_Generation_LogicalId", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "IX_Namespace_Generation_Cluster", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_Generation_Cluster_Kind", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_Generation_LogicalId", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_Generation_LogicalId_Cluster", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_Generation_NamespaceUri", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Generation_Driver", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Generation_Line", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Generation_MachineCode", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Generation_SAPID", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Generation_ZTag", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_Generation_LinePath", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_Generation_LogicalId", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_Generation_Uuid", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_DriverInstance_Generation_Cluster", + table: "DriverInstance"); + + migrationBuilder.DropIndex( + name: "IX_DriverInstance_Generation_Namespace", + table: "DriverInstance"); + + migrationBuilder.DropIndex( + name: "UX_DriverInstance_Generation_LogicalId", + table: "DriverInstance"); + + migrationBuilder.DropIndex( + name: "IX_Device_Generation_Driver", + table: "Device"); + + migrationBuilder.DropIndex( + name: "UX_Device_Generation_LogicalId", + table: "Device"); + + migrationBuilder.DropIndex( + name: "UX_ClusterNode_Primary_Per_Cluster", + table: "ClusterNode"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "VirtualTag"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "UnsLine"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "UnsArea"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "Tag"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "ScriptedAlarm"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "Script"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "PollGroup"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "NodeAcl"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "Namespace"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "Equipment"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "DriverInstance"); + + migrationBuilder.DropColumn( + name: "GenerationId", + table: "Device"); + + migrationBuilder.DropColumn( + name: "RedundancyRole", + table: "ClusterNode"); + + migrationBuilder.RenameIndex( + name: "IX_UnsArea_ClusterId", + table: "UnsArea", + newName: "IX_UnsArea_Cluster"); + + migrationBuilder.RenameIndex( + name: "IX_Namespace_ClusterId", + table: "Namespace", + newName: "IX_Namespace_Cluster"); + + migrationBuilder.RenameIndex( + name: "IX_DriverInstance_ClusterId", + table: "DriverInstance", + newName: "IX_DriverInstance_Cluster"); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "VirtualTag", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "UnsLine", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "UnsArea", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Tag", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "ScriptedAlarm", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Script", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "PollGroup", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "NodeAcl", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Namespace", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Equipment", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "DriverInstance", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "Device", + type: "rowversion", + rowVersion: true, + nullable: false, + defaultValue: new byte[0]); + + migrationBuilder.CreateTable( + name: "ConfigEdit", + columns: table => new + { + EditId = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + EntityType = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + EntityId = table.Column(type: "uniqueidentifier", nullable: false), + FieldsJson = table.Column(type: "nvarchar(max)", nullable: false), + ExecutionId = table.Column(type: "uniqueidentifier", nullable: true), + EditedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + EditedAtUtc = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + SourceNode = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ConfigEdit", x => x.EditId); + table.CheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FriendlyName = table.Column(type: "nvarchar(max)", nullable: true), + Xml = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Deployment", + columns: table => new + { + DeploymentId = table.Column(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + RevisionHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + CreatedAtUtc = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + ArtifactBlob = table.Column(type: "varbinary(max)", nullable: false), + RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: false), + FailureReason = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + SealedAtUtc = table.Column(type: "datetime2(3)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Deployment", x => x.DeploymentId); + }); + + migrationBuilder.CreateTable( + name: "NodeDeploymentState", + columns: table => new + { + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + DeploymentId = table.Column(type: "uniqueidentifier", nullable: false), + Status = table.Column(type: "int", nullable: false), + StartedAtUtc = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + AppliedAtUtc = table.Column(type: "datetime2(3)", nullable: true), + FailureReason = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NodeDeploymentState", x => new { x.NodeId, x.DeploymentId }); + table.ForeignKey( + name: "FK_NodeDeploymentState_ClusterNode_NodeId", + column: x => x.NodeId, + principalTable: "ClusterNode", + principalColumn: "NodeId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_NodeDeploymentState_Deployment_DeploymentId", + column: x => x.DeploymentId, + principalTable: "Deployment", + principalColumn: "DeploymentId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_VirtualTag_Script", + table: "VirtualTag", + column: "ScriptId"); + + migrationBuilder.CreateIndex( + name: "UX_VirtualTag_EquipmentPath", + table: "VirtualTag", + columns: new[] { "EquipmentId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_VirtualTag_LogicalId", + table: "VirtualTag", + column: "VirtualTagId", + unique: true, + filter: "[VirtualTagId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UnsLine_Area", + table: "UnsLine", + column: "UnsAreaId"); + + migrationBuilder.CreateIndex( + name: "UX_UnsLine_AreaName", + table: "UnsLine", + columns: new[] { "UnsAreaId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_UnsLine_LogicalId", + table: "UnsLine", + column: "UnsLineId", + unique: true, + filter: "[UnsLineId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_UnsArea_ClusterName", + table: "UnsArea", + columns: new[] { "ClusterId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_UnsArea_LogicalId", + table: "UnsArea", + column: "UnsAreaId", + unique: true, + filter: "[UnsAreaId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Tag_Driver_Device", + table: "Tag", + columns: new[] { "DriverInstanceId", "DeviceId" }); + + migrationBuilder.CreateIndex( + name: "IX_Tag_Equipment", + table: "Tag", + column: "EquipmentId", + filter: "[EquipmentId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Tag_EquipmentPath", + table: "Tag", + columns: new[] { "EquipmentId", "Name" }, + unique: true, + filter: "[EquipmentId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Tag_FolderPath", + table: "Tag", + columns: new[] { "DriverInstanceId", "FolderPath", "Name" }, + unique: true, + filter: "[EquipmentId] IS NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Tag_LogicalId", + table: "Tag", + column: "TagId", + unique: true, + filter: "[TagId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ScriptedAlarm_Script", + table: "ScriptedAlarm", + column: "PredicateScriptId"); + + migrationBuilder.CreateIndex( + name: "UX_ScriptedAlarm_EquipmentPath", + table: "ScriptedAlarm", + columns: new[] { "EquipmentId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_ScriptedAlarm_LogicalId", + table: "ScriptedAlarm", + column: "ScriptedAlarmId", + unique: true, + filter: "[ScriptedAlarmId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Script_SourceHash", + table: "Script", + column: "SourceHash"); + + migrationBuilder.CreateIndex( + name: "UX_Script_LogicalId", + table: "Script", + column: "ScriptId", + unique: true, + filter: "[ScriptId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PollGroup_Driver", + table: "PollGroup", + column: "DriverInstanceId"); + + migrationBuilder.CreateIndex( + name: "UX_PollGroup_LogicalId", + table: "PollGroup", + column: "PollGroupId", + unique: true, + filter: "[PollGroupId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_NodeAcl_Cluster", + table: "NodeAcl", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IX_NodeAcl_Group", + table: "NodeAcl", + column: "LdapGroup"); + + migrationBuilder.CreateIndex( + name: "IX_NodeAcl_Scope", + table: "NodeAcl", + columns: new[] { "ScopeKind", "ScopeId" }, + filter: "[ScopeId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_NodeAcl_GroupScope", + table: "NodeAcl", + columns: new[] { "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" }, + unique: true, + filter: "[ScopeId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_NodeAcl_LogicalId", + table: "NodeAcl", + column: "NodeAclId", + unique: true, + filter: "[NodeAclId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Namespace_Cluster_Kind", + table: "Namespace", + columns: new[] { "ClusterId", "Kind" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Namespace_LogicalId", + table: "Namespace", + column: "NamespaceId", + unique: true, + filter: "[NamespaceId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Namespace_NamespaceUri", + table: "Namespace", + column: "NamespaceUri", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Equipment_Driver", + table: "Equipment", + column: "DriverInstanceId"); + + migrationBuilder.CreateIndex( + name: "IX_Equipment_Line", + table: "Equipment", + column: "UnsLineId"); + + migrationBuilder.CreateIndex( + name: "IX_Equipment_MachineCode", + table: "Equipment", + column: "MachineCode"); + + migrationBuilder.CreateIndex( + name: "IX_Equipment_SAPID", + table: "Equipment", + column: "SAPID", + filter: "[SAPID] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Equipment_ZTag", + table: "Equipment", + column: "ZTag", + filter: "[ZTag] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Equipment_LinePath", + table: "Equipment", + columns: new[] { "UnsLineId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_Equipment_LogicalId", + table: "Equipment", + column: "EquipmentId", + unique: true, + filter: "[EquipmentId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "UX_Equipment_Uuid", + table: "Equipment", + column: "EquipmentUuid", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DriverInstance_Namespace", + table: "DriverInstance", + column: "NamespaceId"); + + migrationBuilder.CreateIndex( + name: "UX_DriverInstance_LogicalId", + table: "DriverInstance", + column: "DriverInstanceId", + unique: true, + filter: "[DriverInstanceId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Device_Driver", + table: "Device", + column: "DriverInstanceId"); + + migrationBuilder.CreateIndex( + name: "UX_Device_LogicalId", + table: "Device", + column: "DeviceId", + unique: true, + filter: "[DeviceId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ClusterNode_ClusterId", + table: "ClusterNode", + column: "ClusterId"); + + migrationBuilder.CreateIndex( + name: "IX_ConfigEdit_EditedAt", + table: "ConfigEdit", + column: "EditedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_ConfigEdit_Entity", + table: "ConfigEdit", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_ConfigEdit_Execution", + table: "ConfigEdit", + column: "ExecutionId", + filter: "[ExecutionId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Deployment_CreatedAt", + table: "Deployment", + column: "CreatedAtUtc"); + + migrationBuilder.CreateIndex( + name: "IX_Deployment_Status", + table: "Deployment", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_NodeDeploymentState_Deployment", + table: "NodeDeploymentState", + column: "DeploymentId"); + + migrationBuilder.CreateIndex( + name: "IX_NodeDeploymentState_Status", + table: "NodeDeploymentState", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ConfigEdit"); + + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + + migrationBuilder.DropTable( + name: "NodeDeploymentState"); + + migrationBuilder.DropTable( + name: "Deployment"); + + migrationBuilder.DropIndex( + name: "IX_VirtualTag_Script", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "UX_VirtualTag_EquipmentPath", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "UX_VirtualTag_LogicalId", + table: "VirtualTag"); + + migrationBuilder.DropIndex( + name: "IX_UnsLine_Area", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "UX_UnsLine_AreaName", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "UX_UnsLine_LogicalId", + table: "UnsLine"); + + migrationBuilder.DropIndex( + name: "UX_UnsArea_ClusterName", + table: "UnsArea"); + + migrationBuilder.DropIndex( + name: "UX_UnsArea_LogicalId", + table: "UnsArea"); + + migrationBuilder.DropIndex( + name: "IX_Tag_Driver_Device", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_Tag_Equipment", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_EquipmentPath", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_FolderPath", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "UX_Tag_LogicalId", + table: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_ScriptedAlarm_Script", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "UX_ScriptedAlarm_EquipmentPath", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "UX_ScriptedAlarm_LogicalId", + table: "ScriptedAlarm"); + + migrationBuilder.DropIndex( + name: "IX_Script_SourceHash", + table: "Script"); + + migrationBuilder.DropIndex( + name: "UX_Script_LogicalId", + table: "Script"); + + migrationBuilder.DropIndex( + name: "IX_PollGroup_Driver", + table: "PollGroup"); + + migrationBuilder.DropIndex( + name: "UX_PollGroup_LogicalId", + table: "PollGroup"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Cluster", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Group", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "IX_NodeAcl_Scope", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "UX_NodeAcl_GroupScope", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "UX_NodeAcl_LogicalId", + table: "NodeAcl"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_Cluster_Kind", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_LogicalId", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "UX_Namespace_NamespaceUri", + table: "Namespace"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Driver", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_Line", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_MachineCode", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_SAPID", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_Equipment_ZTag", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_LinePath", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_LogicalId", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "UX_Equipment_Uuid", + table: "Equipment"); + + migrationBuilder.DropIndex( + name: "IX_DriverInstance_Namespace", + table: "DriverInstance"); + + migrationBuilder.DropIndex( + name: "UX_DriverInstance_LogicalId", + table: "DriverInstance"); + + migrationBuilder.DropIndex( + name: "IX_Device_Driver", + table: "Device"); + + migrationBuilder.DropIndex( + name: "UX_Device_LogicalId", + table: "Device"); + + migrationBuilder.DropIndex( + name: "IX_ClusterNode_ClusterId", + table: "ClusterNode"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "VirtualTag"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "UnsLine"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "UnsArea"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Tag"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "ScriptedAlarm"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Script"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "PollGroup"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "NodeAcl"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Namespace"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Equipment"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "DriverInstance"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "Device"); + + migrationBuilder.RenameIndex( + name: "IX_UnsArea_Cluster", + table: "UnsArea", + newName: "IX_UnsArea_ClusterId"); + + migrationBuilder.RenameIndex( + name: "IX_Namespace_Cluster", + table: "Namespace", + newName: "IX_Namespace_ClusterId"); + + migrationBuilder.RenameIndex( + name: "IX_DriverInstance_Cluster", + table: "DriverInstance", + newName: "IX_DriverInstance_ClusterId"); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "VirtualTag", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "UnsLine", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "UnsArea", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "Tag", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "ScriptedAlarm", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "Script", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "PollGroup", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "NodeAcl", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "Namespace", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "Equipment", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "DriverInstance", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "GenerationId", + table: "Device", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "RedundancyRole", + table: "ClusterNode", + type: "nvarchar(16)", + maxLength: 16, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "ConfigGeneration", + columns: table => new + { + GenerationId = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ParentGenerationId = table.Column(type: "bigint", nullable: true), + CreatedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Notes = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + PublishedAt = table.Column(type: "datetime2(3)", nullable: true), + PublishedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + Status = table.Column(type: "nvarchar(16)", maxLength: 16, 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: "ClusterNodeGenerationState", + columns: table => new + { + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CurrentGenerationId = table.Column(type: "bigint", nullable: true), + LastAppliedAt = table.Column(type: "datetime2(3)", nullable: true), + LastAppliedError = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + LastAppliedStatus = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: true), + LastSeenAt = table.Column(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.CreateIndex( + name: "IX_VirtualTag_Generation_Script", + table: "VirtualTag", + columns: new[] { "GenerationId", "ScriptId" }); + + migrationBuilder.CreateIndex( + name: "UX_VirtualTag_Generation_EquipmentPath", + table: "VirtualTag", + columns: new[] { "GenerationId", "EquipmentId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_VirtualTag_Generation_LogicalId", + table: "VirtualTag", + columns: new[] { "GenerationId", "VirtualTagId" }, + unique: true, + filter: "[VirtualTagId] 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"); + + 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_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_ScriptedAlarm_Generation_Script", + table: "ScriptedAlarm", + columns: new[] { "GenerationId", "PredicateScriptId" }); + + migrationBuilder.CreateIndex( + name: "UX_ScriptedAlarm_Generation_EquipmentPath", + table: "ScriptedAlarm", + columns: new[] { "GenerationId", "EquipmentId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "UX_ScriptedAlarm_Generation_LogicalId", + table: "ScriptedAlarm", + columns: new[] { "GenerationId", "ScriptedAlarmId" }, + unique: true, + filter: "[ScriptedAlarmId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Script_Generation_SourceHash", + table: "Script", + columns: new[] { "GenerationId", "SourceHash" }); + + migrationBuilder.CreateIndex( + name: "UX_Script_Generation_LogicalId", + table: "Script", + columns: new[] { "GenerationId", "ScriptId" }, + unique: true, + filter: "[ScriptId] 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_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_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_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_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_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: "UX_ClusterNode_Primary_Per_Cluster", + table: "ClusterNode", + column: "ClusterId", + unique: true, + filter: "[RedundancyRole] = 'Primary'"); + + migrationBuilder.CreateIndex( + name: "IX_ClusterNodeGenerationState_Generation", + table: "ClusterNodeGenerationState", + column: "CurrentGenerationId"); + + 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.AddForeignKey( + name: "FK_Device_ConfigGeneration_GenerationId", + table: "Device", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_DriverInstance_ConfigGeneration_GenerationId", + table: "DriverInstance", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Equipment_ConfigGeneration_GenerationId", + table: "Equipment", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Namespace_ConfigGeneration_GenerationId", + table: "Namespace", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_NodeAcl_ConfigGeneration_GenerationId", + table: "NodeAcl", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_PollGroup_ConfigGeneration_GenerationId", + table: "PollGroup", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Script_ConfigGeneration_GenerationId", + table: "Script", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId", + table: "ScriptedAlarm", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Tag_ConfigGeneration_GenerationId", + table: "Tag", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_UnsArea_ConfigGeneration_GenerationId", + table: "UnsArea", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_UnsLine_ConfigGeneration_GenerationId", + table: "UnsLine", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_VirtualTag_ConfigGeneration_GenerationId", + table: "VirtualTag", + column: "GenerationId", + principalTable: "ConfigGeneration", + principalColumn: "GenerationId", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs new file mode 100644 index 0000000..96ba5ae --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.Designer.cs @@ -0,0 +1,1755 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260526105027_AddConfigAuditLogEventIdColumns")] + partial class AddConfigAuditLogEventIdColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("NamespaceId") + .HasDatabaseName("IX_DriverInstance_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Equipment_Driver"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_Namespace_Cluster"); + + b.HasIndex("NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_NamespaceUri"); + + b.HasIndex("ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Cluster_Kind"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b => + { + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AckedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConfirmedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("EnabledState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastAckComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastAckUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastAckUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastConfirmComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastConfirmUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastConfirmUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingExpiresUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("ScriptedAlarmId"); + + b.ToTable("ScriptedAlarmState", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_Tag_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Driver_Device"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs new file mode 100644 index 0000000..72bb343 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260526105027_AddConfigAuditLogEventIdColumns.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddConfigAuditLogEventIdColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CorrelationId", + table: "ConfigAuditLog", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.AddColumn( + name: "EventId", + table: "ConfigAuditLog", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateIndex( + name: "UX_ConfigAuditLog_EventId", + table: "ConfigAuditLog", + column: "EventId", + unique: true, + filter: "[EventId] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "UX_ConfigAuditLog_EventId", + table: "ConfigAuditLog"); + + migrationBuilder.DropColumn( + name: "CorrelationId", + table: "ConfigAuditLog"); + + migrationBuilder.DropColumn( + name: "EventId", + table: "ConfigAuditLog"); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index 7985573..ac59e65 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -17,11 +17,30 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => { b.Property("NodeId") @@ -68,11 +87,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("OpcUaPort") .HasColumnType("int"); - b.Property("RedundancyRole") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - b.Property("ServiceLevelBase") .HasColumnType("tinyint"); @@ -83,9 +97,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasDatabaseName("UX_ClusterNode_ApplicationUri"); b.HasIndex("ClusterId") - .IsUnique() - .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") - .HasFilter("[RedundancyRole] = 'Primary'"); + .HasDatabaseName("IX_ClusterNode_ClusterId"); b.ToTable("ClusterNode", (string)null); }); @@ -141,37 +153,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.ToTable("ClusterNodeCredential", (string)null); }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => - { - b.Property("NodeId") - .HasMaxLength(64) - .HasColumnType("nvarchar(64)"); - - b.Property("CurrentGenerationId") - .HasColumnType("bigint"); - - b.Property("LastAppliedAt") - .HasColumnType("datetime2(3)"); - - b.Property("LastAppliedError") - .HasMaxLength(2048) - .HasColumnType("nvarchar(2048)"); - - b.Property("LastAppliedStatus") - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); - - b.Property("LastSeenAt") - .HasColumnType("datetime2(3)"); - - b.HasKey("NodeId"); - - b.HasIndex("CurrentGenerationId") - .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); - - b.ToTable("ClusterNodeGenerationState", (string)null); - }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => { b.Property("AuditId") @@ -184,9 +165,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + b.Property("DetailsJson") .HasColumnType("nvarchar(max)"); + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + b.Property("EventType") .IsRequired() .HasMaxLength(64) @@ -211,6 +198,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("AuditId"); + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + b.HasIndex("GenerationId") .HasDatabaseName("IX_ConfigAuditLog_Generation") .HasFilter("[GenerationId] IS NOT NULL"); @@ -225,20 +217,73 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations }); }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigEdit", b => { - b.Property("GenerationId") + b.Property("EditId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); - b.Property("ClusterId") + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") .IsRequired() .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("CreatedAt") + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") .ValueGeneratedOnAdd() .HasColumnType("datetime2(3)") .HasDefaultValueSql("SYSUTCDATETIME()"); @@ -248,41 +293,36 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); - b.Property("Notes") - .HasMaxLength(1024) - .HasColumnType("nvarchar(1024)"); + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); - b.Property("ParentGenerationId") - .HasColumnType("bigint"); + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); - b.Property("PublishedAt") + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") .HasColumnType("datetime2(3)"); - b.Property("PublishedBy") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("Status") + .HasColumnType("int"); - b.Property("Status") - .IsRequired() - .HasMaxLength(16) - .HasColumnType("nvarchar(16)"); + b.HasKey("DeploymentId"); - b.HasKey("GenerationId"); + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); - b.HasIndex("ClusterId") - .IsUnique() - .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") - .HasFilter("[Status] = 'Draft'"); + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); - b.HasIndex("ParentGenerationId"); - - b.HasIndex("ClusterId", "Status", "GenerationId") - .IsDescending(false, false, true) - .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); - - SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); - - b.ToTable("ConfigGeneration", (string)null); + b.ToTable("Deployment", (string)null); }); modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => @@ -308,23 +348,26 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("Enabled") .HasColumnType("bit"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Name") .IsRequired() .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.HasKey("DeviceRowId"); - b.HasIndex("GenerationId", "DeviceId") + b.HasIndex("DeviceId") .IsUnique() - .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasDatabaseName("UX_Device_LogicalId") .HasFilter("[DeviceId] IS NOT NULL"); - b.HasIndex("GenerationId", "DriverInstanceId") - .HasDatabaseName("IX_Device_Generation_Driver"); + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); b.ToTable("Device", null, t => { @@ -400,9 +443,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("Enabled") .HasColumnType("bit"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -416,20 +456,24 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("ResilienceConfig") .HasColumnType("nvarchar(max)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.HasKey("DriverInstanceRowId"); - b.HasIndex("ClusterId"); + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); - b.HasIndex("GenerationId", "ClusterId") - .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); - - b.HasIndex("GenerationId", "DriverInstanceId") + b.HasIndex("DriverInstanceId") .IsUnique() - .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasDatabaseName("UX_DriverInstance_LogicalId") .HasFilter("[DriverInstanceId] IS NOT NULL"); - b.HasIndex("GenerationId", "NamespaceId") - .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + b.HasIndex("NamespaceId") + .HasDatabaseName("IX_DriverInstance_Namespace"); b.ToTable("DriverInstance", null, t => { @@ -516,9 +560,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("EquipmentUuid") .HasColumnType("uniqueidentifier"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("HardwareRevision") .HasMaxLength(32) .HasColumnType("nvarchar(32)"); @@ -545,6 +586,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(32) .HasColumnType("nvarchar(32)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("SAPID") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -571,35 +618,35 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("EquipmentRowId"); - b.HasIndex("GenerationId", "DriverInstanceId") - .HasDatabaseName("IX_Equipment_Generation_Driver"); + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Equipment_Driver"); - b.HasIndex("GenerationId", "EquipmentId") + b.HasIndex("EquipmentId") .IsUnique() - .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasDatabaseName("UX_Equipment_LogicalId") .HasFilter("[EquipmentId] IS NOT NULL"); - b.HasIndex("GenerationId", "EquipmentUuid") + b.HasIndex("EquipmentUuid") .IsUnique() - .HasDatabaseName("UX_Equipment_Generation_Uuid"); + .HasDatabaseName("UX_Equipment_Uuid"); - b.HasIndex("GenerationId", "MachineCode") - .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); - b.HasIndex("GenerationId", "SAPID") - .HasDatabaseName("IX_Equipment_Generation_SAPID") + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") .HasFilter("[SAPID] IS NOT NULL"); - b.HasIndex("GenerationId", "UnsLineId") - .HasDatabaseName("IX_Equipment_Generation_Line"); + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); - b.HasIndex("GenerationId", "ZTag") - .HasDatabaseName("IX_Equipment_Generation_ZTag") + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") .HasFilter("[ZTag] IS NOT NULL"); - b.HasIndex("GenerationId", "UnsLineId", "Name") + b.HasIndex("UnsLineId", "Name") .IsUnique() - .HasDatabaseName("UX_Equipment_Generation_LinePath"); + .HasDatabaseName("UX_Equipment_LinePath"); b.ToTable("Equipment", (string)null); }); @@ -870,9 +917,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("Enabled") .HasColumnType("bit"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Kind") .IsRequired() .HasMaxLength(32) @@ -891,30 +935,29 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(1024) .HasColumnType("nvarchar(1024)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.HasKey("NamespaceRowId"); - b.HasIndex("ClusterId"); + b.HasIndex("ClusterId") + .HasDatabaseName("IX_Namespace_Cluster"); - b.HasIndex("GenerationId", "ClusterId") - .HasDatabaseName("IX_Namespace_Generation_Cluster"); - - b.HasIndex("GenerationId", "NamespaceId") + b.HasIndex("NamespaceId") .IsUnique() - .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasDatabaseName("UX_Namespace_LogicalId") .HasFilter("[NamespaceId] IS NOT NULL"); - b.HasIndex("GenerationId", "NamespaceUri") + b.HasIndex("NamespaceUri") .IsUnique() - .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + .HasDatabaseName("UX_Namespace_NamespaceUri"); - b.HasIndex("GenerationId", "ClusterId", "Kind") + b.HasIndex("ClusterId", "Kind") .IsUnique() - .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); - - b.HasIndex("GenerationId", "NamespaceId", "ClusterId") - .IsUnique() - .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") - .HasFilter("[NamespaceId] IS NOT NULL"); + .HasDatabaseName("UX_Namespace_Cluster_Kind"); b.ToTable("Namespace", (string)null); }); @@ -931,9 +974,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("LdapGroup") .IsRequired() .HasMaxLength(256) @@ -950,6 +990,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("PermissionFlags") .HasColumnType("int"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("ScopeId") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -961,29 +1007,70 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("NodeAclRowId"); - b.HasIndex("GenerationId", "ClusterId") - .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); - b.HasIndex("GenerationId", "LdapGroup") - .HasDatabaseName("IX_NodeAcl_Generation_Group"); + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); - b.HasIndex("GenerationId", "NodeAclId") + b.HasIndex("NodeAclId") .IsUnique() - .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasDatabaseName("UX_NodeAcl_LogicalId") .HasFilter("[NodeAclId] IS NOT NULL"); - b.HasIndex("GenerationId", "ScopeKind", "ScopeId") - .HasDatabaseName("IX_NodeAcl_Generation_Scope") + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") .HasFilter("[ScopeId] IS NOT NULL"); - b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") .IsUnique() - .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasDatabaseName("UX_NodeAcl_GroupScope") .HasFilter("[ScopeId] IS NOT NULL"); b.ToTable("NodeAcl", (string)null); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => { b.Property("PollGroupRowId") @@ -996,9 +1083,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("IntervalMs") .HasColumnType("int"); @@ -1011,14 +1095,20 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.HasKey("PollGroupRowId"); - b.HasIndex("GenerationId", "DriverInstanceId") - .HasDatabaseName("IX_PollGroup_Generation_Driver"); + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); - b.HasIndex("GenerationId", "PollGroupId") + b.HasIndex("PollGroupId") .IsUnique() - .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasDatabaseName("UX_PollGroup_LogicalId") .HasFilter("[PollGroupId] IS NOT NULL"); b.ToTable("PollGroup", null, t => @@ -1034,9 +1124,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasColumnType("uniqueidentifier") .HasDefaultValueSql("NEWSEQUENTIALID()"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Language") .IsRequired() .HasMaxLength(16) @@ -1047,6 +1134,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("ScriptId") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -1062,13 +1155,13 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("ScriptRowId"); - b.HasIndex("GenerationId", "ScriptId") + b.HasIndex("ScriptId") .IsUnique() - .HasDatabaseName("UX_Script_Generation_LogicalId") + .HasDatabaseName("UX_Script_LogicalId") .HasFilter("[ScriptId] IS NOT NULL"); - b.HasIndex("GenerationId", "SourceHash") - .HasDatabaseName("IX_Script_Generation_SourceHash"); + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); b.ToTable("Script", (string)null); }); @@ -1093,9 +1186,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("HistorizeToAveva") .HasColumnType("bit"); @@ -1117,6 +1207,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("Retain") .HasColumnType("bit"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("ScriptedAlarmId") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); @@ -1126,17 +1222,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("ScriptedAlarmRowId"); - b.HasIndex("GenerationId", "PredicateScriptId") - .HasDatabaseName("IX_ScriptedAlarm_Generation_Script"); + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); - b.HasIndex("GenerationId", "ScriptedAlarmId") + b.HasIndex("ScriptedAlarmId") .IsUnique() - .HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId") + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") .HasFilter("[ScriptedAlarmId] IS NOT NULL"); - b.HasIndex("GenerationId", "EquipmentId", "Name") + b.HasIndex("EquipmentId", "Name") .IsUnique() - .HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath"); + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); b.ToTable("ScriptedAlarm", null, t => { @@ -1316,9 +1412,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(512) .HasColumnType("nvarchar(512)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Name") .IsRequired() .HasMaxLength(128) @@ -1328,6 +1421,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("TagConfig") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -1341,26 +1440,26 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("TagRowId"); - b.HasIndex("GenerationId", "EquipmentId") - .HasDatabaseName("IX_Tag_Generation_Equipment") + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_Tag_Equipment") .HasFilter("[EquipmentId] IS NOT NULL"); - b.HasIndex("GenerationId", "TagId") + b.HasIndex("TagId") .IsUnique() - .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasDatabaseName("UX_Tag_LogicalId") .HasFilter("[TagId] IS NOT NULL"); - b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") - .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + b.HasIndex("DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Driver_Device"); - b.HasIndex("GenerationId", "EquipmentId", "Name") + b.HasIndex("EquipmentId", "Name") .IsUnique() - .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasDatabaseName("UX_Tag_EquipmentPath") .HasFilter("[EquipmentId] IS NOT NULL"); - b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + b.HasIndex("DriverInstanceId", "FolderPath", "Name") .IsUnique() - .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasDatabaseName("UX_Tag_FolderPath") .HasFilter("[EquipmentId] IS NULL"); b.ToTable("Tag", null, t => @@ -1381,9 +1480,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Name") .IsRequired() .HasMaxLength(32) @@ -1393,25 +1489,29 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(512) .HasColumnType("nvarchar(512)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("UnsAreaId") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); b.HasKey("UnsAreaRowId"); - b.HasIndex("ClusterId"); + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); - b.HasIndex("GenerationId", "ClusterId") - .HasDatabaseName("IX_UnsArea_Generation_Cluster"); - - b.HasIndex("GenerationId", "UnsAreaId") + b.HasIndex("UnsAreaId") .IsUnique() - .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasDatabaseName("UX_UnsArea_LogicalId") .HasFilter("[UnsAreaId] IS NOT NULL"); - b.HasIndex("GenerationId", "ClusterId", "Name") + b.HasIndex("ClusterId", "Name") .IsUnique() - .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + .HasDatabaseName("UX_UnsArea_ClusterName"); b.ToTable("UnsArea", (string)null); }); @@ -1423,9 +1523,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasColumnType("uniqueidentifier") .HasDefaultValueSql("NEWSEQUENTIALID()"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Name") .IsRequired() .HasMaxLength(32) @@ -1435,6 +1532,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(512) .HasColumnType("nvarchar(512)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("UnsAreaId") .IsRequired() .HasMaxLength(64) @@ -1446,17 +1549,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("UnsLineRowId"); - b.HasIndex("GenerationId", "UnsAreaId") - .HasDatabaseName("IX_UnsLine_Generation_Area"); + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); - b.HasIndex("GenerationId", "UnsLineId") + b.HasIndex("UnsLineId") .IsUnique() - .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasDatabaseName("UX_UnsLine_LogicalId") .HasFilter("[UnsLineId] IS NOT NULL"); - b.HasIndex("GenerationId", "UnsAreaId", "Name") + b.HasIndex("UnsAreaId", "Name") .IsUnique() - .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + .HasDatabaseName("UX_UnsLine_AreaName"); b.ToTable("UnsLine", (string)null); }); @@ -1484,9 +1587,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("GenerationId") - .HasColumnType("bigint"); - b.Property("Historize") .HasColumnType("bit"); @@ -1495,6 +1595,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + b.Property("ScriptId") .IsRequired() .HasMaxLength(64) @@ -1509,17 +1615,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.HasKey("VirtualTagRowId"); - b.HasIndex("GenerationId", "ScriptId") - .HasDatabaseName("IX_VirtualTag_Generation_Script"); + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); - b.HasIndex("GenerationId", "VirtualTagId") + b.HasIndex("VirtualTagId") .IsUnique() - .HasDatabaseName("UX_VirtualTag_Generation_LogicalId") + .HasDatabaseName("UX_VirtualTag_LogicalId") .HasFilter("[VirtualTagId] IS NOT NULL"); - b.HasIndex("GenerationId", "EquipmentId", "Name") + b.HasIndex("EquipmentId", "Name") .IsUnique() - .HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath"); + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); b.ToTable("VirtualTag", null, t => { @@ -1551,53 +1657,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Navigation("Node"); }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") - .WithMany() - .HasForeignKey("CurrentGenerationId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") - .WithOne("GenerationState") - .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("CurrentGeneration"); - - b.Navigation("Node"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") - .WithMany("Generations") - .HasForeignKey("ClusterId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") - .WithMany() - .HasForeignKey("ParentGenerationId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Cluster"); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); - }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => { b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") @@ -1606,26 +1665,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - b.Navigation("Cluster"); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); }); modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => @@ -1657,70 +1697,26 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - b.Navigation("Cluster"); - - b.Navigation("Generation"); }); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") .WithMany() - .HasForeignKey("GenerationId") + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.Navigation("Generation"); - }); + b.Navigation("Deployment"); - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); + b.Navigation("Node"); }); modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => @@ -1731,44 +1727,12 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - b.Navigation("Cluster"); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); - }); - - modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => - { - b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") - .WithMany() - .HasForeignKey("GenerationId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Generation"); }); modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => { b.Navigation("Credentials"); - - b.Navigation("GenerationState"); }); modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => @@ -1778,8 +1742,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => { - b.Navigation("Generations"); - b.Navigation("Namespaces"); b.Navigation("Nodes"); diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 9862de8..06ef21f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; @@ -9,12 +10,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration; /// any divergence is a defect caught by the SchemaComplianceTests introspection check. /// public sealed class OtOpcUaConfigDbContext(DbContextOptions options) - : DbContext(options) + : DbContext(options), IDataProtectionKeyContext { public DbSet ServerClusters => Set(); public DbSet ClusterNodes => Set(); public DbSet ClusterNodeCredentials => Set(); - public DbSet ConfigGenerations => Set(); public DbSet Namespaces => Set(); public DbSet UnsAreas => Set(); public DbSet UnsLines => Set(); @@ -24,7 +24,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions Tags => Set(); public DbSet PollGroups => Set(); public DbSet NodeAcls => Set(); - public DbSet ClusterNodeGenerationStates => Set(); public DbSet ConfigAuditLogs => Set(); public DbSet ExternalIdReservations => Set(); public DbSet DriverHostStatuses => Set(); @@ -37,13 +36,21 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions ScriptedAlarms => Set(); public DbSet ScriptedAlarmStates => Set(); + // v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment). + public DbSet Deployments => Set(); + public DbSet NodeDeploymentStates => Set(); + public DbSet ConfigEdits => Set(); + + // ASP.NET DataProtection key ring storage (decision: keys persisted in ConfigDb so every + // admin-role node decrypts the same cookies without sharing a filesystem). + public DbSet DataProtectionKeys => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); ConfigureServerCluster(modelBuilder); ConfigureClusterNode(modelBuilder); ConfigureClusterNodeCredential(modelBuilder); - ConfigureConfigGeneration(modelBuilder); ConfigureNamespace(modelBuilder); ConfigureUnsArea(modelBuilder); ConfigureUnsLine(modelBuilder); @@ -53,7 +60,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.NodeId); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.ClusterId).HasMaxLength(64); - e.Property(x => x.RedundancyRole).HasConversion().HasMaxLength(16); e.Property(x => x.Host).HasMaxLength(255); e.Property(x => x.ApplicationUri).HasMaxLength(256); e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)"); @@ -115,10 +124,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions 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"); + // v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole + // column it filtered on) are gone. Akka cluster leader-of-driver-role is the + // authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator, + // Task 35). }); } @@ -147,39 +156,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions(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().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(e => @@ -192,24 +168,20 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.Kind).HasConversion().HasMaxLength(32); e.Property(x => x.NamespaceUri).HasMaxLength(256); e.Property(x => x.Notes).HasMaxLength(1024); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => new { x.ClusterId, x.Kind }).IsUnique() + .HasDatabaseName("UX_Namespace_Cluster_Kind"); + e.HasIndex(x => x.NamespaceUri).IsUnique() + .HasDatabaseName("UX_Namespace_NamespaceUri"); + e.HasIndex(x => x.NamespaceId).IsUnique() + .HasDatabaseName("UX_Namespace_LogicalId"); + e.HasIndex(x => x.ClusterId) + .HasDatabaseName("IX_Namespace_Cluster"); }); } @@ -224,13 +196,13 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.ClusterId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(32); e.Property(x => x.Notes).HasMaxLength(512); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_UnsArea_Cluster"); + e.HasIndex(x => x.UnsAreaId).IsUnique().HasDatabaseName("UX_UnsArea_LogicalId"); + e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_ClusterName"); }); } @@ -245,12 +217,11 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.UnsAreaId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(32); e.Property(x => x.Notes).HasMaxLength(512); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.UnsAreaId).HasDatabaseName("IX_UnsLine_Area"); + e.HasIndex(x => x.UnsLineId).IsUnique().HasDatabaseName("UX_UnsLine_LogicalId"); + e.HasIndex(x => new { x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_AreaName"); }); } @@ -274,13 +245,13 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.DriverType).HasMaxLength(32); e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)"); e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)"); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster"); + e.HasIndex(x => x.NamespaceId).HasDatabaseName("IX_DriverInstance_Namespace"); + e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId"); }); } @@ -298,11 +269,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)"); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver"); + e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId"); }); } @@ -330,17 +300,16 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.ManufacturerUri).HasMaxLength(512); e.Property(x => x.DeviceManualUri).HasMaxLength(512); e.Property(x => x.EquipmentClassRef).HasMaxLength(128); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Equipment_Driver"); + e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line"); + e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId"); + e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath"); + e.HasIndex(x => x.EquipmentUuid).IsUnique().HasDatabaseName("UX_Equipment_Uuid"); + e.HasIndex(x => x.ZTag).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_ZTag"); + e.HasIndex(x => x.SAPID).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_SAPID"); + e.HasIndex(x => x.MachineCode).HasDatabaseName("IX_Equipment_MachineCode"); }); } @@ -364,20 +333,19 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.AccessLevel).HasConversion().HasMaxLength(16); e.Property(x => x.PollGroupId).HasMaxLength(64); e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)"); + e.Property(x => x.RowVersion).IsRowVersion(); - 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 }) + e.HasIndex(x => new { x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Driver_Device"); + e.HasIndex(x => 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() + .HasDatabaseName("IX_Tag_Equipment"); + e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId"); + e.HasIndex(x => new { 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() + .HasDatabaseName("UX_Tag_EquipmentPath"); + e.HasIndex(x => new { x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique() .HasFilter("[EquipmentId] IS NULL") - .HasDatabaseName("UX_Tag_Generation_FolderPath"); + .HasDatabaseName("UX_Tag_FolderPath"); }); } @@ -394,11 +362,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.PollGroupId).HasMaxLength(64); e.Property(x => x.DriverInstanceId).HasMaxLength(64); e.Property(x => x.Name).HasMaxLength(128); + e.Property(x => x.RowVersion).IsRowVersion(); - 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"); + e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_PollGroup_Driver"); + e.HasIndex(x => x.PollGroupId).IsUnique().HasDatabaseName("UX_PollGroup_LogicalId"); }); } @@ -416,36 +383,16 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.ScopeId).HasMaxLength(64); e.Property(x => x.PermissionFlags).HasConversion(); e.Property(x => x.Notes).HasMaxLength(512); + e.Property(x => x.RowVersion).IsRowVersion(); - 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 }) + e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster"); + e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group"); + e.HasIndex(x => new { 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(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().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(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"); + .HasDatabaseName("IX_NodeAcl_Scope"); + e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId"); + e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope"); }); } @@ -466,6 +413,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.ClusterId).HasMaxLength(64); e.Property(x => x.NodeId).HasMaxLength(64); e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)"); + e.Property(x => x.EventId); + e.Property(x => x.CorrelationId); e.HasIndex(x => new { x.ClusterId, x.Timestamp }) .IsDescending(false, true) @@ -473,6 +422,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.GenerationId) .HasFilter("[GenerationId] IS NOT NULL") .HasDatabaseName("IX_ConfigAuditLog_Generation"); + // Filtered unique index gives cross-restart idempotency for the AuditWriterActor: + // a retry of an already-flushed batch will hit this constraint and the catch in + // FlushBuffer drops the duplicate insert. Nullable + filter so legacy backfill rows + // (EventId=NULL) don't collide. + e.HasIndex(x => x.EventId) + .IsUnique() + .HasFilter("[EventId] IS NOT NULL") + .HasDatabaseName("UX_ConfigAuditLog_EventId"); }); } @@ -640,11 +597,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.SourceCode).HasColumnType("nvarchar(max)"); e.Property(x => x.SourceHash).HasMaxLength(64); e.Property(x => x.Language).HasMaxLength(16); + e.Property(x => x.RowVersion).IsRowVersion(); - e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); - - e.HasIndex(x => new { x.GenerationId, x.ScriptId }).IsUnique().HasDatabaseName("UX_Script_Generation_LogicalId"); - e.HasIndex(x => new { x.GenerationId, x.SourceHash }).HasDatabaseName("IX_Script_Generation_SourceHash"); + e.HasIndex(x => x.ScriptId).IsUnique().HasDatabaseName("UX_Script_LogicalId"); + e.HasIndex(x => x.SourceHash).HasDatabaseName("IX_Script_SourceHash"); }); } @@ -666,12 +622,11 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.Name).HasMaxLength(128); e.Property(x => x.DataType).HasMaxLength(32); e.Property(x => x.ScriptId).HasMaxLength(64); + e.Property(x => x.RowVersion).IsRowVersion(); - e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); - - e.HasIndex(x => new { x.GenerationId, x.VirtualTagId }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_LogicalId"); - e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath"); - e.HasIndex(x => new { x.GenerationId, x.ScriptId }).HasDatabaseName("IX_VirtualTag_Generation_Script"); + e.HasIndex(x => x.VirtualTagId).IsUnique().HasDatabaseName("UX_VirtualTag_LogicalId"); + e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_EquipmentPath"); + e.HasIndex(x => x.ScriptId).HasDatabaseName("IX_VirtualTag_Script"); }); } @@ -693,12 +648,11 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.AlarmType).HasMaxLength(32); e.Property(x => x.MessageTemplate).HasMaxLength(1024); e.Property(x => x.PredicateScriptId).HasMaxLength(64); + e.Property(x => x.RowVersion).IsRowVersion(); - e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict); - - e.HasIndex(x => new { x.GenerationId, x.ScriptedAlarmId }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId"); - e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath"); - e.HasIndex(x => new { x.GenerationId, x.PredicateScriptId }).HasDatabaseName("IX_ScriptedAlarm_Generation_Script"); + e.HasIndex(x => x.ScriptedAlarmId).IsUnique().HasDatabaseName("UX_ScriptedAlarm_LogicalId"); + e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + e.HasIndex(x => x.PredicateScriptId).HasDatabaseName("IX_ScriptedAlarm_Script"); }); } @@ -729,4 +683,79 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); }); } + + private static void ConfigureDeployment(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("Deployment"); + e.HasKey(x => x.DeploymentId); + e.Property(x => x.DeploymentId).HasDefaultValueSql("NEWSEQUENTIALID()"); + e.Property(x => x.RevisionHash).HasMaxLength(64).IsRequired(); + e.Property(x => x.Status).HasConversion(); + e.Property(x => x.CreatedBy).HasMaxLength(128).IsRequired(); + e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); + e.Property(x => x.ArtifactBlob).HasColumnType("varbinary(max)"); + e.Property(x => x.RowVersion).IsRowVersion(); + e.Property(x => x.FailureReason).HasMaxLength(2048); + e.Property(x => x.SealedAtUtc).HasColumnType("datetime2(3)"); + + e.HasIndex(x => x.Status).HasDatabaseName("IX_Deployment_Status"); + e.HasIndex(x => x.CreatedAtUtc).HasDatabaseName("IX_Deployment_CreatedAt"); + }); + } + + private static void ConfigureNodeDeploymentState(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("NodeDeploymentState"); + e.HasKey(x => new { x.NodeId, x.DeploymentId }); + e.Property(x => x.NodeId).HasMaxLength(64); + e.Property(x => x.Status).HasConversion(); + e.Property(x => x.StartedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); + e.Property(x => x.AppliedAtUtc).HasColumnType("datetime2(3)"); + e.Property(x => x.FailureReason).HasMaxLength(2048); + e.Property(x => x.RowVersion).IsRowVersion(); + + e.HasOne(x => x.Node).WithMany().HasForeignKey(x => x.NodeId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Deployment).WithMany().HasForeignKey(x => x.DeploymentId).OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => x.DeploymentId).HasDatabaseName("IX_NodeDeploymentState_Deployment"); + e.HasIndex(x => x.Status).HasDatabaseName("IX_NodeDeploymentState_Status"); + }); + } + + private static void ConfigureConfigEdit(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("ConfigEdit", t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + e.HasKey(x => x.EditId); + e.Property(x => x.EditId).HasDefaultValueSql("NEWSEQUENTIALID()"); + e.Property(x => x.EntityType).HasMaxLength(64).IsRequired(); + e.Property(x => x.FieldsJson).HasColumnType("nvarchar(max)").IsRequired(); + e.Property(x => x.EditedBy).HasMaxLength(128).IsRequired(); + e.Property(x => x.EditedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()"); + e.Property(x => x.SourceNode).HasMaxLength(64).IsRequired(); + + // Replays of admin operations group rows by ExecutionId, then by time. + e.HasIndex(x => new { x.EntityType, x.EntityId }).HasDatabaseName("IX_ConfigEdit_Entity"); + e.HasIndex(x => x.ExecutionId).HasFilter("[ExecutionId] IS NOT NULL").HasDatabaseName("IX_ConfigEdit_Execution"); + e.HasIndex(x => x.EditedAtUtc).HasDatabaseName("IX_ConfigEdit_EditedAt"); + }); + } + + private static void ConfigureDataProtectionKey(ModelBuilder modelBuilder) + { + // ASP.NET DataProtection ships its own EF mapping; override only the table name so it + // matches the rest of the schema's PascalCase convention. + modelBuilder.Entity(e => + { + e.ToTable("DataProtectionKeys"); + }); + } } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ef40b6f --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.OtOpcUa.Configuration; + +public static class ServiceCollectionExtensions +{ + public const string ConnectionStringName = "ConfigDb"; + + /// + /// Registers for + /// using the connection string named ConfigDb from . + /// + public static IServiceCollection AddOtOpcUaConfigDb(this IServiceCollection services, IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString(ConnectionStringName) + ?? throw new InvalidOperationException( + $"Connection string '{ConnectionStringName}' is required. Add it to appsettings.json or the OTOPCUA_CONFIG_CONNECTION env var."); + + services.AddDbContextFactory(opt => opt.UseSqlServer(connectionString)); + return services; + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs index 3be8186..8feaad5 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -228,14 +228,9 @@ public static class DraftValidator $"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.", cluster.ClusterId)); - // Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation - // regardless of mode; catch it here so publish fails loud rather than the runtime - // demoting both to ServiceLevelBand.InvalidTopology at boot. - var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary); - if (primaryCount > 1) - errors.Add(new("ClusterMultiplePrimary", - $"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.", - cluster.ClusterId)); + // v2: the v1 "exactly one Primary per cluster" invariant is gone. RedundancyRole was + // dropped in Task 14d; in v2 the Akka cluster's role-leader-of-"driver" elects the + // primary at runtime, so there is no static configuration to validate here. return errors; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj index 1d64ef1..db15f31 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj @@ -12,16 +12,17 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index 92d3695..31e325d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -25,7 +25,8 @@ public interface IDriver /// /// Apply a config change in place without tearing down the driver process. - /// Used by IGenerationApplier when only this driver's config changed in the new generation. + /// Invoked by the v2 DriverInstanceActor when ApplyDelta reports that only this + /// driver's config changed in the new deployment. /// /// /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B)" — Reinitialize is the diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs new file mode 100644 index 0000000..af14ab0 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverFactory.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// Abstraction over the process-wide driver registry. Runtime consumes this instead of +/// DriverFactoryRegistry directly so the Runtime project doesn't pull in +/// ZB.MOM.WW.OtOpcUa.Core (which would drag in Polly + driver hosting). The fused +/// Host binds a DriverFactoryRegistryAdapter after every Driver.*.Register() +/// extension has run. +/// +public interface IDriverFactory +{ + /// + /// Return a new for the given , or + /// null when no factory is registered for that type (missing assembly, typo, etc.). + /// The DriverHostActor logs + skips the row rather than failing the whole apply. + /// + IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson); + + /// Driver-type names this factory can materialise. Mostly for diagnostics + logs. + IReadOnlyCollection SupportedTypes { get; } +} + +/// +/// Returns null from every call. Bound when the +/// fused Host hasn't registered any concrete driver assemblies yet (Mac dev path, smoke +/// tests). DriverHostActor sees zero supported types and treats the deployment as a no-op. +/// +public sealed class NullDriverFactory : IDriverFactory +{ + public static readonly NullDriverFactory Instance = new(); + private NullDriverFactory() { } + + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) => null; + public IReadOnlyCollection SupportedTypes { get; } = Array.Empty(); +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj index 175475d..62b3673 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj index 9196811..397e853 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj index 5e3f4f9..2be3ecf 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj @@ -15,8 +15,8 @@ - - + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj index 1be9764..36b2e4e 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs new file mode 100644 index 0000000..9bd490c --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistryAdapter.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Hosting; + +/// +/// Adapts the existing (v1 surface, still the +/// concrete singleton every driver assembly registers itself against) to the v2 +/// abstraction consumed by Runtime. The fused Host binds +/// this in DI once each Driver.*.Register(registry) call has completed. +/// +public sealed class DriverFactoryRegistryAdapter : IDriverFactory +{ + private readonly DriverFactoryRegistry _registry; + + public DriverFactoryRegistryAdapter(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + _registry = registry; + } + + public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) + { + var factory = _registry.TryGet(driverType); + return factory?.Invoke(driverInstanceId, driverConfigJson); + } + + public IReadOnlyCollection SupportedTypes => _registry.RegisteredTypes; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs index 7ed2eea..d0d2833 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs @@ -262,9 +262,8 @@ public static class EquipmentNodeWalker /// /// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config /// DB rows. All four collections are scoped to the same -/// + the same /// row. The walker assumes this filter -/// was applied by the caller + does no cross-generation or cross-namespace validation. +/// was applied by the caller + does no cross-namespace validation. /// public sealed record EquipmentNamespaceContent( IReadOnlyList Areas, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj index d9efa18..6369054 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj index 9094032..094c201 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj index 098caba..f71208a 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj index b41f4e2..f4e02ff 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj @@ -12,9 +12,9 @@ - - - + + + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj index 5330def..5cff6e6 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj index 4edd875..8b5a951 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj index 5be8bac..38030f2 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj index f4667aa..0737e18 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index c179d3f..3f3429a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index 8d0968e..22df544 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj index 330d7ad..9caac97 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj index 0f267b3..9adccbf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj @@ -46,11 +46,11 @@ built against) — a package-name mistake, not just a version skew — which would surface as a runtime MissingMethodException the first time the client's retry pipeline ran. --> - - - - - + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj index ae5532d..44fbb78 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj index c3a620f..8899d47 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.csproj @@ -20,13 +20,13 @@ - - - - - - - + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj index 2acd148..6794147 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj index 27459e2..d34f0be 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj index 8a65f95..4024f26 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj @@ -22,7 +22,7 @@ Server, or the standalone Beckhoff.TwinCAT.Ads.TcpRouter package) to reach remote systems. The router is a runtime concern, not a build concern — the library compiles + runs fine without one; ADS calls just fail with transport errors. --> - + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor deleted file mode 100644 index db62d4a..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor +++ /dev/null @@ -1,21 +0,0 @@ -@* Root Blazor component. *@ - - - - - - OtOpcUa Admin - - @* Admin-010: Bootstrap 5 is vendored under wwwroot/lib/bootstrap/ per admin-ui.md - "Tech Stack" — no public-CDN dependency so air-gapped fleet deployments work. *@ - - - - - - - - - - - diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor deleted file mode 100644 index 61bff56..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/ClusterAuthorizeView.razor +++ /dev/null @@ -1,44 +0,0 @@ -@* Cluster-scoped counterpart of . Renders Authorized/ChildContent only when the - signed-in user's effective role for ClusterId meets MinRole; otherwise renders NotAuthorized. - Effective role combines fleet-wide and cluster-scoped grants — see ClaimsPrincipalClusterExtensions. *@ -@using System.Security.Claims -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums - -@if (_authorized) -{ - @(Authorized ?? ChildContent) -} -else -{ - @NotAuthorized -} - -@code { - [CascadingParameter] private Task? AuthState { get; set; } - - /// Cluster the grant is evaluated against. - [Parameter, EditorRequired] public string ClusterId { get; set; } = string.Empty; - - /// Minimum effective role required to render the authorized content. - [Parameter] public AdminRole MinRole { get; set; } = AdminRole.ConfigViewer; - - /// Content shown when authorized (alias-friendly: use this or ). - [Parameter] public RenderFragment? Authorized { get; set; } - - /// Default content slot — shown when authorized if is unset. - [Parameter] public RenderFragment? ChildContent { get; set; } - - /// Content shown when the user lacks the required role; renders nothing when unset. - [Parameter] public RenderFragment? NotAuthorized { get; set; } - - private bool _authorized; - - protected override async Task OnParametersSetAsync() - { - _authorized = false; - if (AuthState is null) return; - var user = (await AuthState).User; - _authorized = user.HasClusterRole(ClusterId, MinRole); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor deleted file mode 100644 index ca8e0df..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor +++ /dev/null @@ -1,152 +0,0 @@ -@page "/account" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using System.Security.Claims -@using ZB.MOM.WW.OtOpcUa.Admin.Security -@using ZB.MOM.WW.OtOpcUa.Admin.Services - -
-

My account

-
- - - - @{ - var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—"; - var displayName = context.User.Identity?.Name ?? "—"; - var roles = context.User.Claims - .Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList(); - var ldapGroups = context.User.Claims - .Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList(); - var clusterGrants = context.User.Claims - .Where(c => c.Type == ClusterRoleClaims.ClaimType) - .Select(c => ClusterRoleClaims.Decode(c.Value)) - .Where(d => d is not null) - .Select(d => d!.Value) - .OrderBy(d => d.ClusterId, StringComparer.OrdinalIgnoreCase) - .ThenBy(d => d.Role) - .ToList(); - } - -
-
-
Identity
-
Username@username
-
Display name@displayName
-
- -
-
Admin roles
- @if (roles.Count == 0 && clusterGrants.Count == 0) - { -
RolesNo Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.
- } - else - { -
- Fleet-wide roles - - @if (roles.Count == 0) - { - none - } - else - { - @foreach (var r in roles) - { - @r - } - } - -
- @if (clusterGrants.Count > 0) - { -
- Cluster-scoped roles - - @foreach (var g in clusterGrants) - { - @g.ClusterId: @g.Role - } - -
- } -
LDAP groups@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))
- } -
-
- -
-
Capabilities
-

- Each Admin role grants a fixed capability set per admin-ui.md §Admin Roles. - Pages below reflect what this session can access; the route's [Authorize] guard - is the ground truth — this table mirrors it for readability. This table covers - fleet-wide capabilities only — a cluster-scoped grant unlocks the same actions inside its - named cluster without satisfying these fleet-wide policies. -

-
-
- - - - - - - - - @foreach (var cap in Capabilities) - { - var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase)); - - - - - - } - -
CapabilityRequired role(s)You have it?
@cap.Name
@cap.Description
@string.Join(" or ", cap.RequiredRoles) - @if (has) - { - Yes - } - else - { - No - } -
- - - -
-
- -
-
- - - -@code { - private sealed record Capability(string Name, string Description, string[] RequiredRoles); - - // Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute. - // When a new page or policy is added, extend this list so operators can self-service check - // whether their session has access without trial-and-error navigation. - private static readonly IReadOnlyList Capabilities = - [ - new("View clusters + fleet status", - "Read-only access to the cluster list, fleet dashboard, and generation history.", - [AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - new("Edit configuration drafts", - "Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.", - [AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - new("Publish generations", - "Promote a draft to Published — triggers node roll-out. CanPublish policy.", - [AdminRoles.FleetAdmin]), - new("Manage certificate trust", - "Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.", - [AdminRoles.FleetAdmin]), - new("Manage external-ID reservations", - "Reserve / release external IDs that map into Galaxy contained names.", - [AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]), - ]; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor deleted file mode 100644 index 09e18cb..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor +++ /dev/null @@ -1,80 +0,0 @@ -@page "/alarms/historian" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian -@rendermode RenderMode.InteractiveServer -@inject HistorianDiagnosticsService Diag - -
-

Alarm historian

-
-

Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.

- -
-
-
Drain state
-
@_status.DrainState
-
-
-
Queue depth
-
@_status.QueueDepth.ToString("N0")
-
-
-
Dead-letter depth
-
@_status.DeadLetterDepth.ToString("N0")
-
-
-
Last success
-
@(_status.LastSuccessUtc?.ToString("u") ?? "—")
-
-
- -@if (!string.IsNullOrEmpty(_status.LastError)) -{ -
- Last error: @_status.LastError -
-} - -
- - -
- -@if (_retryResult is not null) -{ -
Requeued @_retryResult row(s) for retry.
-} - -@code { - private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled); - private int? _retryResult; - - protected override void OnInitialized() => _status = Diag.GetStatus(); - - private Task RefreshAsync() - { - _status = Diag.GetStatus(); - _retryResult = null; - return Task.CompletedTask; - } - - private Task RetryDeadLetteredAsync() - { - _retryResult = Diag.TryRetryDeadLettered(); - _status = Diag.GetStatus(); - return Task.CompletedTask; - } - - private static string BadgeFor(HistorianDrainState s) => s switch - { - HistorianDrainState.Idle => "chip-ok", - HistorianDrainState.Draining => "chip-idle", - HistorianDrainState.BackingOff => "chip-warn", - HistorianDrainState.Disabled => "chip-idle", - _ => "chip-idle", - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor deleted file mode 100644 index a2819f1..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor +++ /dev/null @@ -1,166 +0,0 @@ -@page "/certificates" -@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@rendermode RenderMode.InteractiveServer -@inject CertTrustService Certs -@inject AuthenticationStateProvider AuthState -@inject ILogger Log - -
-

Certificate trust

-
- -
- PKI store root @Certs.PkiStoreRoot. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake. -
- -@if (_status is not null) -{ -
- @_status - -
-} - -
-
Rejected (@_rejected.Count)
- @if (_rejected.Count == 0) - { -

No rejected certificates. Clients that fail to handshake with an untrusted cert land here.

- } - else - { -
- - - - @foreach (var c in _rejected) - { - - - - - - - - } - -
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") - - -
-
- } -
- -
-
Trusted (@_trusted.Count)
- @if (_trusted.Count == 0) - { -

No client certs have been explicitly trusted. The server's own application cert lives in own/ and is not listed here.

- } - else - { -
- - - - @foreach (var c in _trusted) - { - - - - - - - - } - -
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") - -
-
- } -
- -@code { - private IReadOnlyList _rejected = []; - private IReadOnlyList _trusted = []; - private string? _status; - private string _statusKind = "success"; - - protected override void OnInitialized() => Reload(); - - private void Reload() - { - _rejected = Certs.ListRejected(); - _trusted = Certs.ListTrusted(); - } - - private async Task TrustAsync(CertInfo c) - { - if (Certs.TrustRejected(c.Thumbprint)) - { - await LogActionAsync("cert.trust", c); - Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning"); - } - Reload(); - } - - private async Task DeleteRejectedAsync(CertInfo c) - { - if (Certs.DeleteRejected(c.Thumbprint)) - { - await LogActionAsync("cert.delete.rejected", c); - Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning"); - } - Reload(); - } - - private async Task UntrustAsync(CertInfo c) - { - if (Certs.UntrustCert(c.Thumbprint)) - { - await LogActionAsync("cert.untrust", c); - Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success"); - } - else - { - Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning"); - } - Reload(); - } - - private async Task LogActionAsync(string action, CertInfo c) - { - // Cert trust changes are operator-initiated and security-sensitive — Serilog captures the - // user + thumbprint trail. CertTrustService also logs at Information on each filesystem - // move/delete; this line ties the action to the authenticated admin user so the two logs - // correlate. DB-level ConfigAuditLog persistence is deferred — its schema is - // cluster-scoped and cert actions are cluster-agnostic. - var state = await AuthState.GetAuthenticationStateAsync(); - var user = state.User.Identity?.Name ?? "(anonymous)"; - Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}", - user, action, c.Thumbprint, c.Subject); - } - - private void Set(string message, string kind) - { - _status = message; - _statusKind = kind; - } - - private void ClearStatus() => _status = null; - - private static string Short(string thumbprint) => - thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor deleted file mode 100644 index 077109b..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AclsTab.razor +++ /dev/null @@ -1,295 +0,0 @@ -@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 -@using ZB.MOM.WW.OtOpcUa.Core.Authorization -@inject NodeAclService AclSvc -@inject PermissionProbeService ProbeSvc -@inject NavigationManager Nav -@inject AdminHubConnectionFactory HubFactory -@implements IAsyncDisposable - -
-

Access-control grants

- -
- -@if (_acls is null) {

Loading…

} -else if (_acls.Count == 0) {

No ACL grants in this draft. Publish will result in a cluster with no external access.

} -else -{ -
-
Grants
-
- - - - @foreach (var a in _acls) - { - - - - - - - - } - -
LDAP groupScopeScope IDPermissions
@a.LdapGroup@a.ScopeKind@(a.ScopeId ?? "-")@a.PermissionFlags
-
-
-} - -@* Probe-this-permission — task #196 slice 1 *@ -
-
- Probe this permission - - Ask the trie "if LDAP group X asks for permission Y on node Z, would it be granted?" — - answers the same way the live server does at request time. - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - @if (_probeResult is not null) - { - - @if (_probeResult.Granted) - { - Granted - } - else - { - Denied - } - - Required @_probeResult.Required, - Effective @_probeResult.Effective - - - } -
- @if (_probeResult is not null && _probeResult.Matches.Count > 0) - { -
- - - - @foreach (var m in _probeResult.Matches) - { - - - - - - } - -
LDAP group matchedLevelFlags contributed
@m.LdapGroup@m.Scope@m.PermissionFlags
-
- } - else if (_probeResult is not null) - { -
No matching grants for this (group, scope) — effective permission is None.
- } -
-
- -@if (_showForm) -{ -
-
Add grant
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- @if (_error is not null) {
@_error
} -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private List? _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; - - // Probe-this-permission state - private string _probeGroup = string.Empty; - private string _probeNamespaceId = string.Empty; - private string _probeUnsAreaId = string.Empty; - private string _probeUnsLineId = string.Empty; - private string _probeEquipmentId = string.Empty; - private string _probeTagId = string.Empty; - private NodePermissions _probePermission = NodePermissions.Read; - private PermissionProbeResult? _probeResult; - private bool _probing; - - private async Task RunProbeAsync() - { - if (string.IsNullOrWhiteSpace(_probeGroup)) { _probeResult = null; return; } - _probing = true; - try - { - var scope = new NodeScope - { - ClusterId = ClusterId, - NamespaceId = NullIfBlank(_probeNamespaceId), - UnsAreaId = NullIfBlank(_probeUnsAreaId), - UnsLineId = NullIfBlank(_probeUnsLineId), - EquipmentId = NullIfBlank(_probeEquipmentId), - TagId = NullIfBlank(_probeTagId), - Kind = NodeHierarchyKind.Equipment, - }; - _probeResult = await ProbeSvc.ProbeAsync(GenerationId, _probeGroup.Trim(), scope, _probePermission, CancellationToken.None); - } - finally { _probing = false; } - } - - private static string? NullIfBlank(string s) => string.IsNullOrWhiteSpace(s) ? null : s; - - private HubConnection? _hub; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || _hub is not null) return; - _hub = HubFactory.Create("/hubs/fleet"); - _hub.On("NodeAclChanged", async msg => - { - if (msg.ClusterId != ClusterId || msg.GenerationId != GenerationId) return; - _acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None); - await InvokeAsync(StateHasChanged); - }); - // Best-effort: FleetStatusHub requires an authenticated caller, and the server-side - // HubConnection cannot forward the browser auth cookie — swallow connect failures so - // the tab still renders. Live ACL-change updates degrade. - try - { - await _hub.StartAsync(); - await _hub.SendAsync("SubscribeCluster", ClusterId); - } - catch - { - // best-effort live updates — see comment above - } - } - - public async ValueTask DisposeAsync() - { - if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; } - } - - 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); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor deleted file mode 100644 index 76d1234..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/AuditTab.razor +++ /dev/null @@ -1,40 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject AuditLogService AuditSvc - -

Recent audit log

- -@if (_entries is null) {

Loading…

} -else if (_entries.Count == 0) {

No audit entries for this cluster yet.

} -else -{ -
-
Entries
-
- - - - @foreach (var a in _entries) - { - - - - - - - - - } - -
WhenPrincipalEventNodeGenerationDetails
@a.Timestamp.ToString("u")@a.Principal@a.EventType@a.NodeId@a.GenerationId@a.DetailsJson
-
-
-} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - private List? _entries; - - protected override async Task OnParametersSetAsync() => - _entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor deleted file mode 100644 index 56c4bba..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClusterDetail.razor +++ /dev/null @@ -1,227 +0,0 @@ -@page "/clusters/{ClusterId}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using System.Security.Claims -@using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.SignalR.Client -@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.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@implements IAsyncDisposable -@rendermode RenderMode.InteractiveServer -@inject ClusterService ClusterSvc -@inject GenerationService GenerationSvc -@inject NavigationManager Nav -@inject AdminHubConnectionFactory HubFactory - -@if (!_loaded) -{ -

Loading…

-} -else if (!_canView) -{ -
- You don't have access to cluster @ClusterId. A fleet-wide or - cluster-scoped Admin role grant is required — ask a fleet admin to add one on the - role grants page. -
-} -else if (_cluster is null) -{ -
- Cluster @ClusterId was not found. -
-} -else -{ - @if (_liveBanner is not null) - { -
- Live update: @_liveBanner - -
- } -
-
-

@_cluster.Name

- @_cluster.ClusterId - @if (!_cluster.Enabled) { Disabled } -
-
- @if (!_canEdit) - { - Read-only access - } - else if (_currentDraft is not null) - { - - Edit current draft (gen @_currentDraft.GenerationId) - - } - else - { - - } -
-
- - - - @if (_tab == "overview") - { -
-
-
Cluster details
-
Enterprise / Site@_cluster.Enterprise / @_cluster.Site
-
Redundancy@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))
-
- Current published - - @if (_currentPublished is not null) { @_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u")) } - else { none published yet } - -
-
Created@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy
-
-
- } - else if (_tab == "generations") - { - - } - else if (_tab == "equipment" && _currentDraft is not null) - { - - } - else if (_tab == "uns" && _currentDraft is not null) - { - - } - else if (_tab == "namespaces" && _currentDraft is not null) - { - - } - else if (_tab == "drivers" && _currentDraft is not null) - { - - } - else if (_tab == "tags" && _currentDraft is not null) - { - - } - else if (_tab == "acls" && _currentDraft is not null) - { - - } - else if (_tab == "redundancy") - { - - } - else if (_tab == "audit") - { - - } - else - { -
Open a draft to edit this cluster's content.
- } -} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [CascadingParameter] private Task? AuthState { get; set; } - private ServerCluster? _cluster; - private ConfigGeneration? _currentDraft; - private ConfigGeneration? _currentPublished; - private string _tab = "overview"; - private bool _busy; - private bool _loaded; - private bool _canView; - private bool _canEdit; - private HubConnection? _hub; - private string? _liveBanner; - - private string Tab(string key) => _tab == key ? "active" : string.Empty; - - protected override async Task OnInitializedAsync() - { - if (AuthState is not null) - { - var user = (await AuthState).User; - _canView = user.HasClusterRole(ClusterId, AdminRole.ConfigViewer); - _canEdit = user.HasClusterRole(ClusterId, AdminRole.ConfigEditor); - } - _loaded = true; - if (!_canView) return; - - 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 = HubFactory.Create("/hubs/fleet"); - - _hub.On("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); - }); - - // Best-effort: FleetStatusHub requires an authenticated caller, and the server-side - // HubConnection cannot forward the browser auth cookie — a connect failure must not - // crash the page. Live banner updates degrade; the page still renders. - try - { - await _hub.StartAsync(); - await _hub.SendAsync("SubscribeCluster", ClusterId); - } - catch - { - // best-effort live updates — see comment above - } - } - - private async Task CreateDraftAsync() - { - _busy = true; - try - { - // Admin-007: record the authenticated operator's name, not a static literal. - var user = AuthState is not null ? (await AuthState).User : null; - var operatorName = user?.FindFirstValue(ClaimTypes.Name) - ?? user?.FindFirstValue(ClaimTypes.NameIdentifier) - ?? "unknown"; - var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: operatorName, CancellationToken.None); - Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}"); - } - finally { _busy = false; } - } - - public async ValueTask DisposeAsync() - { - if (_hub is not null) await _hub.DisposeAsync(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor deleted file mode 100644 index f3cd96b..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ClustersList.razor +++ /dev/null @@ -1,62 +0,0 @@ -@page "/clusters" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject ClusterService ClusterSvc - -
-

Clusters

- New cluster -
- -@if (_clusters is null) -{ -

Loading…

-} -else if (_clusters.Count == 0) -{ -

No clusters yet. Create the first one.

-} -else -{ -
-
All clusters
-
- - - - - - - - - @foreach (var c in _clusters) - { - - - - - - - - - - - } - -
ClusterIdNameEnterpriseSiteRedundancyModeNodeCountEnabled
@c.ClusterId@c.Name@c.Enterprise@c.Site@c.RedundancyMode@c.NodeCount - @if (c.Enabled) { Active } - else { Disabled } - Open
-
-
-} - -@code { - private List? _clusters; - - protected override async Task OnInitializedAsync() - { - _clusters = await ClusterSvc.ListAsync(CancellationToken.None); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor deleted file mode 100644 index 1c5dfe0..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffSection.razor +++ /dev/null @@ -1,90 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services - -@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps - output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the - Blazor render; overflow banner tells operator how many rows were hidden. *@ - -
-
-
- @Title - @Description -
-
- @if (_added > 0) { +@_added } - @if (_removed > 0) { −@_removed } - @if (_modified > 0) { ~@_modified } - @if (_total == 0) { no changes } -
-
- @if (_total == 0) - { -

No changes in this section.

- } - else - { - @if (_total > RowCap) - { -
- Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class - diffs. Inspect the remainder via the SQL sp_ComputeGenerationDiff directly. -
- } -
- - - - - - @foreach (var r in _visibleRows) - { - - - - - } - -
LogicalIdChange
@r.LogicalId - @switch (r.ChangeKind) - { - case "Added": @r.ChangeKind break; - case "Removed": @r.ChangeKind break; - case "Modified": @r.ChangeKind break; - default: @r.ChangeKind break; - } -
-
- } -
- -@code { - /// Default row-cap per section — matches task #156's acceptance criterion. - public const int DefaultRowCap = 1000; - - [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; - [Parameter] public string Description { get; set; } = string.Empty; - [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; - [Parameter] public int RowCap { get; set; } = DefaultRowCap; - - private int _total; - private int _added; - private int _removed; - private int _modified; - private List _visibleRows = []; - - protected override void OnParametersSet() - { - _total = Rows.Count; - _added = 0; _removed = 0; _modified = 0; - foreach (var r in Rows) - { - switch (r.ChangeKind) - { - case "Added": _added++; break; - case "Removed": _removed++; break; - case "Modified": _modified++; break; - } - } - _visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor deleted file mode 100644 index 8bd3aa3..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DiffViewer.razor +++ /dev/null @@ -1,100 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@inject GenerationService GenerationSvc - - - -
- Viewing cluster @ClusterId requires a fleet-wide or - cluster-scoped Admin role grant. -
-
- - -
-
-

Draft diff

- - Cluster @ClusterId — from last published (@(_fromLabel)) → to draft @GenerationId - -
- Back to editor -
- -@if (_rows is null) -{ -

Computing diff…

-} -else if (_error is not null) -{ -
@_error
-} -else if (_rows.Count == 0) -{ -

No differences — draft is structurally identical to the last published generation.

-} -else -{ -

- @_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections. - Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts. -

- - @foreach (var sec in Sections) - { - - } -} - -
-
- -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [Parameter] public long GenerationId { get; set; } - - /// - /// Ordered section definitions — each maps a TableName emitted by - /// sp_ComputeGenerationDiff to a human label + description. The proc currently - /// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as - /// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156 - /// follow-up). Six sections total matches the task #156 target. - /// - private static readonly IReadOnlyList Sections = new[] - { - new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"), - new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"), - new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"), - new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"), - new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"), - new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (logical id = LdapGroup|ScopeKind|ScopeId)"), - }; - - private List? _rows; - private string _fromLabel = "(empty)"; - private string? _error; - private int _sectionsWithChanges; - - 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); - _sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName)); - } - catch (Exception ex) { _error = ex.Message; } - } - - private IReadOnlyList RowsFor(string tableName) => - _rows?.Where(r => r.TableName == tableName).ToList() ?? []; - - private sealed record SectionDef(string TableName, string Title, string Description); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor deleted file mode 100644 index 87f7363..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor +++ /dev/null @@ -1,127 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@using ZB.MOM.WW.OtOpcUa.Configuration.Validation -@rendermode RenderMode.InteractiveServer -@inject GenerationService GenerationSvc -@inject DraftValidationService ValidationSvc -@inject NavigationManager Nav - - - -
- Editing cluster @ClusterId requires the - ConfigEditor role for this cluster. -
-
- - -
-
-

Draft editor

- Cluster @ClusterId · generation @GenerationId -
-
- Back to cluster - View diff - - - -
-
- - - -
-
- @if (_tab == "equipment") { } - else if (_tab == "uns") { } - else if (_tab == "namespaces") { } - else if (_tab == "drivers") { } - else if (_tab == "acls") { } - else if (_tab == "scripts") { } - else if (_tab == "virtual-tags") { } - else if (_tab == "scripted-alarms") { } -
-
-
-
- Validation - -
-
- @if (_validating) {

Checking…

} - else if (_errors.Count == 0) {

No validation errors — safe to publish.

} - else - { -

@_errors.Count error@(_errors.Count == 1 ? "" : "s")

-
    - @foreach (var e in _errors) - { -
  • - @e.Code - @e.Message - @if (!string.IsNullOrEmpty(e.Context)) {
    @e.Context
    } -
  • - } -
- } -
-
- - @if (_publishError is not null) {
@_publishError
} -
-
- -
-
- -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - [Parameter] public long GenerationId { get; set; } - - private string _tab = "equipment"; - private List _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; } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor deleted file mode 100644 index 4e7b2a5..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DriversTab.razor +++ /dev/null @@ -1,192 +0,0 @@ -@using System.Text.Json -@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject DriverInstanceService DriverSvc -@inject NamespaceService NsSvc - -
-

DriverInstances

- -
- -@if (_drivers is null) {

Loading…

} -else if (_drivers.Count == 0) {

No drivers configured in this draft.

} -else -{ -
-
Configured drivers
-
- - - - @foreach (var d in _drivers) - { - - - - - - - } - -
DriverInstanceIdNameTypeNamespace
@d.DriverInstanceId@d.Name - @if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase)) - { - @d.DriverType - } - else - { - @d.DriverType - } - @d.NamespaceId
-
-
-} - -@if (_showForm && _namespaces is not null) -{ -
-
Add driver
-
-
-
- - -
-
- - -
Type string must match the driver's registered factory name; this dropdown wraps the canonical names.
-
-
- - -
-
- @if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)) - { - @* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back - for driver types that haven't yet shipped a typed editor. *@ - - - } - else - { - - -
Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).
- } -
-
- @if (_error is not null) {
@_error
} -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private List? _drivers; - private List? _namespaces; - private bool _showForm; - private string _name = string.Empty; - private string _type = "Modbus"; - private string _nsId = string.Empty; - private string _config = "{}"; - private string? _error; - - // #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions - // defaults so an unedited form produces config equivalent to the historical - // pre-typed-editor wire output. Serialised to _config on Save when type=Modbus. - private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new(); - private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true }; - - 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 - { - // #147 — for Modbus drivers serialize the typed editor model into the DriverConfig - // JSON column. Other driver types still use the raw textarea contents until each - // ships its own typed editor (decision #94 — per-driver schema validation arrives - // per driver phase). - var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase) - ? SerializeModbusOptions(_modbusOptions) - : _config; - - await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None); - _name = string.Empty; _config = "{}"; - _modbusOptions = new(); - _showForm = false; - await ReloadAsync(); - } - catch (Exception ex) { _error = ex.Message; } - } - - /// - /// Maps the view-model field names onto the JSON shape ModbusDriverFactoryExtensions - /// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the - /// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would - /// emit the .NET-native names instead. - /// - private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) => - JsonSerializer.Serialize(new - { - host = m.Host, - port = m.Port, - unitId = m.UnitId, - family = m.Family.ToString(), - melsecSubFamily = m.MelsecSubFamily.ToString(), - keepAlive = new - { - enabled = m.KeepAliveEnabled, - timeMs = m.KeepAliveTimeSec * 1000, - intervalMs = m.KeepAliveIntervalSec * 1000, - retryCount = m.KeepAliveRetryCount, - }, - reconnect = new - { - initialDelayMs = m.ReconnectInitialDelayMs, - maxDelayMs = m.ReconnectMaxDelayMs, - backoffMultiplier = m.ReconnectBackoffMultiplier, - }, - maxRegistersPerRead = m.MaxRegistersPerRead, - maxRegistersPerWrite = m.MaxRegistersPerWrite, - maxCoilsPerRead = m.MaxCoilsPerRead, - maxReadGap = m.MaxReadGap, - useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites, - useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites, - writeOnChangeOnly = m.WriteOnChangeOnly, - tags = Array.Empty(), - }, ModbusJsonOptions); -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor deleted file mode 100644 index 550b801..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor +++ /dev/null @@ -1,332 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Validation -@inject EquipmentService EquipmentSvc -@inject NavigationManager Nav - -
-

Equipment (draft gen @GenerationId)

-
- - -
-
- -@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@ -
-
Search equipment
-
-
-
- - -
-
-
- - -
-
-
- - @if (_searchHits is not null) - { - - } -
-
- @if (_searchError is not null) - { -

@_searchError

- } -
- - @if (_searchHits is not null) - { - @if (_searchHits.Count == 0) - { -

No matches.

- } - else - { -
- - - - - - - - - @foreach (var hit in _searchHits) - { - - - - - - - - - - } - -
EquipmentIdNameMachineCodeZTagSAPIDMatchedGen
@hit.Equipment.EquipmentId@hit.Equipment.Name@hit.Equipment.MachineCode@hit.Equipment.ZTag@hit.Equipment.SAPID - @if (hit.MatchedField is not null) - { - var chipClass = hit.Score switch - { - 100 => "chip chip-ok", - 50 => "chip chip-warn", - _ => "chip chip-idle", - }; - @hit.MatchedField - } - - @if (hit.IsPublished) - { pub } - else - { draft } -
-
-

- @_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s"). - Exact = green, prefix = amber, fuzzy = grey. - Fuzzy matching requires the "Fuzzy" checkbox. -

- } - } -
- -@if (_equipment is null) -{ -

Loading…

-} -else if (_equipment.Count == 0 && !_showForm) -{ -

No equipment in this draft yet.

-} -else if (_equipment.Count > 0) -{ -
-
Equipment list
-
- - - - - - - - - @foreach (var e in _equipment) - { - - - - - - - - - - - } - -
EquipmentIdNameMachineCodeZTagSAPIDManufacturer / ModelSerial
@e.EquipmentId@e.Name@e.MachineCode@e.ZTag@e.SAPID@e.Manufacturer / @e.Model@e.SerialNumber - - -
-
-
-} - -@if (_showForm) -{ -
-
@(_editMode ? "Edit equipment" : "New equipment")
-
- - -
-
- - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - - @if (_error is not null) {
@_error
} - -
- - -
-
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private void GoImport() => Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}/import-equipment"); - private List? _equipment; - private bool _showForm; - private bool _editMode; - private Equipment _draft = NewBlankDraft(); - private string? _error; - - // ── Five-identifier search ────────────────────────────────────────── - private string _searchQuery = string.Empty; - private bool _searchFuzzy; - private IReadOnlyList? _searchHits; - private bool _searchBusy; - private string? _searchError; - - private async Task RunSearchAsync() - { - _searchError = null; - if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; } - _searchBusy = true; - try - { - _searchHits = await EquipmentSvc.SearchAsync( - _searchQuery, ClusterId, CancellationToken.None, - maxResults: 50, allowFuzzy: _searchFuzzy); - } - catch (Exception ex) { _searchError = ex.Message; } - finally { _searchBusy = false; } - } - - private void ClearSearch() - { - _searchQuery = string.Empty; - _searchHits = null; - _searchError = null; - } - - private async Task OnSearchKeyDown(KeyboardEventArgs e) - { - if (e.Key == "Enter") await RunSearchAsync(); - } - // ─────────────────────────────────────────────────────────────────── - - 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(); - _editMode = false; - _error = null; - _showForm = true; - } - - private void StartEdit(Equipment row) - { - // Shallow-clone so Cancel doesn't mutate the list-displayed row with in-flight form edits. - _draft = new Equipment - { - EquipmentRowId = row.EquipmentRowId, - GenerationId = row.GenerationId, - EquipmentId = row.EquipmentId, - EquipmentUuid = row.EquipmentUuid, - DriverInstanceId = row.DriverInstanceId, - DeviceId = row.DeviceId, - UnsLineId = row.UnsLineId, - Name = row.Name, - MachineCode = row.MachineCode, - ZTag = row.ZTag, - SAPID = row.SAPID, - Manufacturer = row.Manufacturer, - Model = row.Model, - SerialNumber = row.SerialNumber, - HardwareRevision = row.HardwareRevision, - SoftwareRevision = row.SoftwareRevision, - YearOfConstruction = row.YearOfConstruction, - AssetLocation = row.AssetLocation, - ManufacturerUri = row.ManufacturerUri, - DeviceManualUri = row.DeviceManualUri, - EquipmentClassRef = row.EquipmentClassRef, - Enabled = row.Enabled, - }; - _editMode = true; - _error = null; - _showForm = true; - } - - private void Cancel() - { - _showForm = false; - _editMode = false; - } - - private async Task SaveAsync() - { - _error = null; - try - { - if (_editMode) - { - await EquipmentSvc.UpdateAsync(_draft, CancellationToken.None); - } - else - { - _draft.EquipmentUuid = Guid.NewGuid(); - _draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid); - _draft.GenerationId = GenerationId; - await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None); - } - _showForm = false; - _editMode = false; - await ReloadAsync(); - } - catch (Exception ex) { _error = ex.Message; } - } - - private async Task DeleteAsync(Guid id) - { - await EquipmentSvc.DeleteAsync(id, CancellationToken.None); - await ReloadAsync(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor deleted file mode 100644 index 3efc8e6..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/Generations.razor +++ /dev/null @@ -1,76 +0,0 @@ -@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 - -@if (_generations is null) {

Loading…

} -else if (_generations.Count == 0) {

No generations in this cluster yet.

} -else -{ -
-
Generations
-
- - - - - - @foreach (var g in _generations) - { - - - - - - - - - - } - -
IDStatusCreatedPublishedPublishedByNotes
@g.GenerationId@StatusBadge(g.Status)@g.CreatedAt.ToString("u") by @g.CreatedBy@(g.PublishedAt?.ToString("u") ?? "-")@g.PublishedBy@g.Notes - @if (g.Status == GenerationStatus.Draft) - { - Open - } - else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded) - { - - } -
-
-
-} - -@if (_error is not null) {
@_error
} - -@code { - [Parameter] public string ClusterId { get; set; } = string.Empty; - private List? _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("Draft"), - GenerationStatus.Published => new MarkupString("Published"), - GenerationStatus.Superseded => new MarkupString("Superseded"), - _ => new MarkupString($"{s}"), - }; -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor deleted file mode 100644 index 5a1a94c..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/IdentificationFields.razor +++ /dev/null @@ -1,51 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities - -@* Reusable OPC 40010 Machinery Identification editor. Binds to an Equipment row and renders the - nine decision #139 fields in a consistent 3-column Bootstrap grid. Used by EquipmentTab's - create + edit forms so the same UI renders regardless of which flow opened it. *@ - -
-
-
OPC 40010 Identification
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -@code { - [Parameter, EditorRequired] public Equipment? Equipment { get; set; } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor deleted file mode 100644 index 13d9d63..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor +++ /dev/null @@ -1,228 +0,0 @@ -@page "/clusters/{ClusterId}/draft/{GenerationId:long}/import-equipment" -@attribute [Microsoft.AspNetCore.Authorization.Authorize] -@using Microsoft.AspNetCore.Components.Authorization -@using Microsoft.AspNetCore.Components.Web -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@rendermode RenderMode.InteractiveServer -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@using ZB.MOM.WW.OtOpcUa.Configuration.Enums -@inject DriverInstanceService DriverSvc -@inject UnsService UnsSvc -@inject EquipmentImportBatchService BatchSvc -@inject NavigationManager Nav -@inject AuthenticationStateProvider AuthProvider - - - -
- Importing equipment into cluster @ClusterId requires the - ConfigEditor role for this cluster. -
-
- - -
-
-

Equipment CSV import

- Cluster @ClusterId · draft generation @GenerationId -
- Back to draft -
- -
- Accepts @EquipmentCsvImporter.VersionMarker-headered CSV per Stream B.3. - Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns). - Optional columns cover the OPC 40010 Identification fields. Paste the file contents - or upload directly — the parser runs client-stream-side and shows a row-level preview - before anything lands in the draft. ZTag + SAPID reservation conflicts (task #197) are - checked at parse time: rows whose ZTag or SAPID is already reserved by a different - EquipmentUuid appear in the Rejected list so you can resolve them before finalising. -
- -
- Per-tag addressing for Modbus drivers isn't part of equipment import — - tags are configured at the driver-instance level via the - Drivers tab. Use the - address-preview tool to sanity-check - grammar strings (40001:F:CDAB, HR1:I, V2000 for - DL205 family, etc.) before pasting them into the driver config. -
- -
-
Import configuration
-
-
-
- - -
-
- - -
-
- -
-
-
- - -
- -@code { - [Parameter] public string Source { get; set; } = string.Empty; - [Parameter] public EventCallback SourceChanged { get; set; } - - private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}"; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - try - { - await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId); - } - catch (JSException) - { - // Monaco bundle not yet loaded on this page — textarea fallback is - // still functional. - } - } - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor deleted file mode 100644 index 19bbe25..0000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptedAlarmsTab.razor +++ /dev/null @@ -1,260 +0,0 @@ -@using ZB.MOM.WW.OtOpcUa.Admin.Services -@using ZB.MOM.WW.OtOpcUa.Configuration.Entities -@inject ScriptedAlarmService AlarmSvc -@inject ScriptService ScriptSvc - -
-
-

Scripted Alarms

- OPC UA Part 9 alarms raised by C# predicate scripts. Additive to driver-native alarm streams. -
- -
- -@if (_loading) -{ -

Loading…

-} -else if (_alarms.Count == 0 && !_showForm) -{ -
No scripted alarms yet in this draft.
-} -else -{ - @if (_alarms.Count > 0) - { -
-
- Scripted alarms in draft gen @GenerationId - @_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s") -
-
- - - - - - - - - - - - - - - - @foreach (var a in _alarms) - { - - - - - - - - - - - - } - -
NameEquipmentTypeSeverityPredicate scriptHistorizeRetainEnabled
@a.Name@a.EquipmentId@a.AlarmType@a.Severity @SeverityBand(a.Severity)@(ScriptName(a.PredicateScriptId)) - @if (a.HistorizeToAveva) { Aveva } - else { } - - @if (a.Retain) { yes } - else { } - - @if (a.Enabled) { enabled } - else { disabled } - - -
-
-
- } -} - -@if (_showForm) -{ -
-
- New scripted alarm - -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - @if (_scripts.Count == 0) - { -
No scripts in this draft — create one in the Scripts tab first.
- } -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
- - @if (_error is not null) - { -
@_error
- } - -
- - -
-
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private static readonly string[] AlarmTypes = - ["AlarmCondition", "LimitAlarm", "OffNormalAlarm", "DiscreteAlarm"]; - - private bool _loading = true; - private bool _busy; - private bool _showForm; - private List _alarms = []; - private List - -@if (_loading) {

Loading…

} -else if (_scripts.Count == 0 && _editing is null) -{ -
No scripts yet in this draft.
-} -else -{ -
-
-
- @foreach (var s in _scripts) - { - - } -
-
-
- @if (_editing is not null) - { -
-
- @(_isNew ? "New script" : _editing.Name) -
- @if (!_isNew) - { - - } - -
-
-
-
- - -
- - - -
- - -
- - @if (_dependencies is not null) - { -
- Inferred reads - @if (_dependencies.Reads.Count == 0) { none } - else - { -
    - @foreach (var r in _dependencies.Reads) {
  • @r
  • } -
- } - Inferred writes - @if (_dependencies.Writes.Count == 0) { none } - else - { -
    - @foreach (var w in _dependencies.Writes) {
  • @w
  • } -
- } - @if (_dependencies.Rejections.Count > 0) - { -
- Non-literal paths rejected: -
    - @foreach (var r in _dependencies.Rejections) {
  • @r.Message
  • } -
-
- } -
- } - - @if (_testResult is not null) - { -
- Harness result: @_testResult.Outcome - @if (_testResult.Outcome == ScriptTestOutcome.Success) - { -
Output: @(_testResult.Output?.ToString() ?? "null")
- @if (_testResult.Writes.Count > 0) - { -
Writes: -
    - @foreach (var kv in _testResult.Writes) {
  • @kv.Key = @(kv.Value?.ToString() ?? "null")
  • } -
-
- } - } - @if (_testResult.Errors.Count > 0) - { -
- @foreach (var e in _testResult.Errors) {
@e
} -
- } - @if (_testResult.LogEvents.Count > 0) - { -
Script log output: -
    - @foreach (var e in _testResult.LogEvents) {
  • [@e.Level] @e.RenderMessage()
  • } -
-
- } -
- } -
-
- } -
-
-} - -@code { - [Parameter] public long GenerationId { get; set; } - [Parameter] public string ClusterId { get; set; } = string.Empty; - - private bool _loading = true; - private bool _busy; - private bool _harnessBusy; - private bool _isNew; - private List + + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor similarity index 88% rename from src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor rename to src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index 10f394d..e5a89e5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -46,8 +46,14 @@
Scripting
Virtual tags Scripted alarms + Scripts Script log +
Live
+ Deployments + Alerts + Alarms historian +
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor new file mode 100644 index 0000000..7bf82ea --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Account.razor @@ -0,0 +1,78 @@ +@page "/account" +@* v1's Account page surfaced per-cluster role grants alongside identity. v2 dropped per-cluster + grants in favour of fleet-wide LDAP-group → role mapping (Q4 of the AdminUI rebuild plan), so + this version only shows identity + the resolved fleet roles + raw LDAP groups for + troubleshooting. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@using System.Security.Claims + +
+

My account

+
+ + + + @{ + var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.User.Identity?.Name ?? "—"; + var displayName = context.User.Identity?.Name ?? "—"; + var roles = context.User.Claims + .Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + var ldapGroups = context.User.Claims + .Where(c => c.Type == "ldap_group").Select(c => c.Value) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + } + +
+
+
Identity
+
Username@username
+
Display name@displayName
+
+ +
+
Fleet roles
+
+ Resolved roles + + @if (roles.Count == 0) + { + none — sign-in should have been blocked; session claim is likely stale + } + else + { + @foreach (var r in roles) + { + @r + } + } + +
+
+ LDAP groups + + @if (ldapGroups.Count == 0) + { + none + } + else + { + @foreach (var g in ldapGroups) + { + @g + } + } + +
+
+
+ +
+ Fleet roles come from LDAP group membership via the + Authentication:Ldap:GroupToRole mapping. To change them, + edit the LDAP group on the directory server; the next sign-in picks up the change. + Sign out + sign back in to refresh the cookie claim. +
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor new file mode 100644 index 0000000..237d01f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/AlarmsHistorian.razor @@ -0,0 +1,91 @@ +@page "/alarms-historian" +@* Live status of the local node's IAlarmHistorianSink (queue depth, drain state) via the + HistorianAdapterActor.GetStatus query landed in F11. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Akka.Actor +@using Akka.Hosting +@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian +@using ZB.MOM.WW.OtOpcUa.Runtime +@using ZB.MOM.WW.OtOpcUa.Runtime.Historian +@inject IRequiredActor HistorianActor +@implements IDisposable + +
+

Alarms historian sink

+
+ +
+ Snapshot from the local node's HistorianAdapterActor. Default sink + is a no-op (NullAlarmHistorianSink); production wires + SqliteStoreAndForwardSink with the Wonderware historian sidecar + behind it. Polling every @PollSeconds s. +
+ +@if (_status is null) +{ +

Loading…

+} +else +{ +
+
+
Queue
+
Depth@_status.QueueDepth
+
Dead-lettered@_status.DeadLetterDepth
+
Evicted (lifetime)@_status.EvictedCount
+
+ +
+
Drain state
+
State@_status.DrainState
+
Last drain@(_status.LastDrainUtc?.ToString("u") ?? "—")
+
Last success@(_status.LastSuccessUtc?.ToString("u") ?? "—")
+ @if (!string.IsNullOrWhiteSpace(_status.LastError)) + { +
Last error@_status.LastError
+ } +
+
+} + +@code { + private const int PollSeconds = 5; + + private HistorianSinkStatus? _status; + private Timer? _timer; + + protected override async Task OnInitializedAsync() + { + await RefreshAsync(); + _timer = new Timer(_ => _ = InvokeAsync(RefreshAsync), null, + TimeSpan.FromSeconds(PollSeconds), TimeSpan.FromSeconds(PollSeconds)); + } + + private async Task RefreshAsync() + { + try + { + _status = await HistorianActor.ActorRef.Ask( + HistorianAdapterActor.GetStatus.Instance, TimeSpan.FromSeconds(2)); + StateHasChanged(); + } + catch + { + // Actor unavailable (admin-only node, not driver-role) — leave _status null and let + // the page show "Loading…". A dedicated "this role doesn't run a historian" message + // would be nicer; lands when we add role gating to the UI. + } + } + + private static string StateChipClass(HistorianDrainState state) => state switch + { + HistorianDrainState.Disabled => "chip chip-idle", + HistorianDrainState.Idle => "chip chip-idle", + HistorianDrainState.Draining => "chip chip-ok", + HistorianDrainState.BackingOff => "chip chip-caution", + _ => "chip chip-idle", + }; + + public void Dispose() => _timer?.Dispose(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor new file mode 100644 index 0000000..95964f9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Alerts.razor @@ -0,0 +1,126 @@ +@page "/alerts" +@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent + AlarmTransitionEvent entries. Engine wiring (ScriptedAlarmActor publish on the `alerts` + topic) lands with F9; until then the connection stays open and the table is empty. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs +@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts +@inject NavigationManager Nav +@implements IAsyncDisposable + +
+

Alerts

+
+ + @(_connected ? "live" : "disconnected") + + +
+
+ +
+ Live alarm transitions from the cluster's alerts DPS topic. Shows + the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources: + ScriptedAlarmActor, native AB CIP ALMD bridge (F9), Galaxy alarm bridge (future). +
+ +@if (_rows.Count == 0) +{ +
+ No alarms yet. Engine wiring (F9 ScriptedAlarmActor) is pending; once it ships the table + below will start populating in real time. +
+} +else +{ +
+
Recent transitions (@_rows.Count)
+
+ + + + + + + + + + + + + + @foreach (var e in _rows) + { + + + + + + + + + + } + +
TimeAlarmEquipmentKindSeverityUserMessage
@e.TimestampUtc.ToString("HH:mm:ss.fff")@e.AlarmId
@e.AlarmName
@e.EquipmentPath@e.TransitionKind@e.Severity@e.User@e.Message
+
+
+} + +@code { + private const int Capacity = 200; + + private readonly List _rows = new(); + private HubConnection? _hub; + private bool _connected; + + protected override async Task OnInitializedAsync() + { + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri(AlertHub.Endpoint)) + .WithAutomaticReconnect() + .Build(); + + _hub.On(AlertHub.MethodName, evt => + { + _rows.Insert(0, evt); + if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); + InvokeAsync(StateHasChanged); + }); + _hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); }; + _hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); }; + + try + { + await _hub.StartAsync(); + _connected = true; + } + catch + { + // Connection failures (admin-only deployment, hub not mapped, etc.) leave the page + // showing "disconnected" — operator action: reload or talk to the host operator. + } + } + + private async Task ClearAsync() + { + _rows.Clear(); + await InvokeAsync(StateHasChanged); + } + + private static string KindChipClass(string kind) => kind switch + { + "Activated" => "chip-alert", + "Cleared" => "chip-ok", + "Acknowledged" or "Confirmed" => "chip-caution", + "Shelved" or "Disabled" => "chip-idle", + _ => "chip-idle", + }; + + public async ValueTask DisposeAsync() + { + if (_hub is not null) await _hub.DisposeAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor new file mode 100644 index 0000000..0ac3009 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor @@ -0,0 +1,111 @@ +@page "/certificates" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using System.Security.Cryptography.X509Certificates +@using Microsoft.Extensions.Configuration +@inject IConfiguration Config + +
+

OPC UA certificates

+
+ +
+ PKI store layout: {PkiStoreRoot}/own (this server's identity), + issuer / trusted (peers we accept), + rejected (peers we've turned away). F13a wires SDK + auto-creation so the own-store self-signs on first boot. +
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ + @foreach (var store in _rows) + { +
+
@store.Label · @store.Certificates.Count entry@(store.Certificates.Count == 1 ? "" : "s")
+ @if (string.IsNullOrEmpty(store.Path)) + { +
No path configured.
+ } + else if (!Directory.Exists(store.Path)) + { +
+ @store.Path doesn't exist yet. It will be created on first boot. +
+ } + else if (store.Certificates.Count == 0) + { +
No certificates in @store.Path.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var c in store.Certificates) + { + + + + + + + + } + +
SubjectIssuerThumbprintNot beforeNot after
@c.Subject@c.Issuer@c.Thumbprint[..16]…@c.NotBefore.ToString("u")@c.NotAfter.ToString("u")
+
+ } +
+ } +} + +@code { + private List? _rows; + + protected override void OnInitialized() + { + var pkiRoot = Config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; + _rows = new() + { + LoadStore("Own", Path.Combine(pkiRoot, "own", "certs")), + LoadStore("Trusted peers", Path.Combine(pkiRoot, "trusted", "certs")), + LoadStore("Trusted issuers", Path.Combine(pkiRoot, "issuer", "certs")), + LoadStore("Rejected", Path.Combine(pkiRoot, "rejected", "certs")), + }; + } + + private static StoreView LoadStore(string label, string path) + { + var view = new StoreView(label, path, new List()); + if (!Directory.Exists(path)) return view; + foreach (var file in Directory.EnumerateFiles(path).Where(IsCertFile)) + { + try { view.Certificates.Add(X509CertificateLoader.LoadCertificateFromFile(file)); } + catch { /* ignore unreadable entries */ } + } + return view; + } + + private static bool IsCertFile(string path) + { + var ext = Path.GetExtension(path); + return ext.Equals(".der", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".cer", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".crt", StringComparison.OrdinalIgnoreCase); + } + + private sealed record StoreView(string Label, string Path, List Certificates); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor new file mode 100644 index 0000000..aa3390f --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/AclEdit.razor @@ -0,0 +1,228 @@ +@page "/clusters/{ClusterId}/acls/new" +@page "/clusters/{ClusterId}/acls/{NodeAclId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New ACL grant" : "Edit ACL grant") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
ACL @NodeAclId not found.
+} +else +{ + + +
+
Grant
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + +
+
+ + +
+
+
+ +
+ @foreach (var bit in PermissionBits) + { +
+ + +
+ } +
+
+ Bundles: + + · + + · + + · + +
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + private static readonly NodePermissions[] PermissionBits = + [ + NodePermissions.Browse, NodePermissions.Read, NodePermissions.Subscribe, NodePermissions.HistoryRead, + NodePermissions.WriteOperate, NodePermissions.WriteTune, NodePermissions.WriteConfigure, + NodePermissions.AlarmRead, NodePermissions.AlarmAcknowledge, NodePermissions.AlarmConfirm, NodePermissions.AlarmShelve, + NodePermissions.MethodCall, + ]; + + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NodeAclId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NodeAclId); + + private FormModel _form = new(); + private NodeAcl? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (!IsNew) + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.NodeAcls.AsNoTracking().FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (_existing is not null) + { + _form = new FormModel + { + NodeAclId = _existing.NodeAclId, + LdapGroup = _existing.LdapGroup, + ScopeKind = _existing.ScopeKind, + ScopeId = _existing.ScopeId, + PermissionFlags = _existing.PermissionFlags, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.NodeAcls.AnyAsync(a => a.NodeAclId == _form.NodeAclId)) + { _error = $"ACL '{_form.NodeAclId}' already exists."; return; } + db.NodeAcls.Add(new NodeAcl + { + NodeAclId = _form.NodeAclId, + ClusterId = ClusterId, + LdapGroup = _form.LdapGroup, + ScopeKind = _form.ScopeKind, + ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId, + PermissionFlags = _form.PermissionFlags, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.LdapGroup = _form.LdapGroup; + entity.ScopeKind = _form.ScopeKind; + entity.ScopeId = string.IsNullOrWhiteSpace(_form.ScopeId) ? null : _form.ScopeId; + entity.PermissionFlags = _form.PermissionFlags; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/acls"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.NodeAcls.FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.NodeAclId == NodeAclId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/acls"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.NodeAcls.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/acls"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this ACL while you were viewing it."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string NodeAclId { get; set; } = ""; + [Required] public string LdapGroup { get; set; } = ""; + public NodeAclScopeKind ScopeKind { get; set; } = NodeAclScopeKind.Cluster; + public string? ScopeId { get; set; } + public NodePermissions PermissionFlags { get; set; } = NodePermissions.None; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + + public bool HasPerm(NodePermissions bit) => PermissionFlags.HasFlag(bit); + public void SetPerm(NodePermissions bit, bool on) + { + if (on) PermissionFlags |= bit; + else PermissionFlags &= ~bit; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor new file mode 100644 index 0000000..5393a72 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAcls.razor @@ -0,0 +1,99 @@ +@page "/clusters/{ClusterId}/acls" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

ACLs · @ClusterId

+ New ACL grant +
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ ACL rows grant LDAP groups specific NodePermissions on a scope + (a folder, an equipment, a tag). Q4 of the AdminUI rebuild plan dropped per-cluster role + grants in favour of fleet-wide LDAP-group → role mapping; ACLs here are the finer-grained + per-node scope. Live editing lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count ACL row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No ACL rows for this cluster — default permissions from the fleet-wide LDAP group mapping apply.
+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var a in _rows) + { + + + + + + + + + + } + +
NodeAclIdLDAP groupScopeScope targetPermissionsNotes
@a.NodeAclId@a.LdapGroup@a.ScopeKind@(a.ScopeId ?? "—") + @foreach (var perm in PermissionChips(a.PermissionFlags)) + { + @perm + } + @(a.Notes ?? "")Edit
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.NodeAcls.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderBy(a => a.NodeAclId) + .ToListAsync(); + } + + private static IEnumerable PermissionChips(ZB.MOM.WW.OtOpcUa.Configuration.Enums.NodePermissions flags) + { + foreach (var v in Enum.GetValues()) + { + // Skip None (zero) and composite values that aren't single bits. + var n = (int)v; + if (n == 0) continue; + if ((n & (n - 1)) != 0) continue; + if (flags.HasFlag(v)) yield return v.ToString(); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor new file mode 100644 index 0000000..13c50dd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterAudit.razor @@ -0,0 +1,83 @@ +@page "/clusters/{ClusterId}/audit" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Audit log · @ClusterId

+
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Latest @PageSize audit rows scoped to this cluster, newest first. EventId/CorrelationId + columns (F3) make cross-restart deduplication possible — Akka actors that retry an apply + won't insert duplicate rows. Details JSON is shown verbatim. +
+ +
+
@_rows.Count row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No audit rows for this cluster yet.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var a in _rows) + { + + + + + + + + + } + +
TimestampPrincipalEventNodeCorrelationDetails
@a.Timestamp.ToString("u")@a.Principal@a.EventType@(a.NodeId ?? "—")@(a.CorrelationId?.ToString("N")[..8] ?? "—") + @(a.DetailsJson ?? "") +
+
+ } +
+} + +@code { + private const int PageSize = 200; + + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ConfigAuditLogs.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderByDescending(a => a.Timestamp) + .Take(PageSize) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor new file mode 100644 index 0000000..9c4dc02 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterDrivers.razor @@ -0,0 +1,90 @@ +@page "/clusters/{ClusterId}/drivers" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Drivers · @ClusterId

+ New driver +
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Per Q1 of the AdminUI rebuild plan, typed driver editors (Modbus, FOCAS) are deferred. + The expanded view below shows raw JSON config. Live editing — including a generic JSON + editor and per-driver-type forms when operators ask — lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No driver instances for this cluster.
+ } + else + { + @foreach (var d in _rows) + { +
+ + @d.DriverInstanceId + · @d.Name + · @d.DriverType + @if (!d.Enabled) { Disabled } + ns=@d.NamespaceId + +
+
+ Edit +
+
@FormatJson(d.DriverConfig)
+ @if (!string.IsNullOrWhiteSpace(d.ResilienceConfig)) + { +
Resilience overrides:
+
@FormatJson(d.ResilienceConfig)
+ } +
+
+ } + } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + } + + private static string FormatJson(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return ""; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(raw); + return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return raw; + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor new file mode 100644 index 0000000..a159819 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEdit.razor @@ -0,0 +1,200 @@ +@page "/clusters/{ClusterId}/edit" +@* Edit page for an existing ServerCluster. The /clusters/new route lives in NewCluster.razor; + this page handles only the update case so the form can disable ClusterId (immutable). *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthState + +
+

Edit cluster · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (_existing is null) +{ +
+ Cluster @ClusterId was not found. +
+} +else +{ + + +
+
Identity
+
+
+ + +
Immutable after creation. Operator-visible everywhere; renames would invalidate every downstream reference.
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
Topology
+
+
+ + + + + + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private FormModel _form = new(); + private ServerCluster? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_existing is not null) + { + _form = new FormModel + { + Name = _existing.Name, + Enterprise = _existing.Enterprise, + Site = _existing.Site, + RedundancyMode = _existing.RedundancyMode, + Enabled = _existing.Enabled, + Notes = _existing.Notes, + }; + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (entity is null) { _error = "Cluster no longer exists."; return; } + + var auth = await AuthState.GetAuthenticationStateAsync(); + entity.Name = _form.Name; + entity.Enterprise = _form.Enterprise; + entity.Site = _form.Site; + entity.RedundancyMode = _form.RedundancyMode; + entity.NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2; + entity.Enabled = _form.Enabled; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + entity.ModifiedAt = DateTime.UtcNow; + entity.ModifiedBy = auth.User.Identity?.Name ?? "(anonymous)"; + + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task DeleteAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (entity is null) { Nav.NavigateTo("/clusters"); return; } + db.ServerClusters.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo("/clusters"); + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. Likely because nodes, namespaces, drivers, or other rows still reference this cluster — remove them first."; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required] public string Name { get; set; } = ""; + [Required] public string Enterprise { get; set; } = ""; + [Required] public string Site { get; set; } = ""; + public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None; + public bool Enabled { get; set; } = true; + public string? Notes { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor new file mode 100644 index 0000000..c1b18e0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterEquipment.razor @@ -0,0 +1,91 @@ +@page "/clusters/{ClusterId}/equipment" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Equipment · @ClusterId

+ +
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Equipment rows are scoped to a UNS line and bound to a single driver. EquipmentId is + system-generated (decision #125); browse identifiers are MachineCode (operator) + ZTag + (ERP). Live editing lands in a Phase C.2 follow-up. +
+ +
+
@_rows.Count equipment row@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No equipment defined for this cluster.
+ } + else + { +
+ + + + + + + + + + + + + + + @foreach (var e in _rows) + { + + + + + + + + + + + } + +
EquipmentIdNameMachineCodeZTagDriverUNS lineIdentification
@e.EquipmentId@e.Name@e.MachineCode@(e.ZTag ?? "—")@e.DriverInstanceId@e.UnsLineId + @if (!string.IsNullOrWhiteSpace(e.Manufacturer)) { @e.Manufacturer } + @if (!string.IsNullOrWhiteSpace(e.Model)) { / @e.Model } + Edit
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var driversInCluster = db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId).Select(d => d.DriverInstanceId); + _rows = await db.Equipment.AsNoTracking() + .Where(e => driversInCluster.Contains(e.DriverInstanceId)) + .OrderBy(e => e.Name) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor new file mode 100644 index 0000000..30b50d8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterNamespaces.razor @@ -0,0 +1,82 @@ +@page "/clusters/{ClusterId}/namespaces" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Namespaces · @ClusterId

+ New namespace +
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Namespaces are content (decision #123) — they're served at the OPC UA endpoint and bound + to driver instances. NamespaceUri must be unique fleet-wide. Live editing lands in a + Phase C.2 follow-up. +
+ +
+
@_rows.Count namespace@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No namespaces defined for this cluster.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var n in _rows) + { + + + + + + + + + } + +
NamespaceIdKindURIStatusNotes
@n.NamespaceId@n.Kind@n.NamespaceUri + @if (n.Enabled) { Enabled } + else { Disabled } + @(n.Notes ?? "")Edit
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor new file mode 100644 index 0000000..d5d7c28 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterOverview.razor @@ -0,0 +1,139 @@ +@page "/clusters/{ClusterId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +@if (!_loaded) +{ +

Loading…

+} +else if (_cluster is null) +{ +
+ Cluster @ClusterId was not found. + Back to list. +
+} +else +{ +
+
+

@_cluster.Name

+ @_cluster.ClusterId + @if (!_cluster.Enabled) { Disabled } +
+ +
+ + + +
+
+
Cluster details
+
Enterprise / Site@_cluster.Enterprise / @_cluster.Site
+
Redundancy@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))
+
Created@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy
+ @if (_cluster.ModifiedAt is not null) + { +
Modified@_cluster.ModifiedAt?.ToString("u") by @(_cluster.ModifiedBy ?? "—")
+ } + @if (!string.IsNullOrWhiteSpace(_cluster.Notes)) + { +
Notes@_cluster.Notes
+ } +
+ +
+
Last deployment
+ @if (_lastDeployment is null) + { +
Statusnone — cluster has never been deployed
+ } + else + { +
Revision@_lastDeployment.RevisionHash[..16]…
+
Status@_lastDeployment.Status
+
Created@_lastDeployment.CreatedAtUtc.ToString("u")
+ @if (_lastDeployment.SealedAtUtc is not null) + { +
Sealed@_lastDeployment.SealedAtUtc?.ToString("u")
+ } + } +
+
+ +
+
+ Nodes + New node +
+ @if (_nodes is null || _nodes.Count == 0) + { +
No nodes registered.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var n in _nodes) + { + + + + + + + + + } + +
Node IDHostOPC UA portApplicationUriServiceLevel base
@n.NodeId@n.Host@n.OpcUaPort@n.ApplicationUri@n.ServiceLevelBaseEdit
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private bool _loaded; + private ServerCluster? _cluster; + private List? _nodes; + private Deployment? _lastDeployment; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _cluster = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_cluster is not null) + { + _nodes = await db.ClusterNodes.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NodeId) + .ToListAsync(); + _lastDeployment = await db.Deployments.AsNoTracking() + .OrderByDescending(d => d.CreatedAtUtc) + .FirstOrDefaultAsync(); + } + _loaded = true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor new file mode 100644 index 0000000..e5c0028 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterRedundancy.razor @@ -0,0 +1,108 @@ +@page "/clusters/{ClusterId}/redundancy" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +@if (!_loaded) +{ +

Loading…

+} +else if (_cluster is null) +{ +
+ Cluster @ClusterId was not found. + Back to list. +
+} +else +{ +
+
+

@_cluster.Name · Redundancy

+ @_cluster.ClusterId +
+
+ + + +
+ v2 redundancy is computed at runtime by RedundancyStateActor + on each admin node. The values below are the static configuration; the resolved live + ServiceLevel for each peer is broadcast on the + redundancy-state DPS topic and consumed by the OPC UA host's + ServerStatus publisher. See + docs/v2/Architecture-v2.md. +
+ +
+
+
Cluster redundancy
+
Mode@_cluster.RedundancyMode
+
Node count@_cluster.NodeCount
+
+
+ +
+
Node service-level configuration
+ @if (_nodes is null || _nodes.Count == 0) + { +
No nodes registered.
+ } + else + { +
+ + + + + + + + + + + @foreach (var n in _nodes) + { + + + + + + + } + +
Node IDApplicationUriServiceLevel baseNotes
@n.NodeId@n.ApplicationUri@n.ServiceLevelBase + @if (n.ServiceLevelBase >= 200) { Primary preference } + else if (n.ServiceLevelBase >= 100) { Secondary preference } + else { Custom } +
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private bool _loaded; + private ServerCluster? _cluster; + private List? _nodes; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _cluster = await db.ServerClusters.AsNoTracking() + .FirstOrDefaultAsync(c => c.ClusterId == ClusterId); + if (_cluster is not null) + { + _nodes = await db.ClusterNodes.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NodeId) + .ToListAsync(); + } + _loaded = true; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor new file mode 100644 index 0000000..1385930 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterTags.razor @@ -0,0 +1,106 @@ +@page "/clusters/{ClusterId}/tags" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

Tags · @ClusterId

+ New tag +
+ + + +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ Tags are bound to a driver instance and (optionally) an equipment + poll group. The view + below shows the first @PageSize tags by Name; full pagination + search land in Phase C.2. +
+ +
+ + + Showing @VisibleRows.Count of @_rows.Count + +
+ +
+
Tags
+ @if (VisibleRows.Count == 0) + { +
No tags match the current filter.
+ } + else + { +
+ + + + + + + + + + + + + + + + @foreach (var t in VisibleRows) + { + + + + + + + + + + + + } + +
TagIdNameDriverEquipmentData typeAccessFolderPoll group
@t.TagId@t.Name@t.DriverInstanceId@(t.EquipmentId ?? "—")@t.DataType@t.AccessLevel@(t.FolderPath ?? "")@(t.PollGroupId ?? "—")Edit
+
+ } +
+} + +@code { + private const int PageSize = 200; + + [Parameter] public string ClusterId { get; set; } = ""; + private List? _rows; + private string _filter = ""; + + private List VisibleRows => (_rows ?? new()) + .Where(t => string.IsNullOrWhiteSpace(_filter) + || t.Name.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + .Take(PageSize) + .ToList(); + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + // Tags don't carry ClusterId; resolve via DriverInstance scoping. + var driverIds = db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .Select(d => d.DriverInstanceId); + _rows = await db.Tags.AsNoTracking() + .Where(t => driverIds.Contains(t.DriverInstanceId)) + .OrderBy(t => t.Name) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor new file mode 100644 index 0000000..55734e3 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClusterUns.razor @@ -0,0 +1,107 @@ +@page "/clusters/{ClusterId}/uns" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

UNS structure · @ClusterId

+
+ + + +@if (_areas is null || _lines is null) +{ +

Loading…

+} +else +{ +
+ UNS levels: Enterprise (cluster) → Site (cluster) → Area → Line → Equipment. Areas and + lines are cluster-scoped; equipment hangs under a single line. Live editing lands in a + Phase C.2 follow-up. +
+ +
+
+ Areas (level 3) · @_areas.Count + New area +
+ @if (_areas.Count == 0) + { +
No areas defined.
+ } + else + { +
+ + + + @foreach (var a in _areas) + { + + + + + + + } + +
UnsAreaIdNameNotes
@a.UnsAreaId@a.Name@(a.Notes ?? "")Edit
+
+ } +
+ +
+
+ Lines (level 4) · @_lines.Count + New line +
+ @if (_lines.Count == 0) + { +
No lines defined.
+ } + else + { +
+ + + + @foreach (var l in _lines) + { + + + + + + + + } + +
UnsLineIdNameAreaNotes
@l.UnsLineId@l.Name@l.UnsAreaId@(l.Notes ?? "")Edit
+
+ } +
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + private List? _areas; + private List? _lines; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _areas = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(); + var areaIds = _areas.Select(a => a.UnsAreaId).ToList(); + _lines = await db.UnsLines.AsNoTracking() + .Where(l => areaIds.Contains(l.UnsAreaId)) + .OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor new file mode 100644 index 0000000..31415f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ClustersList.razor @@ -0,0 +1,77 @@ +@page "/clusters" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

Clusters

+ New cluster +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No clusters defined yet. Use New cluster above to create one. +
+} +else +{ +
+
All clusters
+
+ + + + + + + + + + + + + @foreach (var c in _rows) + { + + + + + + + + + } + +
ClusterSiteNodesRedundancyStatusCreated
+ @c.ClusterId +
@c.Name
+
@c.Enterprise / @c.Site@c.NodeCount@c.RedundancyMode + @if (c.Enabled) { Enabled } + else { Disabled } + @c.CreatedAt.ToString("u")
+
+
+} + +@code { + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ServerClusters.AsNoTracking() + .OrderBy(c => c.ClusterId) + .ToListAsync(); + } + + private void OpenCluster(string clusterId) => Nav.NavigateTo($"/clusters/{clusterId}"); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor new file mode 100644 index 0000000..585b94a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/DriverEdit.razor @@ -0,0 +1,323 @@ +@page "/clusters/{ClusterId}/drivers/new" +@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}" +@* Per Q1 of the AdminUI rebuild plan — JSON editor only, typed driver editors deferred. + DriverInstance is the keystone for everything downstream (Equipment, Tag, VirtualTag, + ScriptedAlarm all reference DriverInstanceId), so this is the second edit page after + Namespace. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New driver instance" : "Edit driver instance") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Driver instance @DriverInstanceId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
@(IsNew ? "Identity" : $"Edit {_form.DriverInstanceId}")
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + + + + + +
Cannot be changed after creation — drives the actor type that owns this instance.
+
+
+ + + @foreach (var ns in _namespaces) + { + + } + +
+
+
+ +
+ + +
+
+
+
+ +
+
Driver config (JSON)
+
+ +
Schemaless per driver type — validated server-side at deploy time. JSON is reformatted on save.
+
+
+ +
+
Resilience overrides (optional)
+
+ +
Polly pipeline overrides per docs/v2/driver-stability.md — bulkhead, retry counts, breaker thresholds. Null = use the driver type's tier defaults.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? DriverInstanceId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(DriverInstanceId); + + private FormModel _form = new(); + private DriverInstance? _existing; + private List _namespaces = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .OrderBy(n => n.NamespaceId) + .ToListAsync(); + + if (IsNew) + { + _form = new FormModel + { + DriverInstanceId = "", + Name = "", + DriverType = "ModbusTcp", + NamespaceId = _namespaces.FirstOrDefault()?.NamespaceId ?? "", + Enabled = true, + DriverConfig = "{}", + }; + } + else + { + _existing = await db.DriverInstances.AsNoTracking() + .FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (_existing is not null) + { + _form = new FormModel + { + DriverInstanceId = _existing.DriverInstanceId, + Name = _existing.Name, + DriverType = _existing.DriverType, + NamespaceId = _existing.NamespaceId, + Enabled = _existing.Enabled, + DriverConfig = _existing.DriverConfig, + ResilienceConfig = _existing.ResilienceConfig, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + var normalizedConfig = NormalizeJson(_form.DriverConfig); + if (normalizedConfig is null) + { + _error = "DriverConfig is not valid JSON."; + return; + } + var normalizedResilience = NormalizeOptionalJson(_form.ResilienceConfig); + if (!string.IsNullOrWhiteSpace(_form.ResilienceConfig) && normalizedResilience is null) + { + _error = "ResilienceConfig is not valid JSON. Leave blank to use defaults."; + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _form.DriverInstanceId)) + { + _error = $"Driver instance '{_form.DriverInstanceId}' already exists."; + return; + } + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = _form.DriverInstanceId, + ClusterId = ClusterId, + NamespaceId = _form.NamespaceId, + Name = _form.Name, + DriverType = _form.DriverType, + Enabled = _form.Enabled, + DriverConfig = normalizedConfig, + ResilienceConfig = normalizedResilience, + }); + } + else + { + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) + { + _error = "Row no longer exists."; + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.NamespaceId = _form.NamespaceId; + entity.Name = _form.Name; + entity.Enabled = _form.Enabled; + entity.DriverConfig = normalizedConfig; + entity.ResilienceConfig = normalizedResilience; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.DriverInstances.FirstOrDefaultAsync( + d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId); + if (entity is null) + { + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.DriverInstances.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this driver instance while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)"; + } + finally + { + _busy = false; + } + } + + private static string? NormalizeJson(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(input); + return System.Text.Json.JsonSerializer.Serialize(doc.RootElement); + } + catch { return null; } + } + + private static string? NormalizeOptionalJson(string? input) => + string.IsNullOrWhiteSpace(input) ? null : NormalizeJson(input); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")] + public string DriverInstanceId { get; set; } = ""; + [Required] + public string Name { get; set; } = ""; + [Required] + public string DriverType { get; set; } = "ModbusTcp"; + [Required] + public string NamespaceId { get; set; } = ""; + public bool Enabled { get; set; } = true; + [Required] + public string DriverConfig { get; set; } = "{}"; + public string? ResilienceConfig { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor new file mode 100644 index 0000000..eacb279 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/EquipmentEdit.razor @@ -0,0 +1,310 @@ +@page "/clusters/{ClusterId}/equipment/new" +@page "/clusters/{ClusterId}/equipment/{EquipmentId}" +@* Equipment CRUD. EquipmentId is system-generated (decision #125) — operator picks Name + + MachineCode + UnsLine + Driver; the EquipmentId is derived from the EquipmentUuid on create. + OPC 40010 identification fields (Manufacturer, Model, etc.) are all optional. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New equipment" : "Edit equipment") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Equipment @EquipmentId was not found. +
+} +else +{ + + +
+
Identity
+
+ @if (!IsNew) + { +
+ + +
System-generated; never operator-edited.
+
+ } +
+
+ + +
UNS level 5 segment; lowercase letters, digits, dashes, up to 32 chars.
+
+
+ + +
+
+
+
+ + + + @foreach (var l in _lines) + { + + } + +
+
+ + + + @foreach (var d in _drivers) + { + + } + +
+
+
+
+ + +
Unique fleet-wide via ExternalIdReservation.
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
OPC 40010 identification (optional)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? EquipmentId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(EquipmentId); + + private FormModel _form = new(); + private Equipment? _existing; + private List _lines = new(); + private List _drivers = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + var areaIds = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId).Select(a => a.UnsAreaId).ToListAsync(); + _lines = await db.UnsLines.AsNoTracking() + .Where(l => areaIds.Contains(l.UnsAreaId)) + .OrderBy(l => l.UnsAreaId).ThenBy(l => l.UnsLineId) + .ToListAsync(); + _drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + + if (!IsNew) + { + _existing = await db.Equipment.AsNoTracking() + .FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (_existing is not null) + { + _form = new FormModel + { + Name = _existing.Name, + MachineCode = _existing.MachineCode, + UnsLineId = _existing.UnsLineId, + DriverInstanceId = _existing.DriverInstanceId, + ZTag = _existing.ZTag, + SAPID = _existing.SAPID, + Manufacturer = _existing.Manufacturer, + Model = _existing.Model, + SerialNumber = _existing.SerialNumber, + HardwareRevision = _existing.HardwareRevision, + SoftwareRevision = _existing.SoftwareRevision, + YearOfConstruction = _existing.YearOfConstruction, + AssetLocation = _existing.AssetLocation, + ManufacturerUri = _existing.ManufacturerUri, + DeviceManualUri = _existing.DeviceManualUri, + Enabled = _existing.Enabled, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + if (string.IsNullOrEmpty(_form.UnsLineId)) { _error = "Pick a UNS line."; return; } + if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver instance."; return; } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + var uuid = Guid.NewGuid(); + var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; + if (await db.Equipment.AnyAsync(e => e.MachineCode == _form.MachineCode)) + { _error = $"MachineCode '{_form.MachineCode}' already exists in this fleet."; return; } + db.Equipment.Add(new Equipment + { + EquipmentId = equipmentId, + EquipmentUuid = uuid, + DriverInstanceId = _form.DriverInstanceId, + UnsLineId = _form.UnsLineId, + Name = _form.Name, + MachineCode = _form.MachineCode, + ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag, + SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID, + Manufacturer = _form.Manufacturer, + Model = _form.Model, + SerialNumber = _form.SerialNumber, + HardwareRevision = _form.HardwareRevision, + SoftwareRevision = _form.SoftwareRevision, + YearOfConstruction = _form.YearOfConstruction, + AssetLocation = _form.AssetLocation, + ManufacturerUri = _form.ManufacturerUri, + DeviceManualUri = _form.DeviceManualUri, + Enabled = _form.Enabled, + }); + } + else + { + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.DriverInstanceId = _form.DriverInstanceId; + entity.UnsLineId = _form.UnsLineId; + entity.Name = _form.Name; + entity.MachineCode = _form.MachineCode; + entity.ZTag = string.IsNullOrWhiteSpace(_form.ZTag) ? null : _form.ZTag; + entity.SAPID = string.IsNullOrWhiteSpace(_form.SAPID) ? null : _form.SAPID; + entity.Manufacturer = _form.Manufacturer; + entity.Model = _form.Model; + entity.SerialNumber = _form.SerialNumber; + entity.HardwareRevision = _form.HardwareRevision; + entity.SoftwareRevision = _form.SoftwareRevision; + entity.YearOfConstruction = _form.YearOfConstruction; + entity.AssetLocation = _form.AssetLocation; + entity.ManufacturerUri = _form.ManufacturerUri; + entity.DeviceManualUri = _form.DeviceManualUri; + entity.Enabled = _form.Enabled; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == EquipmentId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Equipment.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this equipment while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[a-z0-9-]{1,32}$", ErrorMessage = "Lowercase letters, digits, dashes only; max 32 chars.")] + public string Name { get; set; } = ""; + [Required] public string MachineCode { get; set; } = ""; + [Required] public string UnsLineId { get; set; } = ""; + [Required] public string DriverInstanceId { get; set; } = ""; + public string? ZTag { get; set; } + public string? SAPID { get; set; } + 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; } + public bool Enabled { get; set; } = true; + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor new file mode 100644 index 0000000..e5dd0ee --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/ImportEquipment.razor @@ -0,0 +1,256 @@ +@page "/clusters/{ClusterId}/equipment/import" +@* Bulk equipment import via pasted CSV. Header row required; columns: + Name, MachineCode, UnsLineId, DriverInstanceId, ZTag, SAPID, Manufacturer, Model + Empty optional columns parsed as null. EquipmentId is system-generated per row + (matches single-add path in EquipmentEdit.razor). *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

Import equipment · @ClusterId

+ Cancel +
+ + + +
+ Paste CSV below. Required header columns (in order): + Name, MachineCode, UnsLineId, DriverInstanceId. + Optional: ZTag, SAPID, Manufacturer, Model. + Each row inserts one Equipment with a freshly-generated EquipmentId. Existing rows are + detected by MachineCode and skipped (the importer is additive-only — no updates). +
+ +
+
CSV
+
+ +
+
+ +@if (!string.IsNullOrWhiteSpace(_error)) +{ +
@_error
+} + +@if (_preview is not null) +{ +
+
Preview · @_preview.Count row@(_preview.Count == 1 ? "" : "s") to import
+ @if (_preview.Count > 0) + { +
+ + + + + + + + + + + + + + + + @foreach (var p in _preview) + { + + + + + + + + + + + + } + +
NameMachineCodeUNS lineDriverZTagSAPIDManufacturerModelStatus
@p.Name@p.MachineCode@p.UnsLineId@p.DriverInstanceId@(p.ZTag ?? "")@(p.SAPID ?? "")@(p.Manufacturer ?? "")@(p.Model ?? "") + @if (p.IsSkipped) { skip — exists } + else if (!string.IsNullOrEmpty(p.RowError)) { @p.RowError } + else { ready } +
+
+ } +
+} + +
+ + + Cancel +
+ +@code { + [Parameter] public string ClusterId { get; set; } = ""; + + private string _csv = ""; + private List? _preview; + private bool _busy; + private string? _error; + + private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"]; + private static readonly string[] OptionalColumns = ["ZTag", "SAPID", "Manufacturer", "Model"]; + + private async Task PreviewAsync() + { + _busy = true; + _error = null; + _preview = null; + try + { + var parsed = ParseCsv(_csv); + if (parsed is null) return; + + await using var db = await DbFactory.CreateDbContextAsync(); + var driversInCluster = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .Select(d => d.DriverInstanceId) + .ToListAsync(); + var driverSet = driversInCluster.ToHashSet(StringComparer.Ordinal); + var areaIds = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .Select(a => a.UnsAreaId).ToListAsync(); + var validLines = await db.UnsLines.AsNoTracking() + .Where(l => areaIds.Contains(l.UnsAreaId)) + .Select(l => l.UnsLineId).ToListAsync(); + var lineSet = validLines.ToHashSet(StringComparer.Ordinal); + var existingMachineCodes = await db.Equipment.AsNoTracking() + .Select(e => e.MachineCode).ToListAsync(); + var existingSet = existingMachineCodes.ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in parsed) + { + if (existingSet.Contains(row.MachineCode)) + { + row.IsSkipped = true; + continue; + } + if (!driverSet.Contains(row.DriverInstanceId)) + { + row.RowError = $"driver '{row.DriverInstanceId}' not in this cluster"; + continue; + } + if (!lineSet.Contains(row.UnsLineId)) + { + row.RowError = $"UNS line '{row.UnsLineId}' not in this cluster"; + } + } + _preview = parsed; + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task ImportAsync() + { + if (_preview is null) return; + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var added = 0; + foreach (var row in _preview.Where(p => !p.IsSkipped && string.IsNullOrEmpty(p.RowError))) + { + var uuid = Guid.NewGuid(); + var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; + db.Equipment.Add(new Equipment + { + EquipmentId = equipmentId, + EquipmentUuid = uuid, + DriverInstanceId = row.DriverInstanceId, + UnsLineId = row.UnsLineId, + Name = row.Name, + MachineCode = row.MachineCode, + ZTag = row.ZTag, + SAPID = row.SAPID, + Manufacturer = row.Manufacturer, + Model = row.Model, + Enabled = true, + }); + added++; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/equipment"); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private List? ParseCsv(string csv) + { + if (string.IsNullOrWhiteSpace(csv)) { _error = "CSV is empty."; return null; } + var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 2) { _error = "Need a header row and at least one data row."; return null; } + + var header = lines[0].Split(',').Select(c => c.Trim()).ToArray(); + for (var i = 0; i < RequiredColumns.Length; i++) + { + if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase)) + { + _error = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}')."; + return null; + } + } + + var rows = new List(); + for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++) + { + var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray(); + if (parts.Length < RequiredColumns.Length) + { + rows.Add(new PreviewRow { RowError = $"too few columns (got {parts.Length}, need {RequiredColumns.Length})" }); + continue; + } + rows.Add(new PreviewRow + { + Name = parts[0], + MachineCode = parts[1], + UnsLineId = parts[2], + DriverInstanceId = parts[3], + ZTag = NullIfEmpty(parts, 4), + SAPID = NullIfEmpty(parts, 5), + Manufacturer = NullIfEmpty(parts, 6), + Model = NullIfEmpty(parts, 7), + }); + } + return rows; + } + + private static string? NullIfEmpty(string[] parts, int idx) => + idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null; + + private sealed class PreviewRow + { + public string Name { get; set; } = ""; + public string MachineCode { get; set; } = ""; + public string UnsLineId { get; set; } = ""; + public string DriverInstanceId { get; set; } = ""; + public string? ZTag { get; set; } + public string? SAPID { get; set; } + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public bool IsSkipped { get; set; } + public string? RowError { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor new file mode 100644 index 0000000..68499e8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NamespaceEdit.razor @@ -0,0 +1,244 @@ +@page "/clusters/{ClusterId}/namespaces/new" +@page "/clusters/{ClusterId}/namespaces/{NamespaceId}" +@* Live-edit form pattern — one page handles both create (NamespaceId is null) and update. + RowVersion is preserved across post-back so EF Core enforces last-write-wins; concurrency + conflicts surface as a toast and reload the current row. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New namespace" : "Edit namespace") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Namespace @NamespaceId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
@(IsNew ? "Identity" : $"Edit {_form.NamespaceId}")
+
+
+ + +
+
+
+ + + + + + +
+
+ +
+ + +
+
+
+
+ + +
Must be unique fleet-wide. Clients pin discovery here.
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NamespaceId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NamespaceId); + + private FormModel _form = new(); + private Namespace? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (IsNew) + { + _form = new FormModel + { + NamespaceId = "", + Kind = NamespaceKind.Equipment, + NamespaceUri = "", + Enabled = true, + }; + } + else + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.Namespaces.AsNoTracking() + .FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (_existing is not null) + { + _form = new FormModel + { + NamespaceId = _existing.NamespaceId, + Kind = _existing.Kind, + NamespaceUri = _existing.NamespaceUri, + Enabled = _existing.Enabled, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.Namespaces.AnyAsync(n => n.NamespaceId == _form.NamespaceId)) + { + _error = $"Namespace '{_form.NamespaceId}' already exists."; + return; + } + db.Namespaces.Add(new Namespace + { + NamespaceId = _form.NamespaceId, + ClusterId = ClusterId, + Kind = _form.Kind, + NamespaceUri = _form.NamespaceUri, + Enabled = _form.Enabled, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.Namespaces.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (entity is null) + { + _error = "Row no longer exists."; + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.Kind = _form.Kind; + entity.NamespaceUri = _form.NamespaceUri; + entity.Enabled = _form.Enabled; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this namespace while you were editing. Reload to see the latest values, then re-apply your changes."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Namespaces.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NamespaceId == NamespaceId); + if (entity is null) + { + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + return; + } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Namespaces.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/namespaces"); + } + catch (DbUpdateConcurrencyException) + { + _error = "Another user changed this namespace while you were viewing it. Reload before deleting."; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$", ErrorMessage = "Use letters, digits, dash, underscore.")] + public string NamespaceId { get; set; } = ""; + public NamespaceKind Kind { get; set; } = NamespaceKind.Equipment; + [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$", ErrorMessage = "Use a URN, e.g. urn:zb:warsaw-west:equipment.")] + public string NamespaceUri { get; set; } = ""; + public bool Enabled { get; set; } = true; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor new file mode 100644 index 0000000..647cdc5 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NewCluster.razor @@ -0,0 +1,141 @@ +@page "/clusters/new" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthState + +
+

New cluster

+ Cancel +
+ + + +
+
Identity
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
Topology
+
+
+ + + + + + +
NodeCount is implied — 1 for None, 2 for Warm/Hot.
+
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel +
+
+ +@code { + private FormModel _form = new(); + private string? _error; + private bool _busy; + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (await db.ServerClusters.AnyAsync(c => c.ClusterId == _form.ClusterId)) + { + _error = $"Cluster '{_form.ClusterId}' already exists."; + return; + } + + var auth = await AuthState.GetAuthenticationStateAsync(); + var createdBy = auth.User.Identity?.Name ?? "(anonymous)"; + + var entity = new ServerCluster + { + ClusterId = _form.ClusterId, + Name = _form.Name, + Enterprise = _form.Enterprise, + Site = _form.Site, + RedundancyMode = _form.RedundancyMode, + NodeCount = _form.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + Enabled = true, + CreatedAt = DateTime.UtcNow, + CreatedBy = createdBy, + }; + db.ServerClusters.Add(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{entity.ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [System.ComponentModel.DataAnnotations.Required, System.ComponentModel.DataAnnotations.RegularExpression("^[A-Z0-9_-]+$", ErrorMessage = "Use uppercase letters, digits, dash, underscore.")] + public string ClusterId { get; set; } = ""; + [System.ComponentModel.DataAnnotations.Required] + public string Name { get; set; } = ""; + [System.ComponentModel.DataAnnotations.Required] + public string Enterprise { get; set; } = "zb"; + [System.ComponentModel.DataAnnotations.Required] + public string Site { get; set; } = ""; + public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None; + public string? Notes { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor new file mode 100644 index 0000000..4be4b61 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/NodeEdit.razor @@ -0,0 +1,268 @@ +@page "/clusters/{ClusterId}/nodes/new" +@page "/clusters/{ClusterId}/nodes/{NodeId}" +@* ClusterNode CRUD. ApplicationUri is fleet-wide unique — the EF unique index enforces this + at SaveChanges. ServiceLevelBase defaults: 200 primary, 150 secondary. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthState + +
+

@(IsNew ? "New node" : "Edit node") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Node @NodeId was not found in cluster @ClusterId. +
+} +else +{ + + +
+
Identity
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Must be unique fleet-wide. Clients pin trust here — never silently rewrite based on Host.
+
+
+
+ +
+
Redundancy + behaviour
+
+
+
+ + +
200 = primary preference, 150 = secondary preference. Live ServiceLevel adjusts down on faults.
+
+
+ +
+ + +
+
+
+
+ + +
Per-node merge over cluster-level DriverInstance.DriverConfig. Minimal by design — heavy node-specific config is a smell.
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? NodeId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(NodeId); + + private FormModel _form = new(); + private ClusterNode? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (IsNew) + { + _form = new FormModel + { + NodeId = "", + Host = "", + OpcUaPort = 4840, + DashboardPort = 8081, + ApplicationUri = "", + ServiceLevelBase = 200, + Enabled = true, + }; + } + else + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.ClusterNodes.AsNoTracking() + .FirstOrDefaultAsync(n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (_existing is not null) + { + _form = new FormModel + { + NodeId = _existing.NodeId, + Host = _existing.Host, + OpcUaPort = _existing.OpcUaPort, + DashboardPort = _existing.DashboardPort, + ApplicationUri = _existing.ApplicationUri, + ServiceLevelBase = _existing.ServiceLevelBase, + Enabled = _existing.Enabled, + DriverConfigOverridesJson = _existing.DriverConfigOverridesJson, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; + _error = null; + try + { + if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson)) + { + try { using var _ = System.Text.Json.JsonDocument.Parse(_form.DriverConfigOverridesJson); } + catch { _error = "DriverConfigOverridesJson is not valid JSON."; return; } + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId)) + { + _error = $"Node '{_form.NodeId}' already exists."; + return; + } + var auth = await AuthState.GetAuthenticationStateAsync(); + db.ClusterNodes.Add(new ClusterNode + { + NodeId = _form.NodeId, + ClusterId = ClusterId, + Host = _form.Host, + OpcUaPort = _form.OpcUaPort, + DashboardPort = _form.DashboardPort, + ApplicationUri = _form.ApplicationUri, + ServiceLevelBase = _form.ServiceLevelBase, + Enabled = _form.Enabled, + DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson, + CreatedAt = DateTime.UtcNow, + CreatedBy = auth.User.Identity?.Name ?? "(anonymous)", + }); + } + else + { + var entity = await db.ClusterNodes.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (entity is null) { _error = "Row no longer exists."; return; } + entity.Host = _form.Host; + entity.OpcUaPort = _form.OpcUaPort; + entity.DashboardPort = _form.DashboardPort; + entity.ApplicationUri = _form.ApplicationUri; + entity.ServiceLevelBase = _form.ServiceLevelBase; + entity.Enabled = _form.Enabled; + entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; + _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.ClusterNodes.FirstOrDefaultAsync( + n => n.ClusterId == ClusterId && n.NodeId == NodeId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}"); return; } + db.ClusterNodes.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}"); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] + public string NodeId { get; set; } = ""; + [Required] public string Host { get; set; } = ""; + [Range(1, 65535)] public int OpcUaPort { get; set; } = 4840; + [Range(1, 65535)] public int DashboardPort { get; set; } = 8081; + [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")] + public string ApplicationUri { get; set; } = ""; + [Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200; + public bool Enabled { get; set; } = true; + public string? DriverConfigOverridesJson { get; set; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor new file mode 100644 index 0000000..1f069f2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/TagEdit.razor @@ -0,0 +1,320 @@ +@page "/clusters/{ClusterId}/tags/new" +@page "/clusters/{ClusterId}/tags/{TagId}" +@* Tag CRUD. EquipmentId is required when the chosen driver's namespace is Equipment-kind, + forbidden when SystemPlatform-kind (decision #110); the form switches between + "pick equipment" and "FolderPath input" based on namespace kind. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New tag" : "Edit tag") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Tag @TagId was not found. +
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + @foreach (var d in _drivers) + { + + } + +
+
+ + + @foreach (var dt in DataTypes) + { + + } + +
+
+ + @{ var driverNamespace = ResolveDriverNamespace(_form.DriverInstanceId); } + @if (driverNamespace?.Kind == NamespaceKind.Equipment) + { +
+ + + + @foreach (var e in _equipment.Where(e => e.DriverInstanceId == _form.DriverInstanceId)) + { + + } + +
+ } + else if (driverNamespace?.Kind == NamespaceKind.SystemPlatform) + { +
+ + +
Galaxy hierarchy preserved as v1 expressed it — no UNS rule.
+
+ } + else if (!string.IsNullOrEmpty(_form.DriverInstanceId)) + { +
Pick a driver to see its namespace kind.
+ } + +
+
+ + + + + +
+
+ +
+ + +
+
+
+
+ + +
+
+
+ +
+
Tag config (JSON)
+
+ +
Schemaless per driver type — register / address / scaling / byte-order. Validated server-side at deploy.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + private static readonly string[] DataTypes = + ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", + "Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"]; + + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? TagId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(TagId); + + private FormModel _form = new(); + private Tag? _existing; + private List _drivers = new(); + private List _equipment = new(); + private Dictionary _namespacesByDriverInstance = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _drivers = await db.DriverInstances.AsNoTracking() + .Where(d => d.ClusterId == ClusterId) + .OrderBy(d => d.DriverInstanceId) + .ToListAsync(); + var namespaces = await db.Namespaces.AsNoTracking() + .Where(n => n.ClusterId == ClusterId) + .ToListAsync(); + var nsById = namespaces.ToDictionary(n => n.NamespaceId); + _namespacesByDriverInstance = _drivers.ToDictionary( + d => d.DriverInstanceId, + d => nsById.TryGetValue(d.NamespaceId, out var ns) ? ns : namespaces.First()); + var driverIds = _drivers.Select(d => d.DriverInstanceId).ToHashSet(); + _equipment = await db.Equipment.AsNoTracking() + .Where(e => driverIds.Contains(e.DriverInstanceId)) + .OrderBy(e => e.MachineCode) + .ToListAsync(); + + if (!IsNew) + { + _existing = await db.Tags.AsNoTracking() + .FirstOrDefaultAsync(t => t.TagId == TagId); + if (_existing is not null) + { + _form = new FormModel + { + TagId = _existing.TagId, + Name = _existing.Name, + DriverInstanceId = _existing.DriverInstanceId, + EquipmentId = _existing.EquipmentId, + FolderPath = _existing.FolderPath, + DataType = _existing.DataType, + AccessLevel = _existing.AccessLevel, + WriteIdempotent = _existing.WriteIdempotent, + PollGroupId = _existing.PollGroupId, + TagConfig = _existing.TagConfig, + RowVersion = _existing.RowVersion, + }; + } + } + else + { + _form.DataType = "Float"; + _form.AccessLevel = TagAccessLevel.Read; + _form.TagConfig = "{}"; + } + _loaded = true; + } + + private Namespace? ResolveDriverNamespace(string driverId) => + string.IsNullOrEmpty(driverId) ? null + : _namespacesByDriverInstance.TryGetValue(driverId, out var ns) ? ns : null; + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + if (string.IsNullOrEmpty(_form.DriverInstanceId)) { _error = "Pick a driver."; return; } + var ns = ResolveDriverNamespace(_form.DriverInstanceId); + if (ns?.Kind == NamespaceKind.Equipment && string.IsNullOrEmpty(_form.EquipmentId)) + { _error = "Driver lives in an Equipment-kind namespace — pick an equipment."; return; } + if (ns?.Kind == NamespaceKind.SystemPlatform && !string.IsNullOrEmpty(_form.EquipmentId)) + { _error = "Driver lives in a SystemPlatform namespace — EquipmentId must be empty (use FolderPath)."; return; } + + try { using var _ = System.Text.Json.JsonDocument.Parse(_form.TagConfig); } + catch { _error = "TagConfig is not valid JSON."; return; } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.Tags.AnyAsync(t => t.TagId == _form.TagId)) + { _error = $"Tag '{_form.TagId}' already exists."; return; } + db.Tags.Add(new Tag + { + TagId = _form.TagId, + DriverInstanceId = _form.DriverInstanceId, + EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId, + Name = _form.Name, + FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath, + DataType = _form.DataType, + AccessLevel = _form.AccessLevel, + WriteIdempotent = _form.WriteIdempotent, + PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId, + TagConfig = _form.TagConfig, + }); + } + else + { + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.DriverInstanceId = _form.DriverInstanceId; + entity.EquipmentId = string.IsNullOrEmpty(_form.EquipmentId) ? null : _form.EquipmentId; + entity.Name = _form.Name; + entity.FolderPath = string.IsNullOrWhiteSpace(_form.FolderPath) ? null : _form.FolderPath; + entity.DataType = _form.DataType; + entity.AccessLevel = _form.AccessLevel; + entity.WriteIdempotent = _form.WriteIdempotent; + entity.PollGroupId = string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId; + entity.TagConfig = _form.TagConfig; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/tags"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == TagId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/tags"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Tags.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/tags"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this tag while you were viewing it."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string DriverInstanceId { get; set; } = ""; + public string? EquipmentId { get; set; } + public string? FolderPath { get; set; } + [Required] public string DataType { get; set; } = "Float"; + public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read; + public bool WriteIdempotent { get; set; } + public string? PollGroupId { get; set; } + [Required] public string TagConfig { get; set; } = "{}"; + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor new file mode 100644 index 0000000..632a698 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsAreaEdit.razor @@ -0,0 +1,167 @@ +@page "/clusters/{ClusterId}/uns/areas/new" +@page "/clusters/{ClusterId}/uns/areas/{UnsAreaId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New UNS area" : "Edit UNS area") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Area @UnsAreaId was not found. +
+} +else +{ + + +
+
UNS area (level 3)
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? UnsAreaId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(UnsAreaId); + + private FormModel _form = new(); + private UnsArea? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (!IsNew) + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.UnsAreas.AsNoTracking() + .FirstOrDefaultAsync(a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + if (_existing is not null) + { + _form = new FormModel + { + UnsAreaId = _existing.UnsAreaId, + Name = _existing.Name, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == _form.UnsAreaId)) + { _error = $"Area '{_form.UnsAreaId}' already exists."; return; } + db.UnsAreas.Add(new UnsArea + { + UnsAreaId = _form.UnsAreaId, + ClusterId = ClusterId, + Name = _form.Name, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.UnsAreas.FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.Name = _form.Name; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were editing. Reload to see the latest values."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.UnsAreas.FirstOrDefaultAsync( + a => a.ClusterId == ClusterId && a.UnsAreaId == UnsAreaId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.UnsAreas.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this area while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor new file mode 100644 index 0000000..729ae6d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/UnsLineEdit.razor @@ -0,0 +1,187 @@ +@page "/clusters/{ClusterId}/uns/lines/new" +@page "/clusters/{ClusterId}/uns/lines/{UnsLineId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New UNS line" : "Edit UNS line") · @ClusterId

+ Cancel +
+ + + +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
+ Line @UnsLineId was not found. +
+} +else +{ + + +
+
UNS line (level 4)
+
+
+ + +
+
+ + + @foreach (var area in _areas) + { + + } + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
@_error
+ } + +
+ + Cancel + @if (!IsNew) + { + + } +
+
+} + +@code { + [Parameter] public string ClusterId { get; set; } = ""; + [Parameter] public string? UnsLineId { get; set; } + + private bool IsNew => string.IsNullOrEmpty(UnsLineId); + + private FormModel _form = new(); + private UnsLine? _existing; + private List _areas = new(); + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _areas = await db.UnsAreas.AsNoTracking() + .Where(a => a.ClusterId == ClusterId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(); + + if (!IsNew) + { + _existing = await db.UnsLines.AsNoTracking() + .FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (_existing is not null) + { + _form = new FormModel + { + UnsLineId = _existing.UnsLineId, + UnsAreaId = _existing.UnsAreaId, + Name = _existing.Name, + Notes = _existing.Notes, + RowVersion = _existing.RowVersion, + }; + } + } + else + { + _form.UnsAreaId = _areas.FirstOrDefault()?.UnsAreaId ?? ""; + } + _loaded = true; + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.UnsLines.AnyAsync(l => l.UnsLineId == _form.UnsLineId)) + { _error = $"Line '{_form.UnsLineId}' already exists."; return; } + db.UnsLines.Add(new UnsLine + { + UnsLineId = _form.UnsLineId, + UnsAreaId = _form.UnsAreaId, + Name = _form.Name, + Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes, + }); + } + else + { + var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.UnsAreaId = _form.UnsAreaId; + entity.Name = _form.Name; + entity.Notes = string.IsNullOrWhiteSpace(_form.Notes) ? null : _form.Notes; + } + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == UnsLineId); + if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/uns"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.UnsLines.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo($"/clusters/{ClusterId}/uns"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this line while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."; } + finally { _busy = false; } + } + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string UnsLineId { get; set; } = ""; + [Required] public string UnsAreaId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + public string? Notes { get; set; } + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor new file mode 100644 index 0000000..be5ddca --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor @@ -0,0 +1,136 @@ +@page "/deployments" +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces +@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations + +@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")] + +@inject IDbContextFactory DbFactory +@inject IAdminOperationsClient AdminOps +@inject AuthenticationStateProvider AuthState +@rendermode InteractiveServer + +Deployments + +

Deployments

+ +
+ + + @if (_drift is not null) + { + + @(_drift.Value ? "Configuration drift" : "In sync") + + } +
+ +@if (_lastMessage is not null) +{ +
+ @_lastMessage +
+} + + + + + + + + + + + + + + @foreach (var d in _deployments) + { + + + + + + + + + } + +
DeploymentRevisionStatusCreated byCreated (UTC)Sealed (UTC)
@Short(d.DeploymentId)@d.RevisionHash[..12]…@d.Status@d.CreatedBy@d.CreatedAtUtc.ToString("u")@(d.SealedAtUtc?.ToString("u") ?? "—")
+ +@code { + private IReadOnlyList _deployments = Array.Empty(); + private bool _busy; + private bool _lastSuccess; + private string? _lastMessage; + private bool? _drift; + + protected override async Task OnInitializedAsync() + { + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _deployments = await db.Deployments + .AsNoTracking() + .OrderByDescending(d => d.CreatedAtUtc) + .Take(50) + .ToListAsync(); + + // Drift: if no sealed deployment yet, no drift to report. Otherwise compare the latest + // sealed revision hash to a fresh snapshot of the live-edit state. + var latestSealed = _deployments.FirstOrDefault(d => d.Status == DeploymentStatus.Sealed); + if (latestSealed is null) + { + _drift = null; + return; + } + var current = await ConfigComposer.SnapshotAndFlattenAsync(db); + _drift = !string.Equals(current.RevisionHash, latestSealed.RevisionHash, StringComparison.Ordinal); + } + + private async Task StartDeploymentAsync() + { + _busy = true; + _lastMessage = null; + try + { + var auth = await AuthState.GetAuthenticationStateAsync(); + var createdBy = auth.User.Identity?.Name ?? "(anonymous)"; + var result = await AdminOps.StartDeploymentAsync( + createdBy: createdBy, + ct: CancellationToken.None); + + _lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted; + _lastMessage = result.Outcome switch + { + StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).", + StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.", + StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.", + _ => result.Message ?? "Deployment rejected.", + }; + await ReloadAsync(); + } + catch (Exception ex) + { + _lastSuccess = false; + _lastMessage = $"Deploy failed: {ex.Message}"; + } + finally + { + _busy = false; + } + } + + private static string Short(Guid id) => id.ToString("N")[..8]; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor new file mode 100644 index 0000000..4f669ee --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Fleet.razor @@ -0,0 +1,180 @@ +@page "/fleet" +@* Per-node deployment status. v2 reads NodeDeploymentState (the per-(node, deployment) apply + progress row owned by each DriverHostActor) and projects the most-recent row per node. The + Akka cluster topology comes from IClusterRoleInfo so we can show nodes that haven't applied + anything yet alongside nodes that have. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Configuration.Enums +@inject IClusterRoleInfo Cluster +@inject IDbContextFactory DbFactory +@implements IDisposable + +
+

Fleet status

+
+ +
+ + + Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") + +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No driver-role nodes are currently Up in the Akka cluster, and no NodeDeploymentState + rows have been recorded yet. Either no driver nodes have joined or the cluster is still + forming. +
+} +else +{ +
+
+
Nodes
+
@_rows.Count
+
+
+
Applied
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applied)
+
+
+
Applying
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Applying)
+
+
+
Failed
+
@_rows.Count(r => r.Status == NodeDeploymentStatus.Failed)
+
+
+ +
+
Nodes
+
+ + + + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + + + } + +
NodeRolesStatusApplied atStarted atFailure reason
@r.NodeId + @foreach (var role in r.Roles) + { + @role + } + @StatusLabel(r.Status)@(r.AppliedAtUtc?.ToString("u") ?? "—")@(r.StartedAtUtc?.ToString("u") ?? "—")@(r.FailureReason ?? "")
+
+
+} + +@code { + private const int RefreshIntervalSeconds = 10; + + private List? _rows; + private bool _refreshing; + private DateTime? _lastRefreshUtc; + private Timer? _timer; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + _timer = new Timer(_ => _ = InvokeAsync(LoadAsync), null, + TimeSpan.FromSeconds(RefreshIntervalSeconds), + TimeSpan.FromSeconds(RefreshIntervalSeconds)); + } + + private async Task RefreshAsync() => await LoadAsync(); + + private async Task LoadAsync() + { + _refreshing = true; + StateHasChanged(); + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + // Project the most-recent NodeDeploymentState per node — that's the row the + // DriverHostActor most recently touched, regardless of which deployment it was for. + var states = await db.NodeDeploymentStates.AsNoTracking() + .GroupBy(s => s.NodeId) + .Select(g => g.OrderByDescending(s => s.StartedAtUtc).First()) + .ToListAsync(); + var byNode = states.ToDictionary(s => s.NodeId); + + // Union with current Akka driver members so a freshly-joined node that has no + // NodeDeploymentState row yet still appears as "waiting". + var akkaDrivers = Cluster.MembersWithRole("driver") + .Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase); + var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); + + _rows = allNodes.Select(nodeId => + { + byNode.TryGetValue(nodeId, out var state); + return new NodeRow( + NodeId: nodeId, + Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty(), + Status: state?.Status, + StartedAtUtc: state?.StartedAtUtc, + AppliedAtUtc: state?.AppliedAtUtc, + FailureReason: state?.FailureReason); + }).ToList(); + _lastRefreshUtc = DateTime.UtcNow; + } + finally + { + _refreshing = false; + StateHasChanged(); + } + } + + private static string StatusChipClass(NodeDeploymentStatus? status) => status switch + { + NodeDeploymentStatus.Applied => "chip-ok", + NodeDeploymentStatus.Applying => "chip-caution", + NodeDeploymentStatus.Failed => "chip-alert", + _ => "chip-idle", + }; + + private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting"; + + public void Dispose() => _timer?.Dispose(); + + private sealed record NodeRow( + string NodeId, + IReadOnlyCollection Roles, + NodeDeploymentStatus? Status, + DateTime? StartedAtUtc, + DateTime? AppliedAtUtc, + string? FailureReason); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor new file mode 100644 index 0000000..48d9f00 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Home.razor @@ -0,0 +1,7 @@ +@page "/" + +OtOpcUa + +

OtOpcUa Admin

+

v2 fused host. Use the nav above to manage deployments.

+

Most v1 admin pages were removed by the live-edit migration — see follow-up F15 for the per-page restoration plan.

diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor new file mode 100644 index 0000000..7f6ff26 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -0,0 +1,196 @@ +@page "/hosts" +@* Akka cluster topology: each member's NodeId (host:port), roles, leader status. v2 reshapes + v1's "driver host" page — there are no per-driver host rows yet (driver-instance child actors + land with F7). For now this is the cluster-membership view; expand to per-driver rows when + DriverHostActor starts spawning DriverInstanceActor children. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Akka.Actor +@using Akka.Cluster +@inject ActorSystem ActorSystem +@implements IDisposable + +
+

Cluster hosts

+
+ +
+ + + Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—") + +
+ +
+ Each row is one Akka cluster member identified by host:port. Roles + drive which actors run on which node — admin nodes host the + control-plane singletons, driver nodes host the per-node runtime + actors. The leader columns identify which member currently owns each role's singletons. +
+ +@if (_rows is null) +{ +

Loading…

+} +else if (_rows.Count == 0) +{ +
+ No cluster members visible. The local node may still be joining. +
+} +else +{ +
+
+
Members
+
@_rows.Count
+
+
+
Up
+
@_rows.Count(r => r.Status == "Up")
+
+
+
Joining/Leaving
+
@_rows.Count(r => r.Status is "Joining" or "Leaving" or "Exiting")
+
+
+
Unreachable
+
@_rows.Count(r => r.Unreachable)
+
+
+ +
+
Members
+
+ + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + } + +
AddressStatusRolesLeader for
+ @r.Address + @if (r.IsSelf) { self } + + + @(r.Unreachable ? $"{r.Status} (unreachable)" : r.Status) + + + @foreach (var role in r.Roles) + { + @role + } + + @if (r.LeaderRoles.Count == 0) + { + + } + else + { + @foreach (var role in r.LeaderRoles) + { + @role + } + } +
+
+
+} + +@code { + private const int RefreshIntervalSeconds = 5; + + private List? _rows; + private bool _refreshing; + private DateTime? _lastRefreshUtc; + private Timer? _timer; + + protected override void OnInitialized() + { + Refresh(); + _timer = new Timer(_ => InvokeAsync(() => { Refresh(); StateHasChanged(); }), null, + TimeSpan.FromSeconds(RefreshIntervalSeconds), + TimeSpan.FromSeconds(RefreshIntervalSeconds)); + } + + private async Task RefreshAsync() + { + _refreshing = true; + StateHasChanged(); + try + { + await Task.Yield(); + Refresh(); + } + finally + { + _refreshing = false; + StateHasChanged(); + } + } + + private void Refresh() + { + var cluster = Akka.Cluster.Cluster.Get(ActorSystem); + var state = cluster.State; + var unreachable = state.Unreachable + .Select(m => m.Address.ToString()).ToHashSet(); + var selfAddress = cluster.SelfAddress.ToString(); + + _rows = state.Members.Select(m => + { + var address = m.Address.ToString(); + var hostPort = $"{m.Address.Host ?? "?"}:{m.Address.Port ?? 0}"; + var leaderRoles = m.Roles + .Where(role => cluster.State.RoleLeader(role)?.ToString() == address) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToList(); + return new MemberRow( + Address: hostPort, + Status: m.Status.ToString(), + Roles: m.Roles.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(), + LeaderRoles: leaderRoles, + Unreachable: unreachable.Contains(address), + IsSelf: address == selfAddress); + }) + .OrderBy(r => r.Address, StringComparer.OrdinalIgnoreCase) + .ToList(); + _lastRefreshUtc = DateTime.UtcNow; + } + + private static string StatusChipClass(string status, bool unreachable) => (status, unreachable) switch + { + (_, true) => "chip-alert", + ("Up", _) => "chip-ok", + ("Joining", _) or ("Leaving", _) or ("Exiting", _) or ("WeaklyUp", _) => "chip-caution", + ("Down", _) or ("Removed", _) => "chip-alert", + _ => "chip-idle", + }; + + public void Dispose() => _timer?.Dispose(); + + private sealed record MemberRow( + string Address, + string Status, + IReadOnlyCollection Roles, + IReadOnlyCollection LeaderRoles, + bool Unreachable, + bool IsSelf); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor similarity index 69% rename from src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor rename to src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor index b0e6b30..af87ffc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor @@ -1,14 +1,10 @@ @page "/login" -@* The login page must stay anonymously reachable — otherwise the fallback - authorization policy (Admin-001) would lock operators out of the only way in. *@ +@* Login MUST stay anonymously reachable — otherwise the fallback authorization policy + would lock operators out of the only way in (Admin-001). Static-rendered on purpose: + the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response. + Calling SignInAsync from an interactive circuit would be too late. *@ @attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous] -@* Admin-005: this page is static server-rendered (no @rendermode). It is a plain HTML - form that POSTs to the /auth/login minimal-API endpoint with data-enhance="false", so - the LDAP bind, cookie SignInAsync and redirect all run while the endpoint still owns - an unstarted HTTP response. SignInAsync must NOT be called from an interactive Blazor - circuit — by then the original HTTP response has long completed. *@ -
@code { - /// Error message surfaced by the /auth/login endpoint after a failed bind. + /// Error message surfaced by /auth/login after a failed bind. [SupplyParameterFromQuery] private string? Error { get; set; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor new file mode 100644 index 0000000..86295cc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Reservations.razor @@ -0,0 +1,70 @@ +@page "/reservations" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.EntityFrameworkCore +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory + +
+

External ID reservations

+
+ +@if (_rows is null) +{ +

Loading…

+} +else +{ +
+ External IDs (ZTag, SAPID) are reserved fleet-wide via this table. Reservations bind a + value to an Equipment's UUID so the ID can move with the equipment across cluster + reshuffles without colliding with another cluster's equipment. +
+ +
+
@_rows.Count reservation@(_rows.Count == 1 ? "" : "s")
+ @if (_rows.Count == 0) + { +
No reservations yet.
+ } + else + { +
+ + + + + + + + + + + @foreach (var r in _rows) + { + + + + + + + } + +
KindValueEquipment UUIDCluster
@r.Kind@r.Value@r.EquipmentUuid@r.ClusterId
+
+ } +
+} + +@code { + private List? _rows; + + protected override async Task OnInitializedAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _rows = await db.ExternalIdReservations.AsNoTracking() + .OrderBy(r => r.Kind).ThenBy(r => r.Value) + .ToListAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor new file mode 100644 index 0000000..caed4c7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -0,0 +1,81 @@ +@page "/role-grants" +@* Per Q4 of the AdminUI rebuild plan, v2 replaced v1's per-cluster RoleGrants table with a + fleet-wide LDAP-group → role map. This page surfaces the mapping read-only; the source of + truth is Authentication:Ldap:GroupToRole in appsettings (editable on the host filesystem, not + from the UI yet). *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.Extensions.Options +@using ZB.MOM.WW.OtOpcUa.Security.Ldap +@inject IOptionsSnapshot Ldap + +
+

Role grants

+
+ +
+ LDAP group membership determines fleet roles. Edit the mapping in + appsettings.json under Authentication:Ldap:GroupToRole + and restart the admin node (or sign out + back in for cached claims to refresh). UI-driven + editing of the mapping is deferred — it implies a config-reload mechanism that doesn't exist + yet. +
+ +@if (_options is null) +{ +

Loading…

+} +else +{ +
+
+
LDAP binding
+
Enabled@(_options.Enabled ? "yes" : "no")
+
Server@_options.Server:@_options.Port
+
UseTls@_options.UseTls
+
SearchBase@_options.SearchBase
+ @if (!_options.UseTls && _options.AllowInsecureLdap) + { +
WarningPlaintext credentials over LDAP — dev mode only
+ } +
+
+ +
+
Group → role mapping (@(_options.GroupToRole?.Count ?? 0))
+ @if (_options.GroupToRole is null || _options.GroupToRole.Count == 0) + { +
+ No mapping configured. Every authenticated user lands with zero roles — + the fallback authorization policy will refuse every request. Add a + GroupToRole entry before deploying. +
+ } + else + { +
+ + + + @foreach (var kvp in _options.GroupToRole.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + { + + + + + } + +
LDAP groupResolved role
@kvp.Key@kvp.Value
+
+ } +
+} + +@code { + private LdapOptions? _options; + + protected override void OnInitialized() + { + _options = Ldap.Value; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor new file mode 100644 index 0000000..f7dd8ab --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor @@ -0,0 +1,199 @@ +@page "/scripts/new" +@page "/scripts/{ScriptId}" +@* Script CRUD. SourceHash is computed automatically from SourceCode on save so the + integrity check in v2's deployment pipeline doesn't require operator action. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using System.Security.Cryptography +@using System.Text +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav +@inject IJSRuntime JS + +
+

@(IsNew ? "New script" : "Edit script")

+ Cancel +
+ +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
@ScriptId not found.
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ +
+
Source
+
+ @* The textarea stays in the DOM and remains Blazor's source of truth. Monaco + mounts a
beside it (textarea hides), and the loader's onDidChangeModelContent + handler mirrors edits back into the textarea + fires the input event so @bind + picks them up. Falls back to the textarea gracefully if Monaco's CDN is + unreachable (air-gapped deployments — see monaco-loader.js). *@ + +
SHA-256 hash is computed automatically on save. Monaco editor attaches over the textarea on render.
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + [Parameter] public string? ScriptId { get; set; } + private bool IsNew => string.IsNullOrEmpty(ScriptId); + + private FormModel _form = new(); + private Script? _existing; + private bool _loaded; + private bool _busy; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (!IsNew) + { + await using var db = await DbFactory.CreateDbContextAsync(); + _existing = await db.Scripts.AsNoTracking().FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (_existing is not null) + { + _form = new FormModel + { + ScriptId = _existing.ScriptId, + Name = _existing.Name, + Language = _existing.Language, + SourceCode = _existing.SourceCode, + RowVersion = _existing.RowVersion, + }; + } + } + _loaded = true; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || !_loaded) return; + // Inject loader once, then attach over the textarea. Failures are silent — the page + // is fully usable via the underlying textarea if Monaco's CDN is unreachable. + try + { + await JS.InvokeVoidAsync("eval", "if (!document.querySelector('script[data-otopcua=monaco-loader]')) { var s=document.createElement('script'); s.src='/_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-loader.js'; s.dataset.otopcua='monaco-loader'; document.head.appendChild(s); }"); + // Wait a tick for the loader IIFE to register window.otOpcUaScriptEditor, then attach. + await Task.Delay(50); + await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", "script-source"); + } + catch + { + // Textarea remains the editor — no-op. + } + } + + private async Task SubmitAsync() + { + _busy = true; _error = null; + try + { + var sourceHash = HashSource(_form.SourceCode); + await using var db = await DbFactory.CreateDbContextAsync(); + if (IsNew) + { + if (await db.Scripts.AnyAsync(s => s.ScriptId == _form.ScriptId)) + { _error = $"Script '{_form.ScriptId}' already exists."; return; } + db.Scripts.Add(new Script + { + ScriptId = _form.ScriptId, + Name = _form.Name, + Language = _form.Language, + SourceCode = _form.SourceCode, + SourceHash = sourceHash, + }); + } + else + { + var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (entity is null) { _error = "Row no longer exists."; return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + entity.Name = _form.Name; + entity.Language = _form.Language; + entity.SourceCode = _form.SourceCode; + entity.SourceHash = sourceHash; + } + await db.SaveChangesAsync(); + Nav.NavigateTo("/scripts"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were editing."; } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync() + { + if (IsNew) return; + _busy = true; _error = null; + try + { + await using var db = await DbFactory.CreateDbContextAsync(); + var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == ScriptId); + if (entity is null) { Nav.NavigateTo("/scripts"); return; } + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion; + db.Scripts.Remove(entity); + await db.SaveChangesAsync(); + Nav.NavigateTo("/scripts"); + } + catch (DbUpdateConcurrencyException) { _error = "Another user changed this script while you were viewing it."; } + catch (Exception ex) { _error = $"Delete failed: {ex.Message}. Likely because virtual tags or scripted alarms still reference this script — remove them first."; } + finally { _busy = false; } + } + + private static string HashSource(string source) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source))); + + private sealed class FormModel + { + [Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string ScriptId { get; set; } = ""; + [Required] public string Name { get; set; } = ""; + [Required] public string Language { get; set; } = "CSharp"; + [Required] public string SourceCode { get; set; } = ""; + public byte[] RowVersion { get; set; } = []; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor new file mode 100644 index 0000000..fe28f7d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptLog.razor @@ -0,0 +1,163 @@ +@page "/script-log" +@* Live script-log tail via SignalR. Subscribes to /hubs/script-log and shows entries from + VirtualTagActor / ScriptedAlarmActor script execution. Engine emit lands with F8 + F9. *@ +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.SignalR.Client +@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs +@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging +@inject NavigationManager Nav +@implements IAsyncDisposable + +
+

Script log

+
+ + + + @(_connected ? "live" : "disconnected") + + +
+
+ +
+ Live tail of script-logs DPS topic, capped at @Capacity entries. + Filter by minimum level + script ID. Sources: VirtualTagActor (F8), ScriptedAlarmActor (F9). +
+ +@if (VisibleRows.Count == 0) +{ +
+ @if (_rows.Count == 0) + { + No script-log entries yet. Engine emit (F8/F9) is pending. + } + else + { + No entries match the current filter (@_rows.Count entries available). + } +
+} +else +{ +
+
Showing @VisibleRows.Count of @_rows.Count
+
+ + + + + + + + + + + + @foreach (var e in VisibleRows) + { + + + + + + + + } + +
TimeLevelScriptContextMessage
@e.TimestampUtc.ToString("HH:mm:ss.fff")@e.Level@e.ScriptId + @if (!string.IsNullOrEmpty(e.VirtualTagId)) { vtag=@e.VirtualTagId } + @if (!string.IsNullOrEmpty(e.AlarmId)) { alarm=@e.AlarmId } + @if (!string.IsNullOrEmpty(e.EquipmentId)) { eq=@e.EquipmentId } + @e.Message
+
+
+} + +@code { + private const int Capacity = 500; + + private readonly List _rows = new(); + private HubConnection? _hub; + private bool _connected; + private string _levelFilter = ""; + private string _scriptFilter = ""; + + private static readonly Dictionary LevelRank = new(StringComparer.OrdinalIgnoreCase) + { + ["Trace"] = 0, ["Debug"] = 1, ["Information"] = 2, ["Warning"] = 3, ["Error"] = 4, ["Critical"] = 5, + }; + + private List VisibleRows + { + get + { + IEnumerable q = _rows; + if (!string.IsNullOrWhiteSpace(_levelFilter) + && LevelRank.TryGetValue(_levelFilter, out var minRank)) + { + q = q.Where(e => LevelRank.TryGetValue(e.Level, out var r) && r >= minRank); + } + if (!string.IsNullOrWhiteSpace(_scriptFilter)) + { + q = q.Where(e => e.ScriptId.Contains(_scriptFilter, StringComparison.OrdinalIgnoreCase)); + } + return q.ToList(); + } + } + + protected override async Task OnInitializedAsync() + { + _hub = new HubConnectionBuilder() + .WithUrl(Nav.ToAbsoluteUri(ScriptLogHub.Endpoint)) + .WithAutomaticReconnect() + .Build(); + + _hub.On(ScriptLogHub.MethodName, entry => + { + _rows.Insert(0, entry); + if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1); + InvokeAsync(StateHasChanged); + }); + _hub.Closed += _ => { _connected = false; return InvokeAsync(StateHasChanged); }; + _hub.Reconnected += _ => { _connected = true; return InvokeAsync(StateHasChanged); }; + + try + { + await _hub.StartAsync(); + _connected = true; + } + catch + { + // Connection error — page shows "disconnected". + } + } + + private async Task ClearAsync() + { + _rows.Clear(); + await InvokeAsync(StateHasChanged); + } + + private static string LevelChipClass(string level) => level switch + { + "Critical" or "Error" => "chip-alert", + "Warning" => "chip-caution", + "Information" => "chip-idle", + _ => "chip-idle", + }; + + public async ValueTask DisposeAsync() + { + if (_hub is not null) await _hub.DisposeAsync(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor new file mode 100644 index 0000000..17aeb62 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarmEdit.razor @@ -0,0 +1,236 @@ +@page "/scripted-alarms/new" +@page "/scripted-alarms/{ScriptedAlarmId}" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@rendermode RenderMode.InteractiveServer +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.EntityFrameworkCore +@using System.ComponentModel.DataAnnotations +@using ZB.MOM.WW.OtOpcUa.Configuration +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject IDbContextFactory DbFactory +@inject NavigationManager Nav + +
+

@(IsNew ? "New scripted alarm" : "Edit scripted alarm")

+ Cancel +
+ +@if (!_loaded) +{ +

Loading…

+} +else if (!IsNew && _existing is null) +{ +
@ScriptedAlarmId not found.
+} +else +{ + + +
+
Identity
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + @foreach (var e in _equipment) { } + +
+
+ + + + + + + +
+
+ + +
+
+
+
+ + + + @foreach (var s in _scripts) { } + +
+
+
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {
@_error
} + +
+ + Cancel + @if (!IsNew) { } +
+
+} + +@code { + [Parameter] public string? ScriptedAlarmId { get; set; } + private bool IsNew => string.IsNullOrEmpty(ScriptedAlarmId); + + private FormModel _form = new(); + private ScriptedAlarm? _existing; + private List _equipment = new(); + private List