Compare commits
7 Commits
v2-gap-clo
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfbca6469 | ||
|
|
44b8a9c7ff | ||
|
|
60beb9128e | ||
|
|
6884de9774 | ||
|
|
c064ec16cf | ||
|
|
ed1c17bc7b | ||
|
|
1e64488c0d |
@@ -9,8 +9,9 @@ Mac-friendly multi-cluster OtOpcUa fleet for manual UI exercise + integration sm
|
|||||||
| Service | Role | Ports |
|
| Service | Role | Ports |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | host `14330` → container `1433` |
|
| `sql` | SQL Server 2022 — single `OtOpcUa` ConfigDb shared by all three clusters | host `14330` → container `1433` |
|
||||||
| `ldap` | OpenLDAP with dev users `alice` / `bob` | host `3893` → container `1389` |
|
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8089` |
|
||||||
| `traefik` | Routes :80 by Host header / PathPrefix | host `80`, dashboard `8080` |
|
|
||||||
|
Authentication runs in `DevStubMode` — every host container has `Authentication__Ldap__DevStubMode=true` set, so the LDAP service is not part of the dev compose right now (the `bitnami/openldap:2.6` image was retired and the legacy tag crashes mid-setup with exit 68). Any non-empty username/password signs in as `FleetAdmin`. To restore a real LDAP service, drop the env var and add an `openldap`-compatible image back to compose.
|
||||||
|
|
||||||
### Main cluster — split admin/driver roles
|
### Main cluster — split admin/driver roles
|
||||||
|
|
||||||
@@ -59,6 +60,12 @@ The SQL lives at `seed/seed-clusters.sql`; the wait-and-apply wrapper lives at `
|
|||||||
docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed
|
docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Galaxy / MxAccess gateway
|
||||||
|
|
||||||
|
The seed also pre-creates a `SystemPlatform` Namespace + a `GalaxyMxGateway` DriverInstance in the MAIN cluster pointing at `http://10.100.0.48:5120`. The API key is resolved from the `GALAXY_MXGW_API_KEY` env var set on every driver-role container in compose; override via `GALAXY_MXGW_API_KEY=... docker compose up -d` to swap keys without editing the compose file.
|
||||||
|
|
||||||
|
The DriverHost actor doesn't spawn drivers from raw DriverInstance rows on its own — the v2 deploy lifecycle requires a *sealed Deployment* before drivers materialise. After first bring-up, sign in to the Admin UI and click **Deploy current configuration** on `/deployments` to compose the seeded rows into an artifact and dispatch it. The Galaxy driver instance will start its gRPC connection to the gateway on the next deploy ack.
|
||||||
|
|
||||||
## Bring up
|
## Bring up
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -70,7 +77,7 @@ docker compose -f docker-dev/docker-compose.yml up -d --build
|
|||||||
open http://localhost # main cluster admin UI
|
open http://localhost # main cluster admin UI
|
||||||
open http://site-a.localhost # site A admin UI
|
open http://site-a.localhost # site A admin UI
|
||||||
open http://site-b.localhost # site B admin UI
|
open http://site-b.localhost # site B admin UI
|
||||||
open http://localhost:8080 # Traefik dashboard
|
open http://localhost:8089 # Traefik dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS, `*.localhost` resolves to `127.0.0.1` automatically. On Linux add `127.0.0.1 site-a.localhost site-b.localhost` to `/etc/hosts` if your resolver doesn't.
|
On macOS, `*.localhost` resolves to `127.0.0.1` automatically. On Linux add `127.0.0.1 site-a.localhost site-b.localhost` to `/etc/hosts` if your resolver doesn't.
|
||||||
@@ -79,14 +86,7 @@ The first build takes a few minutes (.NET SDK image + restore + publish). Subseq
|
|||||||
|
|
||||||
## Auth (dev only)
|
## Auth (dev only)
|
||||||
|
|
||||||
Use one of the LDAP dev users from `LDAP_USERS` in `docker-compose.yml`:
|
`Authentication__Ldap__DevStubMode=true` is set on every host container, so any non-empty username/password signs in as a `FleetAdmin` user without contacting an LDAP server. **Do not** ship this configuration to production — set `DevStubMode=false` and wire a real LDAP backend before any non-dev deployment.
|
||||||
|
|
||||||
| Username | Password |
|
|
||||||
|---|---|
|
|
||||||
| `alice` | `alice123` |
|
|
||||||
| `bob` | `bob123` |
|
|
||||||
|
|
||||||
The compose mounts everyone into `ou=FleetAdmin` so the dev role mapping resolves to `FleetAdmin`.
|
|
||||||
|
|
||||||
## Tear down
|
## Tear down
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ The `-v` drops the SQL + LDAP volumes; remove it to keep ConfigDb state across r
|
|||||||
|
|
||||||
## Failover smoke
|
## 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.
|
1. Watch the Traefik dashboard at `http://localhost:8089`. 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
# open http://localhost # main cluster Blazor admin UI
|
# open http://localhost # main cluster Blazor admin UI
|
||||||
# open http://site-a.localhost # site A admin UI
|
# open http://site-a.localhost # site A admin UI
|
||||||
# open http://site-b.localhost # site B admin UI
|
# open http://site-b.localhost # site B admin UI
|
||||||
# open http://localhost:8080 # Traefik dashboard
|
# open http://localhost:8089 # Traefik dashboard (8080 is the sister scadalink stack)
|
||||||
#
|
#
|
||||||
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
|
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
|
||||||
|
|
||||||
@@ -71,17 +71,12 @@ services:
|
|||||||
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
|
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
ldap:
|
# OpenLDAP was previously here but the bitnami/openldap:2.6 image was retired
|
||||||
image: bitnami/openldap:2.6
|
# (manifest gone) and bitnamilegacy/openldap:2.6 crashes during LDIF setup with
|
||||||
environment:
|
# exit 68. For the dev compose every host container now runs with
|
||||||
LDAP_ROOT: "dc=lmxopcua,dc=local"
|
# Authentication__Ldap__DevStubMode=true, so any non-empty username/password
|
||||||
LDAP_ADMIN_USERNAME: "admin"
|
# signs in as `FleetAdmin`. Restore a real LDAP service when there's a need
|
||||||
LDAP_ADMIN_PASSWORD: "ldapadmin"
|
# for end-to-end LDAP coverage (the host code path is unchanged).
|
||||||
LDAP_USERS: "alice,bob"
|
|
||||||
LDAP_PASSWORDS: "alice123,bob123"
|
|
||||||
LDAP_USER_DC: "ou=FleetAdmin"
|
|
||||||
ports:
|
|
||||||
- "3893:1389"
|
|
||||||
|
|
||||||
admin-a: &otopcua-host
|
admin-a: &otopcua-host
|
||||||
build:
|
build:
|
||||||
@@ -102,9 +97,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
|
|
||||||
admin-b:
|
admin-b:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -120,9 +114,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
|
|
||||||
driver-a:
|
driver-a:
|
||||||
<<: *otopcua-host
|
<<: *otopcua-host
|
||||||
@@ -134,6 +127,9 @@ services:
|
|||||||
Cluster__PublicHostname: "driver-a"
|
Cluster__PublicHostname: "driver-a"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
|
||||||
|
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
|
||||||
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
ports:
|
ports:
|
||||||
- "4840:4840"
|
- "4840:4840"
|
||||||
|
|
||||||
@@ -147,6 +143,7 @@ services:
|
|||||||
Cluster__PublicHostname: "driver-b"
|
Cluster__PublicHostname: "driver-b"
|
||||||
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
Cluster__SeedNodes__0: "akka.tcp://otopcua@admin-a:4053"
|
||||||
Cluster__Roles__0: "driver"
|
Cluster__Roles__0: "driver"
|
||||||
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
ports:
|
ports:
|
||||||
- "4841:4840"
|
- "4841:4840"
|
||||||
|
|
||||||
@@ -170,9 +167,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
ports:
|
ports:
|
||||||
- "4842:4840"
|
- "4842:4840"
|
||||||
|
|
||||||
@@ -194,9 +190,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
ports:
|
ports:
|
||||||
- "4843:4840"
|
- "4843:4840"
|
||||||
|
|
||||||
@@ -217,9 +212,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
ports:
|
ports:
|
||||||
- "4844:4840"
|
- "4844:4840"
|
||||||
|
|
||||||
@@ -241,9 +235,8 @@ services:
|
|||||||
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
|
||||||
Security__Jwt__Issuer: "otopcua-dev"
|
Security__Jwt__Issuer: "otopcua-dev"
|
||||||
Security__Jwt__Audience: "otopcua-dev"
|
Security__Jwt__Audience: "otopcua-dev"
|
||||||
Authentication__Ldap__Server: "ldap"
|
Authentication__Ldap__DevStubMode: "true"
|
||||||
Authentication__Ldap__Port: "1389"
|
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua__UY_NKlBl3vWuZt8HD7usfZsU76eibMKB6CufwzabUI}"
|
||||||
Authentication__Ldap__AllowInsecureLdap: "true"
|
|
||||||
ports:
|
ports:
|
||||||
- "4845:4840"
|
- "4845:4840"
|
||||||
|
|
||||||
@@ -256,7 +249,7 @@ services:
|
|||||||
- --api.insecure=true
|
- --api.insecure=true
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "8080:8080"
|
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# docker-dev cluster-seed entrypoint. Waits for the host containers to finish
|
# docker-dev cluster-seed entrypoint. Waits for the OtOpcUa ConfigDb schema to
|
||||||
# their EF Core auto-migration (which creates the ServerCluster table), then
|
# be in place, then applies the idempotent row seed.
|
||||||
# applies the idempotent seed script.
|
|
||||||
#
|
#
|
||||||
# Image: mcr.microsoft.com/mssql-tools (Debian + sqlcmd at /opt/mssql-tools18/bin).
|
# IMPORTANT: this container does NOT run EF migrations — sqlcmd can't execute
|
||||||
|
# the V2 migration script cleanly because it contains CREATE PROCEDURE
|
||||||
|
# statements inside IF NOT EXISTS BEGIN ... END blocks (procs must be the
|
||||||
|
# first statement in their batch). Migrations are owned by the operator:
|
||||||
|
#
|
||||||
|
# dotnet ef database update \
|
||||||
|
# --project src/Core/ZB.MOM.WW.OtOpcUa.Configuration \
|
||||||
|
# --startup-project src/Server/ZB.MOM.WW.OtOpcUa.Host
|
||||||
|
#
|
||||||
|
# (with ConnectionStrings__ConfigDb pointing at Server=localhost,14330;...).
|
||||||
|
# Once the schema is in place, restart the cluster-seed container — or just
|
||||||
|
# `docker compose up -d` and the seed will pick up where it left off thanks to
|
||||||
|
# the IF NOT EXISTS guards in seed-clusters.sql.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SQLCMD="/opt/mssql-tools18/bin/sqlcmd"
|
SQLCMD="/opt/mssql-tools/bin/sqlcmd"
|
||||||
SERVER="${SQL_HOST:-sql},1433"
|
SERVER="${SQL_HOST:-sql},1433"
|
||||||
USER="${SQL_USER:-sa}"
|
USER="${SQL_USER:-sa}"
|
||||||
PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}"
|
PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}"
|
||||||
DB="${SQL_DATABASE:-OtOpcUa}"
|
DB="${SQL_DATABASE:-OtOpcUa}"
|
||||||
|
|
||||||
run_sql() {
|
run_sql_in() {
|
||||||
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$DB" -No -b -h -1 "$@"
|
local target_db="$1"; shift
|
||||||
|
# -I forces SET QUOTED_IDENTIFIER ON (needed for filtered indexes if you
|
||||||
|
# ever extend this script to touch them).
|
||||||
|
"$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$target_db" -b -h -1 -I "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "[cluster-seed] waiting for SQL Server to accept connections..."
|
echo "[cluster-seed] waiting for SQL Server to accept connections..."
|
||||||
until run_sql -Q "SELECT 1" >/dev/null 2>&1; do
|
until run_sql_in master -Q "SELECT 1" >/dev/null 2>&1; do
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "[cluster-seed] SQL Server up."
|
echo "[cluster-seed] SQL Server up."
|
||||||
|
|
||||||
echo "[cluster-seed] waiting for $DB.ServerCluster (host containers must finish EF migration)..."
|
echo "[cluster-seed] waiting for ${DB} database + dbo.ServerCluster table (operator must run dotnet ef database update)..."
|
||||||
until run_sql -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
|
until run_sql_in "$DB" -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
echo "[cluster-seed] schema ready."
|
echo "[cluster-seed] schema ready."
|
||||||
|
|
||||||
echo "[cluster-seed] applying seed-clusters.sql..."
|
echo "[cluster-seed] applying seed-clusters.sql (ServerCluster + ClusterNode rows)..."
|
||||||
run_sql -i /seed/seed-clusters.sql
|
run_sql_in "$DB" -i /seed/seed-clusters.sql
|
||||||
|
|
||||||
echo "[cluster-seed] done."
|
echo "[cluster-seed] done."
|
||||||
|
|||||||
@@ -55,45 +55,130 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-B')
|
|||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
-- ClusterNode — main cluster OPC UA publishers
|
-- ClusterNode — main cluster OPC UA publishers
|
||||||
|
--
|
||||||
|
-- NodeId is "<compose-service>:4053" so it matches what ClusterRoleInfo +
|
||||||
|
-- ConfigPublishCoordinator derive from Akka.Cluster.Get(system).State.Members
|
||||||
|
-- (member.Address.Host:Port). NodeDeploymentState.NodeId is FK-bound to
|
||||||
|
-- ClusterNode.NodeId; mismatched values cause FK 547 on deploy.
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('driver-a', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
|
VALUES ('driver-a:4053', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('driver-b', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
|
VALUES ('driver-b:4053', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
-- ClusterNode — site A
|
-- ClusterNode — site A
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('site-a-1', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
|
VALUES ('site-a-1:4053', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('site-a-2', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
|
VALUES ('site-a-2:4053', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
-- ClusterNode — site B
|
-- ClusterNode — site B
|
||||||
------------------------------------------------------------------------------
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('site-b-1', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
|
VALUES ('site-b-1:4053', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2')
|
IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
|
||||||
INSERT INTO dbo.ClusterNode
|
INSERT INTO dbo.ClusterNode
|
||||||
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
VALUES ('site-b-2', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- Galaxy MxAccess gateway — MAIN cluster
|
||||||
|
--
|
||||||
|
-- Namespace.Kind=SystemPlatform is required for Galaxy/MXAccess data per
|
||||||
|
-- decision #107; raw equipment drivers use Equipment. DriverInstance points
|
||||||
|
-- at the external mxaccessgw process. The driver code lives in this repo
|
||||||
|
-- (.NET 10, cross-platform); only the gateway worker needs Windows.
|
||||||
|
--
|
||||||
|
-- ApiKeySecretRef = env:GALAXY_MXGW_API_KEY → resolved at runtime by
|
||||||
|
-- GalaxyDriver.ResolveApiKey. The env var is set on every driver-role
|
||||||
|
-- container in docker-compose.yml.
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Namespace WHERE NamespaceId = 'MAIN-galaxy')
|
||||||
|
INSERT INTO dbo.Namespace
|
||||||
|
(NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
|
||||||
|
VALUES
|
||||||
|
(NEWID(), 'MAIN-galaxy', 'MAIN', 'SystemPlatform',
|
||||||
|
'urn:zb:docker-dev:galaxy', 1,
|
||||||
|
'docker-dev seed — Galaxy / MXAccess namespace served by the MAIN cluster.');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-galaxy-mxgw')
|
||||||
|
INSERT INTO dbo.DriverInstance
|
||||||
|
(DriverInstanceRowId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||||
|
VALUES
|
||||||
|
(NEWID(), 'MAIN-galaxy-mxgw', 'MAIN', 'MAIN-galaxy',
|
||||||
|
'MxAccess gateway (10.100.0.48:5120)', 'GalaxyMxGateway', 1,
|
||||||
|
N'{
|
||||||
|
"Gateway": {
|
||||||
|
"Endpoint": "http://10.100.0.48:5120",
|
||||||
|
"ApiKeySecretRef": "env:GALAXY_MXGW_API_KEY",
|
||||||
|
"UseTls": false,
|
||||||
|
"ConnectTimeoutSeconds": 10,
|
||||||
|
"DefaultCallTimeoutSeconds": 30
|
||||||
|
},
|
||||||
|
"MxAccess": {
|
||||||
|
"ClientName": "OtOpcUa-MAIN-docker-dev",
|
||||||
|
"PublishingIntervalMs": 1000
|
||||||
|
},
|
||||||
|
"Repository": {
|
||||||
|
"DiscoverPageSize": 5000,
|
||||||
|
"WatchDeployEvents": true
|
||||||
|
},
|
||||||
|
"Reconnect": {
|
||||||
|
"InitialBackoffMs": 500,
|
||||||
|
"MaxBackoffMs": 30000,
|
||||||
|
"ReplayOnSessionLost": true
|
||||||
|
}
|
||||||
|
}');
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
-- Galaxy test tags — TestMachine_001.TestAlarm001..003
|
||||||
|
--
|
||||||
|
-- SystemPlatform-namespace tags have EquipmentId=NULL and use FolderPath +
|
||||||
|
-- Name to address the MXAccess item. The Galaxy driver subscribes via the
|
||||||
|
-- "FolderPath.Name" MXAccess reference form; OPC UA browse path is the
|
||||||
|
-- equivalent "FolderPath/Name" under the SystemPlatform namespace.
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm001')
|
||||||
|
INSERT INTO dbo.Tag
|
||||||
|
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||||
|
VALUES
|
||||||
|
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm001', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||||
|
'TestAlarm001', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm002')
|
||||||
|
INSERT INTO dbo.Tag
|
||||||
|
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||||
|
VALUES
|
||||||
|
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm002', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||||
|
'TestAlarm002', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-galaxy-TestMachine_001-TestAlarm003')
|
||||||
|
INSERT INTO dbo.Tag
|
||||||
|
(TagRowId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||||
|
VALUES
|
||||||
|
(NEWID(), 'MAIN-galaxy-TestMachine_001-TestAlarm003', 'MAIN-galaxy-mxgw', NULL, NULL,
|
||||||
|
'TestAlarm003', 'TestMachine_001', 'Boolean', 0, 0, NULL, N'{}');
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
@@ -104,3 +189,7 @@ COMMIT TRANSACTION;
|
|||||||
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
|
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
|
||||||
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
|
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
|
||||||
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
|
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
|
||||||
|
SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
|
||||||
|
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
|
||||||
|
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
|
||||||
|
SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name;
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ http:
|
|||||||
services:
|
services:
|
||||||
otopcua-admin:
|
otopcua-admin:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||||
|
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||||
|
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||||
|
sticky:
|
||||||
|
cookie:
|
||||||
|
name: otopcua_lb
|
||||||
|
httpOnly: true
|
||||||
|
sameSite: lax
|
||||||
servers:
|
servers:
|
||||||
- url: "http://admin-a:9000"
|
- url: "http://admin-a:9000"
|
||||||
- url: "http://admin-b:9000"
|
- url: "http://admin-b:9000"
|
||||||
@@ -38,6 +46,14 @@ http:
|
|||||||
|
|
||||||
otopcua-site-a:
|
otopcua-site-a:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||||
|
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||||
|
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||||
|
sticky:
|
||||||
|
cookie:
|
||||||
|
name: otopcua_lb
|
||||||
|
httpOnly: true
|
||||||
|
sameSite: lax
|
||||||
servers:
|
servers:
|
||||||
- url: "http://site-a-1:9000"
|
- url: "http://site-a-1:9000"
|
||||||
- url: "http://site-a-2:9000"
|
- url: "http://site-a-2:9000"
|
||||||
@@ -48,6 +64,14 @@ http:
|
|||||||
|
|
||||||
otopcua-site-b:
|
otopcua-site-b:
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
# Blazor Server uses SignalR; the WebSocket upgrade must hit the same
|
||||||
|
# backend that owns the circuit ID. Sticky cookie keeps each session
|
||||||
|
# pinned to one node so the post-handshake WebSocket doesn't 404.
|
||||||
|
sticky:
|
||||||
|
cookie:
|
||||||
|
name: otopcua_lb
|
||||||
|
httpOnly: true
|
||||||
|
sameSite: lax
|
||||||
servers:
|
servers:
|
||||||
- url: "http://site-b-1:9000"
|
- url: "http://site-b-1:9000"
|
||||||
- url: "http://site-b-2:9000"
|
- url: "http://site-b-2:9000"
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||||
|
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||||
|
|
||||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ public interface IOpcUaAddressSpaceSink
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
|
||||||
|
/// <paramref name="parentFolderNodeId"/> (or the namespace root when null). Created with
|
||||||
|
/// Bad quality + null value; subsequent <see cref="WriteValue"/> calls update both.
|
||||||
|
/// Used by <c>Phase7Applier</c> to materialise Galaxy / SystemPlatform tags ahead of any
|
||||||
|
/// driver-side subscribe so OPC UA clients can browse them. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dataType">OPC UA built-in type name ("Boolean" / "Int32" / "Float" / etc.).</param>
|
||||||
|
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
|
||||||
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
/// successful deployment apply so the node manager reflects the new config. Idempotent.
|
||||||
@@ -42,5 +52,6 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||||
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
public void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc) { }
|
||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
|
||||||
public void RebuildAddressSpace() { }
|
public void RebuildAddressSpace() { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
<div class="login-wrap rise" style="animation-delay:.02s">
|
<div class="login-wrap rise" style="animation-delay:.02s">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">OtOpcUa Admin — sign in</div>
|
<div style="padding:1.4rem 1.1rem 1.25rem">
|
||||||
<div style="padding:1.1rem 1.1rem 1.25rem">
|
<h1 class="login-title">OtOpcUa Admin — sign in</h1>
|
||||||
<form method="post" action="/auth/login" data-enhance="false">
|
<form method="post" action="/auth/login" data-enhance="false">
|
||||||
@if (ReturnUrl is not null)
|
@if (ReturnUrl is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -49,6 +49,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login card title. Replaces the panel-head top strip on the login page so the
|
||||||
|
card reads as a self-contained sign-in form, not a tabbed panel. */
|
||||||
|
.login-title {
|
||||||
|
margin: 0 0 1.1rem 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
|
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
|
||||||
.sidebar .brand styling — used now that the top app-bar was dropped. */
|
.sidebar .brand styling — used now that the top app-bar was dropped. */
|
||||||
.side-rail .brand {
|
.side-rail .brand {
|
||||||
|
|||||||
@@ -24,18 +24,21 @@ public static class HealthEndpoints
|
|||||||
|
|
||||||
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
|
// AllowAnonymous on all three — Traefik / k8s liveness probes / load-balancers
|
||||||
|
// hit these without credentials. Without it the AddOtOpcUaAuth fallback policy
|
||||||
|
// 401s every probe and Traefik marks every backend unhealthy.
|
||||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||||
{
|
{
|
||||||
Predicate = c => c.Tags.Contains("ready"),
|
Predicate = c => c.Tags.Contains("ready"),
|
||||||
});
|
}).AllowAnonymous();
|
||||||
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
||||||
{
|
{
|
||||||
Predicate = c => c.Tags.Contains("active"),
|
Predicate = c => c.Tags.Contains("active"),
|
||||||
});
|
}).AllowAnonymous();
|
||||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||||
{
|
{
|
||||||
Predicate = _ => false, // process-liveness only — no probes run.
|
Predicate = _ => false, // process-liveness only — no probes run.
|
||||||
});
|
}).AllowAnonymous();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,69 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
|
||||||
|
/// <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
|
||||||
|
/// timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
|
||||||
|
/// Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the
|
||||||
|
/// Galaxy driver issues SubscribeBulk.
|
||||||
|
/// </summary>
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(displayName);
|
||||||
|
|
||||||
|
// If already present, leave it alone (idempotent re-applies).
|
||||||
|
if (_variables.ContainsKey(variableNodeId)) return;
|
||||||
|
|
||||||
|
lock (Lock)
|
||||||
|
{
|
||||||
|
if (_variables.ContainsKey(variableNodeId)) return;
|
||||||
|
|
||||||
|
var parent = ResolveParentFolder(parentFolderNodeId);
|
||||||
|
var variable = new BaseDataVariableState(parent)
|
||||||
|
{
|
||||||
|
NodeId = new NodeId(variableNodeId, NamespaceIndex),
|
||||||
|
BrowseName = new QualifiedName(variableNodeId, NamespaceIndex),
|
||||||
|
DisplayName = displayName,
|
||||||
|
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.Organizes,
|
||||||
|
DataType = ResolveBuiltInDataType(dataType),
|
||||||
|
ValueRank = ValueRanks.Scalar,
|
||||||
|
AccessLevel = AccessLevels.CurrentRead,
|
||||||
|
UserAccessLevel = AccessLevels.CurrentRead,
|
||||||
|
Historizing = false,
|
||||||
|
Value = null,
|
||||||
|
StatusCode = StatusCodes.BadWaitingForInitialData,
|
||||||
|
Timestamp = DateTime.MinValue,
|
||||||
|
};
|
||||||
|
parent.AddChild(variable);
|
||||||
|
AddPredefinedNode(SystemContext, variable);
|
||||||
|
_variables[variableNodeId] = variable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
|
||||||
|
/// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
|
||||||
|
/// (matches CreateVariable's default for lazy-created nodes).</summary>
|
||||||
|
private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
|
||||||
|
{
|
||||||
|
"Boolean" => DataTypeIds.Boolean,
|
||||||
|
"SByte" => DataTypeIds.SByte,
|
||||||
|
"Byte" => DataTypeIds.Byte,
|
||||||
|
"Int16" => DataTypeIds.Int16,
|
||||||
|
"UInt16" => DataTypeIds.UInt16,
|
||||||
|
"Int32" => DataTypeIds.Int32,
|
||||||
|
"UInt32" => DataTypeIds.UInt32,
|
||||||
|
"Int64" => DataTypeIds.Int64,
|
||||||
|
"UInt64" => DataTypeIds.UInt64,
|
||||||
|
"Float" => DataTypeIds.Float,
|
||||||
|
"Double" => DataTypeIds.Double,
|
||||||
|
"String" => DataTypeIds.String,
|
||||||
|
"DateTime" => DataTypeIds.DateTime,
|
||||||
|
_ => DataTypeIds.BaseDataType,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
/// <summary>Clear every registered variable + folder from the address space. Phase7Applier
|
||||||
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
/// calls this when Equipment/Alarm topology changes; the populator then re-adds via
|
||||||
/// EnsureFolder + WriteValue on the next pass.</summary>
|
/// EnsureFolder + WriteValue on the next pass.</summary>
|
||||||
|
|||||||
@@ -64,16 +64,19 @@ public sealed class Phase7Applier
|
|||||||
}
|
}
|
||||||
|
|
||||||
var changedCount =
|
var changedCount =
|
||||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count;
|
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||||
|
plan.ChangedGalaxyTags.Count;
|
||||||
var addedCount =
|
var addedCount =
|
||||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count;
|
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||||
|
plan.AddedGalaxyTags.Count;
|
||||||
|
|
||||||
// Any add/remove of Equipment or ScriptedAlarm requires a real address-space rebuild.
|
// Any add/remove of Equipment, ScriptedAlarm, or Galaxy tag topology requires a real
|
||||||
// Driver-instance changes don't touch the address-space topology directly — they go
|
// address-space rebuild. Driver-instance changes don't touch the address-space topology
|
||||||
// through DriverHostActor's spawn-plan in Runtime.
|
// directly — they go through DriverHostActor's spawn-plan in Runtime.
|
||||||
var needsRebuild =
|
var needsRebuild =
|
||||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
|
||||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0;
|
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
|
||||||
|
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0;
|
||||||
|
|
||||||
if (needsRebuild)
|
if (needsRebuild)
|
||||||
{
|
{
|
||||||
@@ -125,12 +128,55 @@ public sealed class Phase7Applier
|
|||||||
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
composition.UnsAreas.Count, composition.UnsLines.Count, composition.EquipmentNodes.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot:
|
||||||
|
/// for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder
|
||||||
|
/// under the namespace root), then ensure a Variable node sits inside that folder for
|
||||||
|
/// the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
|
||||||
|
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
|
||||||
|
/// Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(composition);
|
||||||
|
if (composition.GalaxyTags.Count == 0) return;
|
||||||
|
|
||||||
|
// Folders first — each distinct FolderPath becomes one folder under the root.
|
||||||
|
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var tag in composition.GalaxyTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||||
|
if (!foldersCreated.Add(tag.FolderPath)) continue;
|
||||||
|
SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables: NodeId is "<FolderPath>.<DisplayName>" so it matches the MXAccess ref the
|
||||||
|
// Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
|
||||||
|
// resolution; flat NodeId keeps the address space lookup cheap.
|
||||||
|
foreach (var tag in composition.GalaxyTags)
|
||||||
|
{
|
||||||
|
var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
|
||||||
|
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
|
||||||
|
SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
|
||||||
|
composition.GalaxyTags.Count, foldersCreated.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
private void SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
|
||||||
{
|
{
|
||||||
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureFolder threw for {Node}", nodeId); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType)
|
||||||
|
{
|
||||||
|
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "Phase7Applier: EnsureVariable threw for {Node}", nodeId); }
|
||||||
|
}
|
||||||
|
|
||||||
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
private void SafeWriteAlarmState(string nodeId, bool active, bool acknowledged, DateTime ts)
|
||||||
{
|
{
|
||||||
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
try { _sink.WriteAlarmState(nodeId, active, acknowledged, ts); }
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||||
|
|
||||||
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
|
||||||
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
|
||||||
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
|
||||||
/// its parent line id so the applier knows where to hang each equipment folder.</summary>
|
/// its parent line id so the applier knows where to hang each equipment folder.
|
||||||
|
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
|
||||||
|
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
|
||||||
public sealed record Phase7CompositionResult(
|
public sealed record Phase7CompositionResult(
|
||||||
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
IReadOnlyList<UnsAreaProjection> UnsAreas,
|
||||||
IReadOnlyList<UnsLineProjection> UnsLines,
|
IReadOnlyList<UnsLineProjection> UnsLines,
|
||||||
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
IReadOnlyList<EquipmentNode> EquipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
|
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans,
|
||||||
|
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
|
||||||
{
|
{
|
||||||
/// <summary>Convenience constructor for tests + earlier callers that don't yet carry UNS topology.</summary>
|
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary>
|
||||||
public Phase7CompositionResult(
|
public Phase7CompositionResult(
|
||||||
IReadOnlyList<EquipmentNode> equipmentNodes,
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||||
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||||
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
|
||||||
equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
|
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
|
||||||
|
public Phase7CompositionResult(
|
||||||
|
IReadOnlyList<UnsAreaProjection> unsAreas,
|
||||||
|
IReadOnlyList<UnsLineProjection> unsLines,
|
||||||
|
IReadOnlyList<EquipmentNode> equipmentNodes,
|
||||||
|
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
|
||||||
|
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
|
||||||
|
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +45,21 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin
|
|||||||
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
|
||||||
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
|
||||||
|
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
|
||||||
|
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
|
||||||
|
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
|
||||||
|
/// that the Galaxy driver consumes when subscribing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GalaxyTagPlan(
|
||||||
|
string TagId,
|
||||||
|
string DriverInstanceId,
|
||||||
|
string FolderPath,
|
||||||
|
string DisplayName,
|
||||||
|
string DataType,
|
||||||
|
string MxAccessRef);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
/// Pure composer that flattens the live-edit DB tables into the address-space build plan a
|
||||||
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
/// driver-role host needs. Same inputs → same outputs, no logging, no DB writes. The driver-role
|
||||||
@@ -43,19 +73,32 @@ public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentI
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Phase7Composer
|
public static class Phase7Composer
|
||||||
{
|
{
|
||||||
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS topology.</summary>
|
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary>
|
||||||
public static Phase7CompositionResult Compose(
|
public static Phase7CompositionResult Compose(
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||||
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms);
|
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
|
||||||
|
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||||
|
|
||||||
|
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary>
|
||||||
|
public static Phase7CompositionResult Compose(
|
||||||
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
|
IReadOnlyList<Equipment> equipment,
|
||||||
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms) =>
|
||||||
|
Compose(unsAreas, unsLines, equipment, driverInstances, scriptedAlarms,
|
||||||
|
Array.Empty<Tag>(), Array.Empty<Namespace>());
|
||||||
|
|
||||||
public static Phase7CompositionResult Compose(
|
public static Phase7CompositionResult Compose(
|
||||||
IReadOnlyList<UnsArea> unsAreas,
|
IReadOnlyList<UnsArea> unsAreas,
|
||||||
IReadOnlyList<UnsLine> unsLines,
|
IReadOnlyList<UnsLine> unsLines,
|
||||||
IReadOnlyList<Equipment> equipment,
|
IReadOnlyList<Equipment> equipment,
|
||||||
IReadOnlyList<DriverInstance> driverInstances,
|
IReadOnlyList<DriverInstance> driverInstances,
|
||||||
IReadOnlyList<ScriptedAlarm> scriptedAlarms)
|
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
|
||||||
|
IReadOnlyList<Tag> tags,
|
||||||
|
IReadOnlyList<Namespace> namespaces)
|
||||||
{
|
{
|
||||||
var areas = unsAreas
|
var areas = unsAreas
|
||||||
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||||
@@ -82,6 +125,30 @@ public static class Phase7Composer
|
|||||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms);
|
// SystemPlatform tags = Galaxy tags. Match each tag to its DriverInstance and that
|
||||||
|
// driver's Namespace; emit only when the namespace kind is SystemPlatform AND the tag
|
||||||
|
// has no EquipmentId (per the entity invariant for SystemPlatform).
|
||||||
|
var driversById = driverInstances.ToDictionary(d => d.DriverInstanceId, StringComparer.Ordinal);
|
||||||
|
var namespacesById = namespaces.ToDictionary(n => n.NamespaceId, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var galaxyTags = tags
|
||||||
|
.Where(t => t.EquipmentId is null)
|
||||||
|
.Where(t => driversById.TryGetValue(t.DriverInstanceId, out var di)
|
||||||
|
&& namespacesById.TryGetValue(di.NamespaceId, out var ns)
|
||||||
|
&& ns.Kind == NamespaceKind.SystemPlatform)
|
||||||
|
.OrderBy(t => t.DriverInstanceId, StringComparer.Ordinal)
|
||||||
|
.ThenBy(t => t.FolderPath, StringComparer.Ordinal)
|
||||||
|
.ThenBy(t => t.Name, StringComparer.Ordinal)
|
||||||
|
.Select(t => new GalaxyTagPlan(
|
||||||
|
TagId: t.TagId,
|
||||||
|
DriverInstanceId: t.DriverInstanceId,
|
||||||
|
FolderPath: t.FolderPath ?? string.Empty,
|
||||||
|
DisplayName: t.Name,
|
||||||
|
DataType: t.DataType,
|
||||||
|
// MXAccess reference: "FolderPath.Name" when FolderPath is set, else just "Name".
|
||||||
|
MxAccessRef: string.IsNullOrWhiteSpace(t.FolderPath) ? t.Name : $"{t.FolderPath}.{t.Name}"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, galaxyTags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,21 @@ public sealed record Phase7Plan(
|
|||||||
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
|
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
|
||||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||||
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
|
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms,
|
||||||
|
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
|
||||||
|
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
|
||||||
|
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
|
||||||
{
|
{
|
||||||
public bool IsEmpty =>
|
public bool IsEmpty =>
|
||||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
||||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0;
|
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||||
|
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0;
|
||||||
|
|
||||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||||
|
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Phase7Planner
|
public static class Phase7Planner
|
||||||
@@ -61,10 +66,16 @@ public static class Phase7Planner
|
|||||||
a => a.ScriptedAlarmId,
|
a => a.ScriptedAlarmId,
|
||||||
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
(a, b) => new Phase7Plan.AlarmDelta(a, b));
|
||||||
|
|
||||||
|
var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById(
|
||||||
|
previous.GalaxyTags, next.GalaxyTags,
|
||||||
|
t => t.TagId,
|
||||||
|
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
|
||||||
|
|
||||||
return new Phase7Plan(
|
return new Phase7Plan(
|
||||||
addedEq, removedEq, changedEq,
|
addedEq, removedEq, changedEq,
|
||||||
addedDrv, removedDrv, changedDrv,
|
addedDrv, removedDrv, changedDrv,
|
||||||
addedAlarm, removedAlarm, changedAlarm);
|
addedAlarm, removedAlarm, changedAlarm,
|
||||||
|
addedGalaxy, removedGalaxy, changedGalaxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
private static (IReadOnlyList<T> Added, IReadOnlyList<T> Removed, IReadOnlyList<TDelta> Changed)
|
||||||
|
|||||||
@@ -27,5 +27,8 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
|
|||||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
|
||||||
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
|
||||||
|
|
||||||
|
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
|
||||||
|
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType);
|
||||||
|
|
||||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ public static class DeploymentArtifact
|
|||||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||||
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
|
||||||
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
|
||||||
|
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
|
||||||
|
|
||||||
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms);
|
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
@@ -115,7 +116,87 @@ public static class DeploymentArtifact
|
|||||||
Array.Empty<UnsLineProjection>(),
|
Array.Empty<UnsLineProjection>(),
|
||||||
Array.Empty<EquipmentNode>(),
|
Array.Empty<EquipmentNode>(),
|
||||||
Array.Empty<DriverInstancePlan>(),
|
Array.Empty<DriverInstancePlan>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
Array.Empty<GalaxyTagPlan>());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
|
||||||
|
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one <see cref="GalaxyTagPlan"/>
|
||||||
|
/// per qualifying tag. Mirrors <c>Phase7Composer.Compose</c>'s filter so a compose-side
|
||||||
|
/// plan and an artifact-decode plan agree on the same set of tags.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<GalaxyTagPlan> BuildGalaxyTagPlans(JsonElement root, IReadOnlyList<DriverInstancePlan> drivers)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
|
||||||
|
return Array.Empty<GalaxyTagPlan>();
|
||||||
|
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
|
||||||
|
return Array.Empty<GalaxyTagPlan>();
|
||||||
|
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
|
||||||
|
return Array.Empty<GalaxyTagPlan>();
|
||||||
|
|
||||||
|
// namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default,
|
||||||
|
// but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both.
|
||||||
|
var systemPlatformNamespaces = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in nsArr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
|
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
|
||||||
|
var isSystemPlatform = kindEl.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1
|
||||||
|
JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if (isSystemPlatform) systemPlatformNamespaces.Add(id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverInstanceId → namespaceId
|
||||||
|
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
foreach (var el in diArr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
|
||||||
|
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
|
||||||
|
driverToNamespace[id!] = ns!;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<GalaxyTagPlan>(tagsArr.GetArrayLength());
|
||||||
|
foreach (var el in tagsArr.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
// Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path).
|
||||||
|
if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue;
|
||||||
|
|
||||||
|
var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null;
|
||||||
|
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
|
||||||
|
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
|
||||||
|
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
|
||||||
|
? fpEl.GetString() : null;
|
||||||
|
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name))
|
||||||
|
continue;
|
||||||
|
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
|
||||||
|
if (!systemPlatformNamespaces.Contains(nsId)) continue;
|
||||||
|
|
||||||
|
var folderPath = folder ?? string.Empty;
|
||||||
|
var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}";
|
||||||
|
result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId);
|
||||||
|
if (byDriver != 0) return byDriver;
|
||||||
|
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
|
||||||
|
if (byFolder != 0) return byFolder;
|
||||||
|
return string.CompareOrdinal(a.DisplayName, b.DisplayName);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
private static IReadOnlyList<T> ReadArray<T>(JsonElement root, string propertyName, Func<JsonElement, T?> reader)
|
||||||
where T : class
|
where T : class
|
||||||
|
|||||||
@@ -66,9 +66,15 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||||
/// configured roles. Mirrors plan §Task 55: Windows-only driver types (Galaxy, Wonderware
|
/// configured roles. Only the v1 in-process types stay Windows-only:
|
||||||
/// Historian) are stubbed when running on non-Windows OR when the host carries the
|
/// <list type="bullet">
|
||||||
/// <c>dev</c> role.
|
/// <item><c>"Galaxy"</c> — legacy MXAccess COM proxy (retired in PR 7.2; gated for any
|
||||||
|
/// leftover DriverInstance rows that still reference the old type name).</item>
|
||||||
|
/// <item><c>"Historian.Wonderware"</c> — Wonderware Historian sidecar over Windows-only
|
||||||
|
/// named pipes.</item>
|
||||||
|
/// </list>
|
||||||
|
/// The v2 <c>"GalaxyMxGateway"</c> driver talks gRPC to an external mxaccessgw process,
|
||||||
|
/// so it runs on any platform .NET 10 supports — Linux containers included. Not stubbed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
|
public static bool ShouldStub(string driverType, IEnumerable<string> roles)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
private int _writes;
|
private int _writes;
|
||||||
private byte _lastServiceLevel;
|
private byte _lastServiceLevel;
|
||||||
private Phase7CompositionResult _lastApplied = new(
|
private Phase7CompositionResult _lastApplied = new(
|
||||||
|
Array.Empty<UnsAreaProjection>(),
|
||||||
|
Array.Empty<UnsLineProjection>(),
|
||||||
Array.Empty<EquipmentNode>(),
|
Array.Empty<EquipmentNode>(),
|
||||||
Array.Empty<DriverInstancePlan>(),
|
Array.Empty<DriverInstancePlan>(),
|
||||||
Array.Empty<ScriptedAlarmPlan>());
|
Array.Empty<ScriptedAlarmPlan>(),
|
||||||
|
Array.Empty<GalaxyTagPlan>());
|
||||||
|
|
||||||
public int WriteCount => _writes;
|
public int WriteCount => _writes;
|
||||||
public byte LastServiceLevel => _lastServiceLevel;
|
public byte LastServiceLevel => _lastServiceLevel;
|
||||||
@@ -190,6 +193,10 @@ public sealed class OpcUaPublishActor : ReceiveActor
|
|||||||
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
// clients see Area/Line/Equipment as proper folders. Idempotent; Phase7Applier
|
||||||
// skips folders that already exist with the same node id.
|
// skips folders that already exist with the same node id.
|
||||||
_applier.MaterialiseHierarchy(composition);
|
_applier.MaterialiseHierarchy(composition);
|
||||||
|
// Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder
|
||||||
|
// + Variable node exist so clients can browse them. The Galaxy driver fills values
|
||||||
|
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
|
||||||
|
_applier.MaterialiseGalaxyTags(composition);
|
||||||
|
|
||||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ public static class AuthEndpoints
|
|||||||
private static async Task<IResult> LogoutAsync(HttpContext http)
|
private static async Task<IResult> LogoutAsync(HttpContext http)
|
||||||
{
|
{
|
||||||
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
return Results.NoContent();
|
|
||||||
|
// Browser form POST → redirect to /login so the user lands somewhere visible.
|
||||||
|
// API callers that prefer the status-only contract should hit the endpoint with
|
||||||
|
// Accept: application/json and we'll hand them a 204 instead.
|
||||||
|
var wantsJson = http.Request.Headers.Accept.Any(v =>
|
||||||
|
v?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
if (wantsJson) return Results.NoContent();
|
||||||
|
return Results.Redirect("/login");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user