Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a0596fb1 | |||
| 219d10a22d | |||
| 607dc51dec | |||
| 9d86287d08 | |||
| 2697af31d1 | |||
| 52997ee164 | |||
| 21eac21409 | |||
| 8b08566f41 | |||
| 50787823d3 | |||
| 7e22e2250c | |||
| d21f6947e1 | |||
| 7fa863f6da | |||
| f427dc4f26 | |||
| 3e3f7588bd | |||
| c02f016f1d | |||
| a1325299ce | |||
| 14fb2b05ed | |||
| da141497f8 | |||
| 9892ceae9a | |||
| 59858129cb | |||
| e248e037e7 | |||
| ae980aef5d | |||
| 2662ac08e4 | |||
| 45740578c9 | |||
| 5ae67a48ba | |||
| d055cb059e | |||
| 74161f9460 | |||
| 396052a126 | |||
| fd0cc4dfdb | |||
| 850d6774ea | |||
| 5c754ecffd | |||
| 68c6f36cfe | |||
| 36c4751571 | |||
| 229282ad8b | |||
| b0a2bb037d | |||
| ba6e5dd7f9 | |||
| 686138123f | |||
| cd5540cb1a | |||
| 4e6ef648d1 | |||
| f18c285cca | |||
| 7a6b016d9e | |||
| 8f32b89fb9 | |||
| 337a691629 | |||
| b06e3ae740 | |||
| f57f61deac | |||
| 8e5c8e29f7 | |||
| 253fb60459 | |||
| 8ac71db464 | |||
| 7e3b56c27d | |||
| e40615dad5 | |||
| 1689901c0e | |||
| 3c3fef911c | |||
| a8becc9c46 | |||
| 5cfbe8b5dd | |||
| 62e3cd6599 | |||
| d6fac2d81d | |||
| bb353c4d43 | |||
| 45a8c79ffe | |||
| b266f63cd7 | |||
| dfc143cdeb | |||
| 463512d1d8 | |||
| 09d6676e1f | |||
| 698709a578 | |||
| 76310b8829 |
@@ -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"
|
||||
@@ -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
|
||||
@@ -18,8 +18,6 @@
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ZB.MOM.WW.OtOpcUa.OpcUaServer.csproj" />
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ZB.MOM.WW.OtOpcUa.Runtime.csproj" />
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj" />
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
@@ -64,12 +62,10 @@
|
||||
</Folder>
|
||||
<Folder Name="/tests/Server/">
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj" />
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
+6
-3
@@ -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
|
||||
|
||||
|
||||
+64
-74
@@ -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<long> | `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<long> | `cluster.id` | Primary-role nodes per cluster — should be exactly 1 in nominal state. |
|
||||
| `otopcua.redundancy.secondary_count` | ObservableGauge<long> | `cluster.id` | Secondary-role nodes per cluster. |
|
||||
| `otopcua.redundancy.stale_count` | ObservableGauge<long> | `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<RoleChangedMessage>("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.
|
||||
|
||||
+55
-41
@@ -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<App>`, `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\<service>-*.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.
|
||||
|
||||
@@ -65,35 +65,37 @@
|
||||
{"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": "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": 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": "F1", "subject": "Follow-up: AuthEndpoints integration tests against fused Host", "status": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": ["F2"], "blockedBy": [53], "origin": "Deviation from Task 29 (commit 38ea0c5) — deferred until Task 53 wires AddOtOpcUaAuth/MapOtOpcUaAuth in Program. Add WebApplicationFactory<OtOpcUa.Host.Program> tests for /auth/login (204/401/503), /auth/ping (401/200), /auth/token (200+JWT), /auth/logout (204+cookie clear) using a stub ILdapAuthService."},
|
||||
{"id": "F2", "subject": "Follow-up: Replace JwtBearer BuildServiceProvider antipattern with IPostConfigureOptions", "status": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F1"], "blockedBy": [], "origin": "Deviation from Task 26 (commit 207fc6a) — AddOtOpcUaAuth uses services.BuildServiceProvider().CreateScope() inside .AddJwtBearer lambda (ASP0000). Refactor to IPostConfigureOptions<JwtBearerOptions> 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": "pending", "classification": "small", "estMinutes": 15, "parallelizableWith": ["F4"], "blockedBy": [], "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": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": ["F3"], "blockedBy": [], "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": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "small", "estMinutes": 10, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 35 — RedundancyStateActorTests are skipped because single-node DistributedPubSub bootstrap is unreliable in TestKit. Inject an Action<object> 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": "pending", "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."},
|
||||
{"id": "F8", "subject": "Follow-up: VirtualTagActor engine wiring (compile expression, subscribe deps, publish result)", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "origin": "Self-review of Task 42 — VirtualTagEngine.Evaluate not called; DependencyValueChanged just buffers."},
|
||||
{"id": "F9", "subject": "Follow-up: ScriptedAlarmActor engine wiring + state persistence", "status": "pending", "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."},
|
||||
{"id": "F10", "subject": "Follow-up: OpcUaPublishActor SDK integration (address-space writes + ServiceLevel + RebuildAddressSpace)", "status": "pending", "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."},
|
||||
{"id": "F11", "subject": "Follow-up: HistorianAdapterActor named-pipe IPC + SqliteStoreAndForwardSink wiring", "status": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "small", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "high-risk", "estMinutes": 120, "parallelizableWith": [], "blockedBy": [], "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": "pending", "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."},
|
||||
{"id": "F15", "subject": "Follow-up: Migrate 47 legacy Admin Blazor components into AdminUI library", "status": "pending", "classification": "high-risk", "estMinutes": 180, "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": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "standard", "estMinutes": 30, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "small", "estMinutes": 5, "parallelizableWith": [], "blockedBy": [], "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": "pending", "classification": "standard", "estMinutes": 20, "parallelizableWith": [], "blockedBy": [], "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": "pending", "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."}
|
||||
{"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<OtOpcUa.Host.Program> 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<Program> — 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<JwtBearerOptions> 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<object> 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."}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<JwtBearerOptions>` 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).
|
||||
|
||||
@@ -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)
|
||||
@@ -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` |
|
||||
@@ -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<AkkaClusterOptions>` 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<NodeId> 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).
|
||||
@@ -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<T>` or the `ActorRegistry`:
|
||||
|
||||
```csharp
|
||||
public sealed class AdminOperationsClient(ActorRegistry registry) : IAdminOperationsClient
|
||||
{
|
||||
private readonly IActorRef _proxy = registry.Get<AdminOperationsActorKey>();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 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/`.
|
||||
@@ -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<DbHealthStatus>` 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<OtOpcUaConfigDbContext>` + `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`.
|
||||
@@ -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)."
|
||||
|
||||
@@ -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)"
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,97 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the local <see cref="ActorSystem"/>, applies the embedded HOCON plus an overlay
|
||||
/// generated from <see cref="AkkaClusterOptions"/>, and joins the cluster. On shutdown,
|
||||
/// runs <c>CoordinatedShutdown</c> with the <c>ClusterLeavingReason</c> so the local node
|
||||
/// leaves the cluster cleanly before the process exits.
|
||||
/// </summary>
|
||||
public sealed class AkkaHostedService : IHostedService
|
||||
{
|
||||
private readonly AkkaClusterOptions _options;
|
||||
private readonly ILogger<AkkaHostedService> _logger;
|
||||
private ActorSystem? _actorSystem;
|
||||
|
||||
public AkkaHostedService(IOptions<AkkaClusterOptions> options, ILogger<AkkaHostedService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public ActorSystem ActorSystem =>
|
||||
_actorSystem ?? throw new InvalidOperationException(
|
||||
"ActorSystem requested before AkkaHostedService.StartAsync ran.");
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var overlay = BuildOverlay(_options);
|
||||
var baseConfig = ConfigurationFactory.ParseString(HoconLoader.LoadBaseConfig());
|
||||
var config = ConfigurationFactory.ParseString(overlay).WithFallback(baseConfig);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting ActorSystem '{System}' on {Host}:{Port} with roles=[{Roles}]",
|
||||
_options.SystemName, _options.PublicHostname, _options.Port,
|
||||
string.Join(",", _options.Roles));
|
||||
|
||||
_actorSystem = ActorSystem.Create(_options.SystemName, config);
|
||||
|
||||
if (_options.SeedNodes.Length > 0)
|
||||
{
|
||||
var seeds = _options.SeedNodes.Select(Address.Parse).ToList();
|
||||
Akka.Cluster.Cluster.Get(_actorSystem).JoinSeedNodes(seeds);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_actorSystem is null) return;
|
||||
|
||||
_logger.LogInformation("Initiating cluster-leave CoordinatedShutdown");
|
||||
var shutdown = CoordinatedShutdown.Get(_actorSystem);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
await shutdown.Run(CoordinatedShutdown.ClusterLeavingReason.Instance)
|
||||
.WaitAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Cluster leave timed out after 30s; forcing terminate");
|
||||
await _actorSystem.Terminate().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildOverlay(AkkaClusterOptions o)
|
||||
{
|
||||
var seeds = string.Join(",", o.SeedNodes.Select(Quote));
|
||||
var roles = string.Join(",", o.Roles.Select(Quote));
|
||||
return $@"
|
||||
akka {{
|
||||
remote.dot-netty.tcp {{
|
||||
hostname = {Quote(o.Hostname)}
|
||||
port = {o.Port}
|
||||
public-hostname = {Quote(o.PublicHostname)}
|
||||
}}
|
||||
cluster {{
|
||||
seed-nodes = [{seeds}]
|
||||
roles = [{roles}]
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
|
||||
private static string Quote(string? value)
|
||||
{
|
||||
var escaped = (value ?? string.Empty).Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
return $"\"{escaped}\"";
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
{
|
||||
_cluster = Akka.Cluster.Cluster.Get(system);
|
||||
_logger = logger;
|
||||
_localNode = CommonsNodeId.Parse(options.Value.PublicHostname);
|
||||
// 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<string>(options.Value.Roles, StringComparer.Ordinal);
|
||||
|
||||
SeedFromCurrentState();
|
||||
@@ -48,7 +51,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
{
|
||||
if (!_membersByRole.TryGetValue(role, out var members)) return Array.Empty<CommonsNodeId>();
|
||||
return members
|
||||
.Select(m => CommonsNodeId.Parse(m.Address.Host ?? string.Empty))
|
||||
.Select(m => ToNodeId(m.Address))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -58,7 +61,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
lock (_lock)
|
||||
{
|
||||
return _roleLeaders.TryGetValue(role, out var leader) && leader is not null
|
||||
? CommonsNodeId.Parse(leader.Address.Host ?? string.Empty)
|
||||
? ToNodeId(leader.Address)
|
||||
: (CommonsNodeId?)null;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +124,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
{
|
||||
_roleLeaders.TryGetValue(evt.Role, out var prevMember);
|
||||
if (prevMember is not null)
|
||||
previous = CommonsNodeId.Parse(prevMember.Address.Host ?? string.Empty);
|
||||
previous = ToNodeId(prevMember.Address);
|
||||
|
||||
var nextMember = evt.Leader is null
|
||||
? null
|
||||
@@ -129,7 +132,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
|
||||
_roleLeaders[evt.Role] = nextMember;
|
||||
if (nextMember is not null)
|
||||
next = CommonsNodeId.Parse(nextMember.Address.Host ?? string.Empty);
|
||||
next = ToNodeId(nextMember.Address);
|
||||
|
||||
raise = !Nullable.Equals(previous, next);
|
||||
}
|
||||
@@ -150,6 +153,9 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static CommonsNodeId ToNodeId(Akka.Actor.Address address) =>
|
||||
CommonsNodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_subscriber?.Tell(PoisonPill.Instance);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster.Hosting;
|
||||
using Akka.Hosting;
|
||||
using Akka.Remote.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
@@ -9,20 +11,57 @@ namespace ZB.MOM.WW.OtOpcUa.Cluster;
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the Akka cluster hosted service and exposes <see cref="ActorSystem"/> and
|
||||
/// <see cref="IClusterRoleInfo"/> as singletons resolved from it. Call after binding
|
||||
/// <c>OTOPCUA_ROLES</c> into <c>AkkaClusterOptions.Roles</c> via the calling Program.cs.
|
||||
/// Binds <see cref="AkkaClusterOptions"/> and registers <see cref="IClusterRoleInfo"/>. The
|
||||
/// actual ActorSystem + cluster bootstrap is layered on inside the host's <c>AddAkka(...)</c>
|
||||
/// configurator via <see cref="WithOtOpcUaClusterBootstrap"/> — keeping the entire Akka graph
|
||||
/// under Akka.Hosting's management so cluster singletons land on the same ActorSystem.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOtOpcUaCluster(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOptions<AkkaClusterOptions>()
|
||||
.Bind(configuration.GetSection(AkkaClusterOptions.SectionName));
|
||||
|
||||
services.AddSingleton<AkkaHostedService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||
services.AddSingleton<ActorSystem>(sp => sp.GetRequiredService<AkkaHostedService>().ActorSystem);
|
||||
services.AddSingleton<IClusterRoleInfo, ClusterRoleInfo>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the Akka.Hosting builder with the embedded OtOpcUa HOCON (split-brain resolver,
|
||||
/// pinned dispatcher, failure detector tuning) + remote endpoint + cluster bootstrap derived
|
||||
/// from <see cref="AkkaClusterOptions"/>.
|
||||
///
|
||||
/// Wire from Program.cs:
|
||||
/// <code>
|
||||
/// services.AddAkka("otopcua", (ab, sp) =>
|
||||
/// {
|
||||
/// ab.WithOtOpcUaClusterBootstrap(sp);
|
||||
/// if (hasAdmin) ab.WithOtOpcUaControlPlaneSingletons();
|
||||
/// if (hasDriver) ab.WithOtOpcUaRuntimeActors();
|
||||
/// });
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public static AkkaConfigurationBuilder WithOtOpcUaClusterBootstrap(
|
||||
this AkkaConfigurationBuilder builder,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<AkkaClusterOptions>>().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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence seam for <c>ScriptedAlarmActor</c>'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
|
||||
/// (<see cref="Configuration.Entities.ScriptedAlarmState"/>'s Comments/Confirmed/Shelving)
|
||||
/// stays in the production engine binding — this seam is the small surface the actor
|
||||
/// consumes directly.
|
||||
/// </summary>
|
||||
public interface IAlarmActorStateStore
|
||||
{
|
||||
Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>Persisted slice of <c>ScriptedAlarmActor</c>'s state. Active is NOT persisted —
|
||||
/// it re-derives from the evaluator on startup per Phase 7 decision #14. <c>State</c> here
|
||||
/// distinguishes Acknowledged vs not-yet-acknowledged for cases where the actor came up
|
||||
/// Active and operator interaction had already happened.</summary>
|
||||
/// <param name="AlarmId">Matches <c>ScriptedAlarm.ScriptedAlarmId</c>.</param>
|
||||
/// <param name="State">Inactive / Active / Acknowledged — the actor's 3-state enum, projected to string.</param>
|
||||
/// <param name="LastTransitionUtc">When the actor last transitioned.</param>
|
||||
/// <param name="LastAckUser">Who acknowledged most recently. Null when never acked.</param>
|
||||
public sealed record AlarmActorStateSnapshot(
|
||||
string AlarmId,
|
||||
string State,
|
||||
DateTime LastTransitionUtc,
|
||||
string? LastAckUser);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public sealed class NullAlarmActorStateStore : IAlarmActorStateStore
|
||||
{
|
||||
public static readonly NullAlarmActorStateStore Instance = new();
|
||||
private NullAlarmActorStateStore() { }
|
||||
public Task<AlarmActorStateSnapshot?> LoadAsync(string alarmId, CancellationToken ct) =>
|
||||
Task.FromResult<AlarmActorStateSnapshot?>(null);
|
||||
public Task SaveAsync(AlarmActorStateSnapshot snapshot, CancellationToken ct) =>
|
||||
Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the scripted-alarm predicate engine. Production binds this to a
|
||||
/// wrapper around <c>ScriptedAlarmEngine</c> from <c>Core.ScriptedAlarms</c>; default
|
||||
/// binding is <see cref="NullScriptedAlarmEvaluator"/> which keeps the alarm in its
|
||||
/// current state (so an unconfigured node never spuriously alarms).
|
||||
/// </summary>
|
||||
public interface IScriptedAlarmEvaluator
|
||||
{
|
||||
ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
/// <summary>Result of one alarm-predicate evaluation. <c>Active</c> is only meaningful when
|
||||
/// <c>Success</c> is true; on failure the caller should keep the prior state and log Reason.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Default that always returns <c>Active = false, Success = true</c>. Safe no-op:
|
||||
/// no alarm fires when no real engine is bound.</summary>
|
||||
public sealed class NullScriptedAlarmEvaluator : IScriptedAlarmEvaluator
|
||||
{
|
||||
public static readonly NullScriptedAlarmEvaluator Instance = new();
|
||||
private NullScriptedAlarmEvaluator() { }
|
||||
public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> ScriptedAlarmEvalResult.Ok(active: false);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so
|
||||
/// <see cref="VirtualTagActor"/> can stay free of Roslyn / scripting machinery and the
|
||||
/// production wiring binds an adapter over <c>VirtualTagEngine</c> from
|
||||
/// <c>Core.VirtualTags</c>.
|
||||
/// </summary>
|
||||
public interface IVirtualTagEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="expression"/> against the snapshot in
|
||||
/// <paramref name="dependencies"/>. Implementations must not throw — script failures
|
||||
/// are reported via <see cref="VirtualTagEvalResult.Failure"/>.
|
||||
/// </summary>
|
||||
VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies);
|
||||
}
|
||||
|
||||
/// <summary>Result of one virtual-tag expression eval. Stash a Reason on every Failure so
|
||||
/// callers can emit a useful <c>ScriptLogEntry</c> to operators.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> from every call. Bound by default
|
||||
/// when the production <c>VirtualTagEngine</c> adapter hasn't been registered (Mac dev, tests).</summary>
|
||||
public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
|
||||
{
|
||||
public static readonly NullVirtualTagEvaluator Instance = new();
|
||||
private NullVirtualTagEvaluator() { }
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
=> VirtualTagEvalResult.NoChange;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
|
||||
|
||||
/// <summary>
|
||||
/// Live alarm transition published on the cluster <c>alerts</c> DistributedPubSub topic.
|
||||
/// Emitted by ScriptedAlarmActor (and future native-alarm bridges) when an alarm condition
|
||||
/// transitions; consumed by <c>AlertSignalRBridge</c> for browser fan-out and by historian
|
||||
/// adapters for durable storage.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">Stable condition identity (matches <c>ScriptedAlarm.ScriptedAlarmId</c> for scripted alarms).</param>
|
||||
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the SourceNode.</param>
|
||||
/// <param name="AlarmName">Operator-visible alarm name.</param>
|
||||
/// <param name="TransitionKind">Activated / Cleared / Acknowledged / Confirmed / Shelved / Unshelved / Disabled / Enabled / CommentAdded.</param>
|
||||
/// <param name="Severity">1–1000 numeric severity (OPC UA convention).</param>
|
||||
/// <param name="Message">Fully-rendered message text — template tokens already resolved.</param>
|
||||
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events.</param>
|
||||
/// <param name="TimestampUtc">When the transition occurred.</param>
|
||||
public sealed record AlarmTransitionEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
string AlarmName,
|
||||
string TransitionKind,
|
||||
int Severity,
|
||||
string Message,
|
||||
string User,
|
||||
DateTime TimestampUtc);
|
||||
@@ -0,0 +1,10 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
||||
|
||||
/// <summary>
|
||||
/// Request a diagnostic snapshot from a per-node <c>DriverHostActor</c>. Sent by the admin UI's
|
||||
/// <c>IFleetDiagnosticsClient</c> via <c>ActorSelection</c> over the cluster; the local
|
||||
/// DriverHostActor responds with a <c>NodeDiagnosticsSnapshot</c>.
|
||||
/// </summary>
|
||||
public sealed record GetDiagnostics(CorrelationId CorrelationId);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// One line of script log output published on the cluster <c>script-logs</c> DPS topic.
|
||||
/// Emitted by VirtualTagActor + ScriptedAlarmActor when their hosted scripts call into
|
||||
/// the runtime's logging facade; consumed by <c>ScriptLogSignalRBridge</c> for live
|
||||
/// browser tail-style viewing.
|
||||
/// </summary>
|
||||
/// <param name="ScriptId">The Script row this entry came from (matches <c>Script.ScriptId</c>).</param>
|
||||
/// <param name="Level">"Trace" / "Debug" / "Information" / "Warning" / "Error" / "Critical" — Serilog levels.</param>
|
||||
/// <param name="Message">Operator-facing log message; template tokens already resolved.</param>
|
||||
/// <param name="TimestampUtc">When the script emitted the entry.</param>
|
||||
/// <param name="VirtualTagId">VirtualTag context, if logged from a virtual tag evaluation. Null otherwise.</param>
|
||||
/// <param name="AlarmId">ScriptedAlarm context, if logged from an alarm predicate. Null otherwise.</param>
|
||||
/// <param name="EquipmentId">Equipment scope, if the script ran in a per-equipment context. Null for fleet-wide scripts.</param>
|
||||
public sealed record ScriptLogEntry(
|
||||
string ScriptId,
|
||||
string Level,
|
||||
string Message,
|
||||
DateTime TimestampUtc,
|
||||
string? VirtualTagId,
|
||||
string? AlarmId,
|
||||
string? EquipmentId);
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Central <see cref="Meter"/> + <see cref="ActivitySource"/> 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 <c>Host</c>
|
||||
/// 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
|
||||
/// <c>otopcua.<subsystem>.<event></c>. Subsystem is one of: deploy, driver,
|
||||
/// virtualtag, scriptedalarm, opcua, redundancy.
|
||||
/// </summary>
|
||||
public static class OtOpcUaTelemetry
|
||||
{
|
||||
public const string MeterName = "ZB.MOM.WW.OtOpcUa";
|
||||
public const string ActivitySourceName = "ZB.MOM.WW.OtOpcUa";
|
||||
|
||||
/// <summary>Singleton <see cref="Meter"/> all counters/histograms hang off.</summary>
|
||||
public static readonly Meter Meter = new(MeterName);
|
||||
|
||||
/// <summary>Singleton <see cref="ActivitySource"/> used to start spans wrapping deploy/apply/rebuild.</summary>
|
||||
public static readonly ActivitySource ActivitySource = new(ActivitySourceName);
|
||||
|
||||
// ---------------- Deployment / driver-host coordination ----------------
|
||||
|
||||
/// <summary>Incremented every time DriverHostActor finishes applying a deployment (Ack or Reject).</summary>
|
||||
public static readonly Counter<long> DeploymentApplied =
|
||||
Meter.CreateCounter<long>("otopcua.deploy.applied", unit: "{deployment}",
|
||||
description: "Deployments applied by a driver-role node (outcome=ack|reject).");
|
||||
|
||||
/// <summary>Time from DriverHostActor receiving DispatchDeployment to emitting the ack/reject.</summary>
|
||||
public static readonly Histogram<double> DeploymentApplyDurationSec =
|
||||
Meter.CreateHistogram<double>("otopcua.deploy.apply.duration", unit: "s",
|
||||
description: "Driver-role apply latency from DispatchDeployment → Ack/Reject.");
|
||||
|
||||
/// <summary>DriverInstanceActor spawn count (added=new instance; stop=disposed).</summary>
|
||||
public static readonly Counter<long> DriverInstanceLifecycle =
|
||||
Meter.CreateCounter<long>("otopcua.driver.lifecycle", unit: "{event}",
|
||||
description: "DriverInstanceActor lifecycle transitions (event=spawn|stop|fault).");
|
||||
|
||||
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
|
||||
|
||||
public static readonly Counter<long> VirtualTagEval =
|
||||
Meter.CreateCounter<long>("otopcua.virtualtag.eval", unit: "{eval}",
|
||||
description: "Virtual-tag evaluations attempted (outcome=ok|fail|skip).");
|
||||
|
||||
public static readonly Counter<long> ScriptedAlarmTransition =
|
||||
Meter.CreateCounter<long>("otopcua.scriptedalarm.transition", unit: "{transition}",
|
||||
description: "Scripted-alarm state transitions (state=active|acknowledged|inactive).");
|
||||
|
||||
// ---------------- OPC UA address-space + redundancy ----------------
|
||||
|
||||
public static readonly Counter<long> OpcUaSinkWrite =
|
||||
Meter.CreateCounter<long>("otopcua.opcua.sink.write", unit: "{write}",
|
||||
description: "Writes that landed in IOpcUaAddressSpaceSink (kind=value|alarm|rebuild).");
|
||||
|
||||
public static readonly Counter<long> ServiceLevelChange =
|
||||
Meter.CreateCounter<long>("otopcua.redundancy.service_level_change", unit: "{change}",
|
||||
description: "OPC UA Server.ServiceLevel transitions emitted by the redundancy state.");
|
||||
|
||||
// ---------------- Convenience helpers ----------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Activity? StartDeployApplySpan(string deploymentId)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("otopcua.deploy.apply", ActivityKind.Internal);
|
||||
activity?.SetTag("otopcua.deployment_id", deploymentId);
|
||||
return activity;
|
||||
}
|
||||
|
||||
/// <summary>Span wrapping a full OPC UA address-space rebuild (Phase7 plan → apply).</summary>
|
||||
public static Activity? StartAddressSpaceRebuildSpan()
|
||||
=> ActivitySource.StartActivity("otopcua.opcua.address_space_rebuild", ActivityKind.Internal);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper <see cref="IOpcUaAddressSpaceSink"/> that defers to an inner sink swapped in at
|
||||
/// runtime. Needed because the production sink (<c>SdkAddressSpaceSink</c>) wraps an
|
||||
/// <c>OtOpcUaNodeManager</c> that only exists after the SDK <c>StandardServer</c> 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
|
||||
/// <see cref="SetSink"/> once the server is up. Until that swap happens, every call is a
|
||||
/// no-op against <see cref="NullOpcUaAddressSpaceSink"/>, so the actor stays safe to
|
||||
/// receive messages from the moment it boots.
|
||||
/// </summary>
|
||||
public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private volatile IOpcUaAddressSpaceSink _inner = NullOpcUaAddressSpaceSink.Instance;
|
||||
|
||||
/// <summary>Swap in the production sink. Pass <c>null</c> to revert to the null sink
|
||||
/// (used during graceful shutdown so post-stop writes don't hit a half-disposed manager).</summary>
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Late-binding adapter that holds an inner <see cref="IServiceLevelPublisher"/> reference
|
||||
/// swappable at runtime. Mirrors <see cref="DeferredAddressSpaceSink"/>: Akka actors resolve
|
||||
/// the publisher at DI time, but the production <c>SdkServiceLevelPublisher</c> only exists
|
||||
/// after <c>StandardServer.Start</c>. The Host's hosted service swaps the inner once the SDK
|
||||
/// is up; until then writes route through <see cref="NullServiceLevelPublisher"/>.
|
||||
/// </summary>
|
||||
public sealed class DeferredServiceLevelPublisher : IServiceLevelPublisher
|
||||
{
|
||||
private volatile IServiceLevelPublisher _inner = NullServiceLevelPublisher.Instance;
|
||||
|
||||
/// <summary>Swap the underlying publisher. Pass null to revert to the Null no-op.</summary>
|
||||
public void SetInner(IServiceLevelPublisher? inner) =>
|
||||
_inner = inner ?? NullServiceLevelPublisher.Instance;
|
||||
|
||||
public void Publish(byte serviceLevel) => _inner.Publish(serviceLevel);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the OPC UA SDK's address space. <c>OpcUaPublishActor</c> consumes this
|
||||
/// so the Runtime project doesn't reference <c>Opc.Ua.Server</c> directly — production
|
||||
/// binds a real SDK-backed sink in the fused Host's wiring, dev/Mac binds the
|
||||
/// <see cref="NullOpcUaAddressSpaceSink"/> no-op.
|
||||
/// </summary>
|
||||
public interface IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Write a Variable node's current value + quality + source timestamp.</summary>
|
||||
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>Write an alarm-condition Variable's active/acknowledged state.</summary>
|
||||
void WriteAlarmState(string alarmNodeId, bool active, bool acknowledged, DateTime sourceTimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Ensure a folder node exists under the given parent. Used by <c>Phase7Applier</c> to
|
||||
/// materialise the UNS Area/Line/Equipment hierarchy in the address space. When
|
||||
/// <paramref name="parentNodeId"/> is null the folder is parented under the namespace
|
||||
/// root. Idempotent: calling twice with the same id is safe.
|
||||
/// </summary>
|
||||
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void RebuildAddressSpace();
|
||||
}
|
||||
|
||||
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
|
||||
/// codes; the engine actors only need this 3-state classification.</summary>
|
||||
public enum OpcUaQuality { Good, Uncertain, Bad }
|
||||
|
||||
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
|
||||
/// integration tests without a real SDK behind them.</summary>
|
||||
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() { }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the OPC UA Server object's <c>ServiceLevel</c> Variable (0–255). Production binds
|
||||
/// a sink that pokes the SDK's ServiceLevel node; tests + dev mode bind
|
||||
/// <see cref="NullServiceLevelPublisher"/> which just records the most recently set level
|
||||
/// for inspection.
|
||||
/// </summary>
|
||||
public interface IServiceLevelPublisher
|
||||
{
|
||||
void Publish(byte serviceLevel);
|
||||
}
|
||||
|
||||
/// <summary>No-op default that retains the last-written ServiceLevel in
|
||||
/// <see cref="LastPublished"/>. Used by dev mode + verified by tests.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -22,4 +22,16 @@ public sealed class ConfigAuditLog
|
||||
public long? GenerationId { get; set; }
|
||||
|
||||
public string? DetailsJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable per-event identifier from <c>AuditEvent.EventId</c>. 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.
|
||||
/// </summary>
|
||||
public Guid? EventId { get; set; }
|
||||
|
||||
/// <summary>Correlation ID from <c>AuditEvent.CorrelationId</c> so an audit row joins to its
|
||||
/// originating request/workflow. Nullable for the same backfill reason as <see cref="EventId"/>.</summary>
|
||||
public Guid? CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
+1755
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddConfigAuditLogEventIdColumns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CorrelationId",
|
||||
table: "ConfigAuditLog",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -165,9 +165,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<Guid?>("CorrelationId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid?>("EventId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -192,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");
|
||||
|
||||
@@ -413,6 +413,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.Property(x => 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)
|
||||
@@ -420,6 +422,14 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.HasIndex(x => 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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the process-wide driver registry. Runtime consumes this instead of
|
||||
/// <c>DriverFactoryRegistry</c> directly so the Runtime project doesn't pull in
|
||||
/// <c>ZB.MOM.WW.OtOpcUa.Core</c> (which would drag in Polly + driver hosting). The fused
|
||||
/// Host binds a <c>DriverFactoryRegistryAdapter</c> after every <c>Driver.*.Register()</c>
|
||||
/// extension has run.
|
||||
/// </summary>
|
||||
public interface IDriverFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Return a new <see cref="IDriver"/> for the given <paramref name="driverType"/>, or
|
||||
/// <c>null</c> when no factory is registered for that type (missing assembly, typo, etc.).
|
||||
/// The DriverHostActor logs + skips the row rather than failing the whole apply.
|
||||
/// </summary>
|
||||
IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson);
|
||||
|
||||
/// <summary>Driver-type names this factory can materialise. Mostly for diagnostics + logs.</summary>
|
||||
IReadOnlyCollection<string> SupportedTypes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>null</c> from every <see cref="IDriverFactory.TryCreate"/> 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.
|
||||
/// </summary>
|
||||
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<string> SupportedTypes { get; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts the existing <see cref="DriverFactoryRegistry"/> (v1 surface, still the
|
||||
/// concrete singleton every driver assembly registers itself against) to the v2
|
||||
/// <see cref="IDriverFactory"/> abstraction consumed by Runtime. The fused Host binds
|
||||
/// this in DI once each <c>Driver.*.Register(registry)</c> call has completed.
|
||||
/// </summary>
|
||||
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<string> SupportedTypes => _registry.RegisteredTypes;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
@* Root Blazor component. *@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
@* 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. *@
|
||||
<link rel="stylesheet" href="lib/bootstrap/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="css/theme.css"/>
|
||||
<link rel="stylesheet" href="css/site.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +0,0 @@
|
||||
@* Cluster-scoped counterpart of <AuthorizeView>. 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<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
/// <summary>Cluster the grant is evaluated against.</summary>
|
||||
[Parameter, EditorRequired] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Minimum effective role required to render the authorized content.</summary>
|
||||
[Parameter] public AdminRole MinRole { get; set; } = AdminRole.ConfigViewer;
|
||||
|
||||
/// <summary>Content shown when authorized (alias-friendly: use this or <see cref="ChildContent"/>).</summary>
|
||||
[Parameter] public RenderFragment? Authorized { get; set; }
|
||||
|
||||
/// <summary>Default content slot — shown when authorized if <see cref="Authorized"/> is unset.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Content shown when the user lacks the required role; renders nothing when unset.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">My account</h4>
|
||||
</div>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@{
|
||||
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();
|
||||
}
|
||||
|
||||
<section class="card-grid rise" style="animation-delay:.02s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Identity</div>
|
||||
<div class="kv"><span class="k">Username</span><span class="v mono">@username</span></div>
|
||||
<div class="kv"><span class="k">Display name</span><span class="v">@displayName</span></div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Admin roles</div>
|
||||
@if (roles.Count == 0 && clusterGrants.Count == 0)
|
||||
{
|
||||
<div class="kv"><span class="k">Roles</span><span class="v text-muted">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</span></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="kv">
|
||||
<span class="k">Fleet-wide roles</span>
|
||||
<span class="v">
|
||||
@if (roles.Count == 0)
|
||||
{
|
||||
<span class="text-muted">none</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var r in roles)
|
||||
{
|
||||
<span class="chip chip-idle me-1">@r</span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (clusterGrants.Count > 0)
|
||||
{
|
||||
<div class="kv">
|
||||
<span class="k">Cluster-scoped roles</span>
|
||||
<span class="v">
|
||||
@foreach (var g in clusterGrants)
|
||||
{
|
||||
<span class="chip chip-idle me-1"><span class="mono">@g.ClusterId</span>: @g.Role</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="kv"><span class="k">LDAP groups</span><span class="v">@(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</span></div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Capabilities</div>
|
||||
<p class="px-3 pt-2 text-muted small">
|
||||
Each Admin role grants a fixed capability set per <span class="mono">admin-ui.md</span> §Admin Roles.
|
||||
Pages below reflect what this session can access; the route's <span class="mono">[Authorize]</span> guard
|
||||
is the ground truth — this table mirrors it for readability. This table covers
|
||||
<em>fleet-wide</em> capabilities only — a cluster-scoped grant unlocks the same actions inside its
|
||||
named cluster without satisfying these fleet-wide policies.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Required role(s)</th>
|
||||
<th class="text-end">You have it?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var cap in Capabilities)
|
||||
{
|
||||
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||
<tr>
|
||||
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
||||
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
||||
<td class="text-end">
|
||||
@if (has)
|
||||
{
|
||||
<span class="chip chip-ok">Yes</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-idle">No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mt-4">
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@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<Capability> 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]),
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Alarm historian</h4>
|
||||
</div>
|
||||
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Drain state</div>
|
||||
<div class="agg-value"><span class="chip @BadgeFor(_status.DrainState)">@_status.DrainState</span></div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Queue depth</div>
|
||||
<div class="agg-value numeric">@_status.QueueDepth.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Dead-letter depth</div>
|
||||
<div class="agg-value numeric">@_status.DeadLetterDepth.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Last success</div>
|
||||
<div class="agg-value">@(_status.LastSuccessUtc?.ToString("u") ?? "—")</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<strong>Last error:</strong> @_status.LastError
|
||||
</section>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_retryResult is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">Requeued @_retryResult row(s) for retry.</section>
|
||||
}
|
||||
|
||||
@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",
|
||||
};
|
||||
}
|
||||
@@ -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<Certificates> Log
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Certificate trust</h4>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
PKI store root <span class="mono">@Certs.PkiStoreRoot</span>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake.
|
||||
</section>
|
||||
|
||||
@if (_status is not null)
|
||||
{
|
||||
<div class="alert alert-@_statusKind alert-dismissible">
|
||||
@_status
|
||||
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Rejected (@_rejected.Count)</div>
|
||||
@if (_rejected.Count == 0)
|
||||
{
|
||||
<p class="px-3 py-2 text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _rejected)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><span class="mono small">@c.Thumbprint</span></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Trusted (@_trusted.Count)</div>
|
||||
@if (_trusted.Count == 0)
|
||||
{
|
||||
<p class="px-3 py-2 text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <span class="mono">own/</span> and is not listed here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _trusted)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><span class="mono small">@c.Thumbprint</span></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<CertInfo> _rejected = [];
|
||||
private IReadOnlyList<CertInfo> _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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="panel-head">Access-control grants</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_acls is null) { <p>Loading…</p> }
|
||||
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Grants</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _acls)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.LdapGroup</td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><span class="mono">@(a.ScopeId ?? "-")</span></td>
|
||||
<td><span class="mono">@a.PermissionFlags</span></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@* Probe-this-permission — task #196 slice 1 *@
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">
|
||||
Probe this permission
|
||||
<span class="small text-muted ms-2">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeGroup" placeholder="cn=fleet-admin,…"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeNamespaceId" placeholder="ns-1"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsArea</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsAreaId"/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">UnsLine</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeUnsLineId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Equipment</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeEquipmentId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Tag</label>
|
||||
<input class="form-control form-control-sm" @bind="_probeTagId"/>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Permission</label>
|
||||
<select class="form-select form-select-sm" @bind="_probePermission">
|
||||
@foreach (var p in Enum.GetValues<NodePermissions>())
|
||||
{
|
||||
if (p == NodePermissions.None) continue;
|
||||
<option value="@p">@p</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RunProbeAsync" disabled="@_probing">Probe</button>
|
||||
@if (_probeResult is not null)
|
||||
{
|
||||
<span class="ms-3">
|
||||
@if (_probeResult.Granted)
|
||||
{
|
||||
<span class="chip chip-ok">Granted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad">Denied</span>
|
||||
}
|
||||
<span class="small ms-2">
|
||||
Required <span class="mono">@_probeResult.Required</span>,
|
||||
Effective <span class="mono">@_probeResult.Effective</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (_probeResult is not null && _probeResult.Matches.Count > 0)
|
||||
{
|
||||
<div class="table-wrap mt-3">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>LDAP group matched</th><th>Level</th><th>Flags contributed</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var m in _probeResult.Matches)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@m.LdapGroup</span></td>
|
||||
<td>@m.Scope</td>
|
||||
<td><span class="mono">@m.PermissionFlags</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else if (_probeResult is not null)
|
||||
{
|
||||
<div class="mt-2 small text-muted">No matching grants for this (group, scope) — effective permission is <span class="mono">None</span>.</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Add grant</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group</label>
|
||||
<input class="form-control form-control-sm" @bind="_group"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope kind</label>
|
||||
<select class="form-select form-select-sm" @bind="_scopeKind">
|
||||
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
|
||||
<input class="form-control form-control-sm" @bind="_scopeId"/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
|
||||
<select class="form-select form-select-sm" @bind="_preset">
|
||||
<option value="Read">Read (Browse + Read)</option>
|
||||
<option value="WriteOperate">Read + Write Operate</option>
|
||||
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||
<option value="AlarmAck">Read + Alarm Ack</option>
|
||||
<option value="Full">Full (every flag)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<NodeAcl>? _acls;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
||||
private string _scopeId = string.Empty;
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
// 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<NodeAclChangedMessage>("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);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject AuditLogService AuditSvc
|
||||
|
||||
<h4 class="panel-head">Recent audit log</h4>
|
||||
|
||||
@if (_entries is null) { <p>Loading…</p> }
|
||||
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Entries</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th class="num">Generation</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _entries)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.Timestamp.ToString("u")</td>
|
||||
<td>@a.Principal</td>
|
||||
<td><span class="mono">@a.EventType</span></td>
|
||||
<td>@a.NodeId</td>
|
||||
<td class="num">@a.GenerationId</td>
|
||||
<td><small class="text-muted">@a.DetailsJson</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigAuditLog>? _entries;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (!_canView)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
You don't have access to cluster <span class="mono">@ClusterId</span>. A fleet-wide or
|
||||
cluster-scoped Admin role grant is required — ask a fleet admin to add one on the
|
||||
<a href="/role-grants">role grants</a> page.
|
||||
</section>
|
||||
}
|
||||
else if (_cluster is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Cluster <span class="mono">@ClusterId</span> was not found.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_liveBanner is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
<strong>Live update:</strong> @_liveBanner
|
||||
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
|
||||
</section>
|
||||
}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">@_cluster.Name</h4>
|
||||
<span class="mono text-muted">@_cluster.ClusterId</span>
|
||||
@if (!_cluster.Enabled) { <span class="chip chip-idle ms-2">Disabled</span> }
|
||||
</div>
|
||||
<div>
|
||||
@if (!_canEdit)
|
||||
{
|
||||
<span class="chip chip-idle">Read-only access</span>
|
||||
}
|
||||
else if (_currentDraft is not null)
|
||||
{
|
||||
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
|
||||
Edit current draft (gen @_currentDraft.GenerationId)
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("tags")" @onclick='() => _tab = "tags"'>Tags</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("redundancy")" @onclick='() => _tab = "redundancy"'>Redundancy</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@if (_tab == "overview")
|
||||
{
|
||||
<section class="card-grid rise" style="animation-delay:.08s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Cluster details</div>
|
||||
<div class="kv"><span class="k">Enterprise / Site</span><span class="v">@_cluster.Enterprise / @_cluster.Site</span></div>
|
||||
<div class="kv"><span class="k">Redundancy</span><span class="v">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</span></div>
|
||||
<div class="kv">
|
||||
<span class="k">Current published</span>
|
||||
<span class="v">
|
||||
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
|
||||
else { <span class="text-muted">none published yet</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv"><span class="k">Created</span><span class="v">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</span></div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (_tab == "generations")
|
||||
{
|
||||
<Generations ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "equipment" && _currentDraft is not null)
|
||||
{
|
||||
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
|
||||
}
|
||||
else if (_tab == "uns" && _currentDraft is not null)
|
||||
{
|
||||
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "namespaces" && _currentDraft is not null)
|
||||
{
|
||||
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "drivers" && _currentDraft is not null)
|
||||
{
|
||||
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "tags" && _currentDraft is not null)
|
||||
{
|
||||
<TagsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "acls" && _currentDraft is not null)
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "redundancy")
|
||||
{
|
||||
<RedundancyTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">Open a draft to edit this cluster's content.</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[CascadingParameter] private Task<AuthenticationState>? 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<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
|
||||
await LoadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Clusters</h4>
|
||||
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||
</div>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters yet. Create the first one.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">All clusters</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th>
|
||||
<th>RedundancyMode</th><th class="num">NodeCount</th><th>Enabled</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@c.ClusterId</span></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise</td>
|
||||
<td>@c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td class="num">@c.NodeCount</td>
|
||||
<td>
|
||||
@if (c.Enabled) { <span class="chip chip-ok">Active</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -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. *@
|
||||
|
||||
<section class="panel rise mb-3" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="chip chip-ok me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="chip chip-bad me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="chip chip-warn me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="chip chip-idle">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<p class="p-3 text-muted small mb-0">No changes in this section.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="p-2 small text-muted border-bottom">
|
||||
<span class="s-warn">Showing the first @RowCap of @_total rows</span> — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <span class="mono">sp_ComputeGenerationDiff</span> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.LogicalId</span></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="chip chip-ok">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="chip chip-bad">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="chip chip-warn">@r.ChangeKind</span> break;
|
||||
default: <span class="chip chip-idle">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
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<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigViewer">
|
||||
<NotAuthorized>
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Viewing cluster <span class="mono">@ClusterId</span> requires a fleet-wide or
|
||||
cluster-scoped Admin role grant.
|
||||
</section>
|
||||
</NotAuthorized>
|
||||
<Authorized>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Draft diff</h4>
|
||||
<small class="text-muted">
|
||||
Cluster <span class="mono">@ClusterId</span> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||
</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Computing diff…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s"><span class="s-bad">@_error</span></section>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No differences — draft is structurally identical to the last published generation.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted mb-3">
|
||||
@_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.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
</Authorized>
|
||||
</ClusterAuthorizeView>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> 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.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> 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<DiffRow>? _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<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigEditor">
|
||||
<NotAuthorized>
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Editing cluster <span class="mono">@ClusterId</span> requires the
|
||||
<span class="mono">ConfigEditor</span> role for this cluster.
|
||||
</section>
|
||||
</NotAuthorized>
|
||||
<Authorized>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Draft editor</h4>
|
||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · generation @GenerationId</small>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
|
||||
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
|
||||
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.FleetAdmin">
|
||||
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
|
||||
</ClusterAuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Active("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("uns")" @onclick='() => _tab = "uns"'>UNS</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("virtual-tags")" @onclick='() => _tab = "virtual-tags"'>Virtual Tags</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("scripted-alarms")" @onclick='() => _tab = "scripted-alarms"'>Scripted Alarms</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "virtual-tags") { <VirtualTagsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "scripted-alarms") { <ScriptedAlarmsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<section class="panel rise sticky-top" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>Validation</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
@if (_validating) { <p class="text-muted">Checking…</p> }
|
||||
else if (_errors.Count == 0) { <p class="s-ok mb-0">No validation errors — safe to publish.</p> }
|
||||
else
|
||||
{
|
||||
<p class="s-bad mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</p>
|
||||
<ul class="list-unstyled">
|
||||
@foreach (var e in _errors)
|
||||
{
|
||||
<li class="mb-2">
|
||||
<span class="chip chip-bad me-1">@e.Code</span>
|
||||
<small>@e.Message</small>
|
||||
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><span class="mono">@e.Context</span></div> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_publishError is not null) { <section class="panel notice rise mt-2" style="animation-delay:.08s"><span class="s-bad">@_publishError</span></section> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Authorized>
|
||||
</ClusterAuthorizeView>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private string _tab = "equipment";
|
||||
private List<ValidationError> _errors = [];
|
||||
private bool _validating;
|
||||
private bool _busy;
|
||||
private string? _publishError;
|
||||
|
||||
private string Active(string k) => _tab == k ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RevalidateAsync();
|
||||
|
||||
private async Task RevalidateAsync()
|
||||
{
|
||||
_validating = true;
|
||||
try
|
||||
{
|
||||
var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None);
|
||||
_errors = errors.ToList();
|
||||
}
|
||||
finally { _validating = false; }
|
||||
}
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_publishError = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex) { _publishError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="panel-head">DriverInstances</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
|
||||
</div>
|
||||
|
||||
@if (_drivers is null) { <p>Loading…</p> }
|
||||
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Configured drivers</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@d.DriverInstanceId</span></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><span class="mono">@d.NamespaceId</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_showForm && _namespaces is not null)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Add driver</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="_name"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">DriverType</label>
|
||||
<select class="form-select form-select-sm" @bind="_type">
|
||||
<option>Galaxy</option>
|
||||
<option>Modbus</option>
|
||||
<option>AbCip</option>
|
||||
<option>AbLegacy</option>
|
||||
<option>S7</option>
|
||||
<option>Focas</option>
|
||||
<option>OpcUaClient</option>
|
||||
</select>
|
||||
<div class="form-text">Type string must match the driver's registered factory name; this dropdown wraps the canonical names.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Namespace</label>
|
||||
<select class="form-select form-select-sm" @bind="_nsId">
|
||||
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@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. *@
|
||||
<label class="form-label">Modbus options (typed editor)</label>
|
||||
<ModbusOptionsEditor Model="_modbusOptions"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_config"></textarea>
|
||||
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _name = string.Empty;
|
||||
private string _type = "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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the view-model field names onto the JSON shape <c>ModbusDriverFactoryExtensions</c>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<object>(),
|
||||
}, ModbusJsonOptions);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="panel-head">Equipment (draft gen @GenerationId)</h4>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm me-2" @onclick="GoImport">Import CSV…</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
|
||||
<section class="panel rise mb-3" style="animation-delay:.02s">
|
||||
<div class="panel-head">Search equipment</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-1">
|
||||
Search by ZTag, MachineCode, SAPID, EquipmentId, or EquipmentUuid
|
||||
</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="e.g. z-001 or MC-42 or SAP-…"
|
||||
@bind="_searchQuery"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="OnSearchKeyDown"/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="fuzzyCheck" @bind="_searchFuzzy"/>
|
||||
<label class="form-check-label small" for="fuzzyCheck">Fuzzy (substring)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RunSearchAsync" disabled="@_searchBusy">Search</button>
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-link ms-1" @onclick="ClearSearch">Clear</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_searchError is not null)
|
||||
{
|
||||
<p class="small text-danger mt-2 mb-0">@_searchError</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_searchHits is not null)
|
||||
{
|
||||
@if (_searchHits.Count == 0)
|
||||
{
|
||||
<p class="p-3 text-muted small mb-0">No matches.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-wrap" style="max-height: 340px; overflow-y: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th style="width:110px">Matched</th><th style="width:80px">Gen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var hit in _searchHits)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@hit.Equipment.EquipmentId</span></td>
|
||||
<td>@hit.Equipment.Name</td>
|
||||
<td>@hit.Equipment.MachineCode</td>
|
||||
<td>@hit.Equipment.ZTag</td>
|
||||
<td>@hit.Equipment.SAPID</td>
|
||||
<td>
|
||||
@if (hit.MatchedField is not null)
|
||||
{
|
||||
var chipClass = hit.Score switch
|
||||
{
|
||||
100 => "chip chip-ok",
|
||||
50 => "chip chip-warn",
|
||||
_ => "chip chip-idle",
|
||||
};
|
||||
<span class="@chipClass">@hit.MatchedField</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (hit.IsPublished)
|
||||
{ <span class="chip chip-ok">pub</span> }
|
||||
else
|
||||
{ <span class="chip chip-idle">draft</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="p-2 text-muted small mb-0">
|
||||
@_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
|
||||
Exact = green, prefix = amber, fuzzy = grey.
|
||||
Fuzzy matching requires the "Fuzzy" checkbox.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_equipment.Count == 0 && !_showForm)
|
||||
{
|
||||
<p class="text-muted">No equipment in this draft yet.</p>
|
||||
}
|
||||
else if (_equipment.Count > 0)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Equipment list</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th>Manufacturer / Model</th><th>Serial</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _equipment)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@e.EquipmentId</span></td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.MachineCode</td>
|
||||
<td>@e.ZTag</td>
|
||||
<td>@e.SAPID</td>
|
||||
<td>@e.Manufacturer / @e.Model</td>
|
||||
<td>@e.SerialNumber</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(e)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@(_editMode ? "Edit equipment" : "New equipment")</div>
|
||||
<div class="card-body">
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="equipment-form">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Name (UNS segment)</label>
|
||||
<InputText @bind-Value="_draft.Name" class="form-control form-control-sm"/>
|
||||
<ValidationMessage For="() => _draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MachineCode</label>
|
||||
<InputText @bind-Value="_draft.MachineCode" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstanceId</label>
|
||||
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">UnsLineId</label>
|
||||
<InputText @bind-Value="_draft.UnsLineId" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">ZTag</label>
|
||||
<InputText @bind-Value="_draft.ZTag" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">SAPID</label>
|
||||
<InputText @bind-Value="_draft.SAPID" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IdentificationFields Equipment="_draft"/>
|
||||
|
||||
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@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>? _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<EquipmentSearchHit>? _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();
|
||||
}
|
||||
}
|
||||
@@ -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) { <p>Loading…</p> }
|
||||
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Generations</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th class="num">ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<tr>
|
||||
<td class="num mono">@g.GenerationId</td>
|
||||
<td>@StatusBadge(g.Status)</td>
|
||||
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
|
||||
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
|
||||
<td><small>@g.PublishedBy</small></td>
|
||||
<td><small>@g.Notes</small></td>
|
||||
<td>
|
||||
@if (g.Status == GenerationStatus.Draft)
|
||||
{
|
||||
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
|
||||
}
|
||||
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_error is not null) { <section class="panel notice rise" style="animation-delay:.02s">@_error</section> }
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigGeneration>? _generations;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None);
|
||||
|
||||
private async Task RollbackAsync(long targetId)
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private static MarkupString StatusBadge(GenerationStatus s) => s switch
|
||||
{
|
||||
GenerationStatus.Draft => new MarkupString("<span class='chip chip-idle'>Draft</span>"),
|
||||
GenerationStatus.Published => new MarkupString("<span class='chip chip-ok'>Published</span>"),
|
||||
GenerationStatus.Superseded => new MarkupString("<span class='chip chip-idle'>Superseded</span>"),
|
||||
_ => new MarkupString($"<span class='chip chip-idle'>{s}</span>"),
|
||||
};
|
||||
}
|
||||
-51
@@ -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. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted border-bottom pb-1">OPC 40010 Identification</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Manufacturer</label>
|
||||
<InputText @bind-Value="Equipment!.Manufacturer" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Model</label>
|
||||
<InputText @bind-Value="Equipment!.Model" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Serial number</label>
|
||||
<InputText @bind-Value="Equipment!.SerialNumber" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Hardware rev</label>
|
||||
<InputText @bind-Value="Equipment!.HardwareRevision" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Software rev</label>
|
||||
<InputText @bind-Value="Equipment!.SoftwareRevision" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Year of construction</label>
|
||||
<InputNumber @bind-Value="Equipment!.YearOfConstruction" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Asset location</label>
|
||||
<InputText @bind-Value="Equipment!.AssetLocation" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Manufacturer URI</label>
|
||||
<InputText @bind-Value="Equipment!.ManufacturerUri" class="form-control form-control-sm" placeholder="https://…"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Device manual URI</label>
|
||||
<InputText @bind-Value="Equipment!.DeviceManualUri" class="form-control form-control-sm" placeholder="https://…"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Equipment? Equipment { get; set; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
<ClusterAuthorizeView ClusterId="@ClusterId" MinRole="AdminRole.ConfigEditor">
|
||||
<NotAuthorized>
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Importing equipment into cluster <span class="mono">@ClusterId</span> requires the
|
||||
<span class="mono">ConfigEditor</span> role for this cluster.
|
||||
</section>
|
||||
</NotAuthorized>
|
||||
<Authorized>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Equipment CSV import</h4>
|
||||
<small class="text-muted">Cluster <span class="mono">@ClusterId</span> · draft generation @GenerationId</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to draft</a>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Accepts <span class="mono">@EquipmentCsvImporter.VersionMarker</span>-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.
|
||||
</section>
|
||||
|
||||
<section class="panel notice rise mt-2" style="animation-delay:.08s">
|
||||
<strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import —
|
||||
tags are configured at the driver-instance level via the
|
||||
<a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the
|
||||
<a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check
|
||||
grammar strings (<span class="mono">40001:F:CDAB</span>, <span class="mono">HR1:I</span>, <span class="mono">V2000</span> for
|
||||
DL205 family, etc.) before pasting them into the driver config.
|
||||
</section>
|
||||
|
||||
<section class="panel rise mt-2" style="animation-delay:.14s">
|
||||
<div class="panel-head">Import configuration</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target driver instance (for every accepted row)</label>
|
||||
<select class="form-select form-select-sm" @bind="_driverInstanceId">
|
||||
<option value="">-- select driver --</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.DriverInstanceId</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Target UNS line (for every accepted row)</label>
|
||||
<select class="form-select form-select-sm" @bind="_unsLineId">
|
||||
<option value="">-- select line --</option>
|
||||
@if (_unsLines is not null)
|
||||
{
|
||||
@foreach (var l in _unsLines) { <option value="@l.UnsLineId">@l.UnsLineId — @l.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<InputFile OnChange="HandleFileAsync" class="form-control form-control-sm" accept=".csv,.txt"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label">CSV content (paste or uploaded)</label>
|
||||
<textarea class="form-control mono" rows="8" @bind="_csvText"
|
||||
placeholder="# OtOpcUaCsv v1 ZTag,MachineCode,SAPID,EquipmentUuid,Name,…"/>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="ParseAsync" disabled="@_busy">Parse</button>
|
||||
<button class="btn btn-sm btn-primary ms-2" @onclick="StageAndFinaliseAsync"
|
||||
disabled="@(_parseResult is null || _parseResult.AcceptedRows.Count == 0 || string.IsNullOrWhiteSpace(_driverInstanceId) || string.IsNullOrWhiteSpace(_unsLineId) || _busy)">
|
||||
Stage + Finalise
|
||||
</button>
|
||||
@if (_parseError is not null) { <span class="chip chip-bad ms-3">@_parseError</span> }
|
||||
@if (_result is not null) { <span class="chip chip-ok ms-3">@_result</span> }
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_parseResult is not null)
|
||||
{
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-6">
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head"><span class="s-ok">Accepted (@_parseResult.AcceptedRows.Count)</span></div>
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.AcceptedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No accepted rows.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>ZTag</th><th>Machine</th><th>Name</th><th>Line</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _parseResult.AcceptedRows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.ZTag</span></td>
|
||||
<td>@r.MachineCode</td>
|
||||
<td>@r.Name</td>
|
||||
<td>@r.UnsLineName</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head"><span class="s-bad">Rejected (@_parseResult.RejectedRows.Count)</span></div>
|
||||
<div class="table-wrap" style="max-height: 400px; overflow-y: auto;">
|
||||
@if (_parseResult.RejectedRows.Count == 0)
|
||||
{
|
||||
<p class="text-muted p-3 mb-0">No rejections.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="data-table">
|
||||
<thead><tr><th class="num">Line</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _parseResult.RejectedRows)
|
||||
{
|
||||
<tr>
|
||||
<td class="num">@e.LineNumber</td>
|
||||
<td><span class="s-bad">@e.Reason</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</Authorized>
|
||||
</ClusterAuthorizeView>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<UnsLine>? _unsLines;
|
||||
private string _driverInstanceId = string.Empty;
|
||||
private string _unsLineId = string.Empty;
|
||||
private string _csvText = string.Empty;
|
||||
private EquipmentCsvParseResult? _parseResult;
|
||||
private string? _parseError;
|
||||
private string? _result;
|
||||
private bool _busy;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_unsLines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task HandleFileAsync(InputFileChangeEventArgs e)
|
||||
{
|
||||
// 5 MiB cap — refuses pathological uploads that would OOM the server.
|
||||
using var stream = e.File.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024);
|
||||
using var reader = new StreamReader(stream);
|
||||
_csvText = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private async Task ParseAsync()
|
||||
{
|
||||
_parseError = null;
|
||||
_parseResult = null;
|
||||
_result = null;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var raw = EquipmentCsvImporter.Parse(_csvText);
|
||||
_parseResult = await BatchSvc.ApplyReservationPreCheckAsync(raw, CancellationToken.None);
|
||||
}
|
||||
catch (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task StageAndFinaliseAsync()
|
||||
{
|
||||
if (_parseResult is null) return;
|
||||
_busy = true;
|
||||
_result = null;
|
||||
_parseError = null;
|
||||
try
|
||||
{
|
||||
var auth = await AuthProvider.GetAuthenticationStateAsync();
|
||||
var createdBy = auth.User.Identity?.Name ?? "unknown";
|
||||
|
||||
var batch = await BatchSvc.CreateBatchAsync(ClusterId, createdBy, CancellationToken.None);
|
||||
await BatchSvc.StageRowsAsync(batch.Id, _parseResult.AcceptedRows, _parseResult.RejectedRows, CancellationToken.None);
|
||||
await BatchSvc.FinaliseBatchAsync(batch.Id, GenerationId, _driverInstanceId, _unsLineId, CancellationToken.None);
|
||||
|
||||
_result = $"Finalised batch {batch.Id:N} — {_parseResult.AcceptedRows.Count} rows added.";
|
||||
// Pause 600 ms so the success banner is visible, then navigate back.
|
||||
await Task.Delay(600);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{GenerationId}");
|
||||
}
|
||||
catch (Exception ex) { _parseError = $"Finalise failed: {ex.Message}"; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="panel-head">Namespaces</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
|
||||
</div>
|
||||
|
||||
@if (_namespaces is null) { <p>Loading…</p> }
|
||||
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Defined namespaces</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var n in _namespaces)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@n.NamespaceId</span></td>
|
||||
<td>@n.Kind</td>
|
||||
<td>@n.NamespaceUri</td>
|
||||
<td>@(n.Enabled ? "yes" : "no")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Add namespace</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control form-control-sm" @bind="_uri"/></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kind</label>
|
||||
<select class="form-select form-select-sm" @bind="_kind">
|
||||
<option value="@NamespaceKind.Equipment">Equipment</option>
|
||||
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _uri = string.Empty;
|
||||
private NamespaceKind _kind = NamespaceKind.Equipment;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_uri)) return;
|
||||
await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None);
|
||||
_uri = string.Empty;
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
@page "/clusters/new"
|
||||
@* Cluster creation is a FleetAdmin operation per admin-ui.md "Add a new cluster" —
|
||||
CanPublish gates it (Admin-002). Without this attribute the page was reachable
|
||||
and its CreateAsync write path exploitable by any caller. *@
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "CanPublish")]
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">New cluster</h4>
|
||||
</div>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted border-bottom pb-1">Identity</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">ClusterId <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.ClusterId" class="form-control form-control-sm"/>
|
||||
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
||||
<ValidationMessage For="() => _input.ClusterId"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Display name <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.Name" class="form-control form-control-sm"/>
|
||||
<ValidationMessage For="() => _input.Name"/>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted border-bottom pb-1">Placement</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Enterprise</label>
|
||||
<InputText @bind-Value="_input.Enterprise" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Site</label>
|
||||
<InputText @bind-Value="_input.Site" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted border-bottom pb-1">Topology</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Redundancy</label>
|
||||
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select form-select-sm">
|
||||
<option value="@RedundancyMode.None">None (single node)</option>
|
||||
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_error))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@_submitting">Save</button>
|
||||
<a href="/clusters" class="btn btn-outline-secondary btn-sm">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
||||
[StringLength(32)] public string Site { get; set; } = "dev";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
}
|
||||
|
||||
// Admin-007: record the authenticated operator's identity on every write path, not
|
||||
// the static literal "admin-ui" which produced an unattributable audit trail.
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
private Input _input = new();
|
||||
private bool _submitting;
|
||||
private string? _error;
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Resolve the signed-in principal name. The page is [Authorize(Policy="CanPublish")]
|
||||
// so AuthState will always be available with an authenticated user here; fall back to
|
||||
// "unknown" only as a defensive last resort (should never happen in practice).
|
||||
var user = AuthState is not null ? (await AuthState).User : null;
|
||||
var operatorName = user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? "unknown";
|
||||
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = _input.ClusterId,
|
||||
Name = _input.Name,
|
||||
Enterprise = _input.Enterprise,
|
||||
Site = _input.Site,
|
||||
RedundancyMode = _input.RedundancyMode,
|
||||
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||
Enabled = true,
|
||||
CreatedBy = operatorName,
|
||||
};
|
||||
|
||||
await ClusterSvc.CreateAsync(cluster, createdBy: operatorName, CancellationToken.None);
|
||||
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: operatorName, CancellationToken.None);
|
||||
|
||||
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally { _submitting = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,187 +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
|
||||
@inject ClusterNodeService NodeSvc
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h4 class="panel-head">Redundancy topology</h4>
|
||||
@if (_roleChangedBanner is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">@_roleChangedBanner</section>
|
||||
}
|
||||
<p class="text-muted small">
|
||||
One row per <span class="mono">ClusterNode</span> in this cluster. Role, <span class="mono">ApplicationUri</span>,
|
||||
and <span class="mono">ServiceLevelBase</span> are authored separately; the Admin UI shows them read-only
|
||||
here so operators can confirm the published topology without touching it. LastSeen older than
|
||||
@((int)ClusterNodeService.StaleThreshold.TotalSeconds)s is flagged Stale — the node has
|
||||
stopped heart-beating and is likely down. Role swap goes through the server-side
|
||||
<span class="mono">RedundancyCoordinator</span> apply-lease flow, not direct DB edits.
|
||||
</p>
|
||||
|
||||
@if (_nodes is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_nodes.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No ClusterNode rows for this cluster. The server process needs at least one entry
|
||||
(with a non-blank <span class="mono">ApplicationUri</span>) before it can start up per OPC UA spec.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
var primaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Primary);
|
||||
var secondaries = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Secondary);
|
||||
var standalone = _nodes.Count(n => n.RedundancyRole == RedundancyRole.Standalone);
|
||||
var staleCount = _nodes.Count(ClusterNodeService.IsStale);
|
||||
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Nodes</div>
|
||||
<div class="agg-value numeric">@_nodes.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Primary</div>
|
||||
<div class="agg-value numeric @(primaries > 0 ? "s-ok" : "")">@primaries</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Secondary</div>
|
||||
<div class="agg-value numeric">@secondaries</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Stale</div>
|
||||
<div class="agg-value numeric @(staleCount > 0 ? "s-warn" : "")">@staleCount</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (primaries == 0 && standalone == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<span class="s-bad">No Primary or Standalone node — the cluster has no authoritative write target. Secondaries
|
||||
stay read-only until one of them gets promoted via <span class="mono">RedundancyCoordinator</span>.</span>
|
||||
</section>
|
||||
}
|
||||
else if (primaries > 1)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<span class="s-bad"><strong>Split-brain:</strong> @primaries nodes claim the Primary role. Apply-lease
|
||||
enforcement should have made this impossible at the coordinator level. Investigate
|
||||
immediately — one of the rows was likely hand-edited.</span>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Role</th>
|
||||
<th>Host</th>
|
||||
<th class="num">OPC UA port</th>
|
||||
<th class="num">ServiceLevel base</th>
|
||||
<th>ApplicationUri</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var n in _nodes)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@n.NodeId</span></td>
|
||||
<td><span class="chip @RoleBadge(n.RedundancyRole)">@n.RedundancyRole</span></td>
|
||||
<td>@n.Host</td>
|
||||
<td class="num mono">@n.OpcUaPort</td>
|
||||
<td class="num">@n.ServiceLevelBase</td>
|
||||
<td class="mono">@n.ApplicationUri</td>
|
||||
<td>
|
||||
@if (n.Enabled) { <span class="chip chip-ok">Enabled</span> }
|
||||
else { <span class="chip chip-idle">Disabled</span> }
|
||||
</td>
|
||||
<td class="@(ClusterNodeService.IsStale(n) ? "s-warn" : "")">
|
||||
@(n.LastSeenAt is null ? "never" : FormatAge(n.LastSeenAt.Value))
|
||||
@if (ClusterNodeService.IsStale(n)) { <span class="chip chip-warn ms-1">Stale</span> }
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<ClusterNode>? _nodes;
|
||||
private HubConnection? _hub;
|
||||
private string? _roleChangedBanner;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, CancellationToken.None);
|
||||
if (_hub is null) await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
|
||||
_hub.On<RoleChangedMessage>("RoleChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_roleChangedBanner = $"Role changed on {msg.NodeId}: {msg.FromRole} → {msg.ToRole} at {msg.ObservedAtUtc:HH:mm:ss 'UTC'}";
|
||||
_nodes = await NodeSvc.ListByClusterAsync(ClusterId, 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 role-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;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(ClusterNode n) =>
|
||||
ClusterNodeService.IsStale(n) ? "table-warning" :
|
||||
!n.Enabled ? "table-secondary" : "";
|
||||
|
||||
private static string RoleBadge(RedundancyRole r) => r switch
|
||||
{
|
||||
RedundancyRole.Primary => "chip-ok",
|
||||
RedundancyRole.Secondary => "chip-idle",
|
||||
RedundancyRole.Standalone => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||
pulls the CDN bundle).
|
||||
|
||||
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||
tab re-renders on change for the dependency preview. The test-harness button
|
||||
lives in the parent so one editor can drive multiple script types.
|
||||
*@
|
||||
|
||||
<div class="script-editor">
|
||||
<textarea class="form-control mono" rows="14" spellcheck="false"
|
||||
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Source { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ScriptedAlarmService AlarmSvc
|
||||
@inject ScriptService ScriptSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="panel-head mb-0">Scripted Alarms</h4>
|
||||
<small class="text-muted">OPC UA Part 9 alarms raised by C# predicate scripts. Additive to driver-native alarm streams.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New alarm</button>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted">Loading…</p>
|
||||
}
|
||||
else if (_alarms.Count == 0 && !_showForm)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">No scripted alarms yet in this draft.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_alarms.Count > 0)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>Scripted alarms in draft gen @GenerationId</span>
|
||||
<span class="tb-count text-muted">@_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s")</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Severity</th>
|
||||
<th>Predicate script</th>
|
||||
<th>Historize</th>
|
||||
<th>Retain</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in _alarms)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@a.Name</span></td>
|
||||
<td><span class="mono">@a.EquipmentId</span></td>
|
||||
<td><span class="chip chip-idle">@a.AlarmType</span></td>
|
||||
<td class="num">@a.Severity <small class="text-muted">@SeverityBand(a.Severity)</small></td>
|
||||
<td><span class="mono">@(ScriptName(a.PredicateScriptId))</span></td>
|
||||
<td>
|
||||
@if (a.HistorizeToAveva) { <span class="chip chip-ok">Aveva</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (a.Retain) { <span class="chip chip-ok">yes</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (a.Enabled) { <span class="chip chip-ok">enabled</span> }
|
||||
else { <span class="chip chip-idle">disabled</span> }
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(a.ScriptedAlarmId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>New scripted alarm</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Equipment ID</label>
|
||||
<input class="form-control form-control-sm" @bind="_draft.EquipmentId"
|
||||
placeholder="e.g. eq-abc123 — logical FK to Equipment"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Alarm name <small class="text-muted">(operator-facing display name)</small></label>
|
||||
<input class="form-control form-control-sm" @bind="_draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Alarm type <small class="text-muted">(OPC UA Part 9 subtype)</small></label>
|
||||
<select class="form-select form-select-sm" @bind="_draft.AlarmType">
|
||||
@foreach (var t in AlarmTypes)
|
||||
{
|
||||
<option value="@t">@t</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">
|
||||
Severity <small class="text-muted">1–1000 (Low <250, Med <500, High <750, Critical 1000)</small>
|
||||
</label>
|
||||
<input type="number" min="1" max="1000" class="form-control form-control-sm" @bind="_draft.Severity"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Predicate script <small class="text-muted">(returns bool)</small></label>
|
||||
<select class="form-select form-select-sm" @bind="_draft.PredicateScriptId">
|
||||
<option value="">— select script —</option>
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<option value="@s.ScriptId">@s.Name (@s.ScriptId)</option>
|
||||
}
|
||||
</select>
|
||||
@if (_scripts.Count == 0)
|
||||
{
|
||||
<div class="form-text s-warn">No scripts in this draft — create one in the Scripts tab first.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">
|
||||
Message template
|
||||
<small class="text-muted">Use <code class="mono">{EquipmentPath/TagName}</code> tokens — resolved at alarm emission time</small>
|
||||
</label>
|
||||
<input class="form-control form-control-sm" @bind="_draft.MessageTemplate"
|
||||
placeholder='e.g. "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}"'/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-2">
|
||||
<input type="checkbox" class="form-check-input" id="salHistorize" @bind="_draft.HistorizeToAveva"/>
|
||||
<label class="form-check-label" for="salHistorize">
|
||||
Historize to Aveva <small class="text-muted">(SQLite store-and-forward sink)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-2">
|
||||
<input type="checkbox" class="form-check-input" id="salRetain" @bind="_draft.Retain"/>
|
||||
<label class="form-check-label" for="salRetain">
|
||||
Retain <small class="text-muted">(keep condition visible after clear while un-acked)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<section class="panel notice mt-3"><span class="s-bad">@_error</span></section>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@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<ScriptedAlarm> _alarms = [];
|
||||
private List<Script> _scripts = [];
|
||||
private string? _error;
|
||||
|
||||
private ScriptedAlarm _draft = NewDraft();
|
||||
|
||||
private static ScriptedAlarm NewDraft() => new()
|
||||
{
|
||||
ScriptedAlarmId = string.Empty,
|
||||
EquipmentId = string.Empty,
|
||||
Name = string.Empty,
|
||||
AlarmType = "AlarmCondition",
|
||||
Severity = 500,
|
||||
MessageTemplate = string.Empty,
|
||||
PredicateScriptId = string.Empty,
|
||||
HistorizeToAveva = true,
|
||||
Retain = true,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_draft = NewDraft();
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_draft.EquipmentId) ||
|
||||
string.IsNullOrWhiteSpace(_draft.Name) ||
|
||||
string.IsNullOrWhiteSpace(_draft.PredicateScriptId))
|
||||
{
|
||||
_error = "Equipment ID, Name, and Predicate script are required.";
|
||||
return;
|
||||
}
|
||||
if (_draft.Severity is < 1 or > 1000)
|
||||
{
|
||||
_error = "Severity must be between 1 and 1000.";
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await AlarmSvc.AddAsync(
|
||||
GenerationId,
|
||||
_draft.EquipmentId, _draft.Name, _draft.AlarmType,
|
||||
_draft.Severity, _draft.MessageTemplate, _draft.PredicateScriptId,
|
||||
_draft.HistorizeToAveva, _draft.Retain,
|
||||
CancellationToken.None);
|
||||
_showForm = false;
|
||||
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(string id)
|
||||
{
|
||||
await AlarmSvc.DeleteAsync(GenerationId, id, CancellationToken.None);
|
||||
_alarms = await AlarmSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private string ScriptName(string scriptId)
|
||||
{
|
||||
var s = _scripts.FirstOrDefault(x => x.ScriptId == scriptId);
|
||||
return s is not null ? s.Name : scriptId;
|
||||
}
|
||||
|
||||
private static string SeverityBand(int s) => s switch
|
||||
{
|
||||
<= 250 => "(Low)",
|
||||
<= 500 => "(Medium)",
|
||||
<= 750 => "(High)",
|
||||
_ => "(Critical)",
|
||||
};
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||
@inject ScriptService ScriptSvc
|
||||
@inject ScriptTestHarnessService Harness
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="panel-head mb-0">Scripts</h4>
|
||||
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/monaco-loader.js"></script>
|
||||
|
||||
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||
else if (_scripts.Count == 0 && _editing is null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">No scripts yet in this draft.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="list-group">
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||
@onclick="() => Open(s)">
|
||||
<strong>@s.Name</strong>
|
||||
<div class="small text-muted mono">@s.ScriptId</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
@if (_editing is not null)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||
<div>
|
||||
@if (!_isNew)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="_editing.Name"/>
|
||||
</div>
|
||||
<label class="form-label">Source</label>
|
||||
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||
</div>
|
||||
|
||||
@if (_dependencies is not null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<strong>Inferred reads</strong>
|
||||
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var r in _dependencies.Reads) { <li><span class="mono">@r</span></li> }
|
||||
</ul>
|
||||
}
|
||||
<strong>Inferred writes</strong>
|
||||
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var w in _dependencies.Writes) { <li><span class="mono">@w</span></li> }
|
||||
</ul>
|
||||
}
|
||||
@if (_dependencies.Rejections.Count > 0)
|
||||
{
|
||||
<section class="panel notice mt-2">
|
||||
<strong>Non-literal paths rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var r in _dependencies.Rejections) { <li><span class="s-bad">@r.Message</span></li> }
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<strong>Harness result:</strong> <span class="chip chip-idle">@_testResult.Outcome</span>
|
||||
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||
{
|
||||
<div>Output: <span class="mono">@(_testResult.Output?.ToString() ?? "null")</span></div>
|
||||
@if (_testResult.Writes.Count > 0)
|
||||
{
|
||||
<div class="mt-1"><strong>Writes:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var kv in _testResult.Writes) { <li><span class="mono">@kv.Key</span> = <span class="mono">@(kv.Value?.ToString() ?? "null")</span></li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_testResult.Errors.Count > 0)
|
||||
{
|
||||
<section class="panel notice mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div><span class="s-warn">@e</span></div> }
|
||||
</section>
|
||||
}
|
||||
@if (_testResult.LogEvents.Count > 0)
|
||||
{
|
||||
<div class="mt-2"><strong>Script log output:</strong>
|
||||
<ul class="small mb-0">
|
||||
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@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<Script> _scripts = [];
|
||||
private Script? _editing;
|
||||
private DependencyExtractionResult? _dependencies;
|
||||
private ScriptTestResult? _testResult;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void Open(Script s)
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||
SourceHash = s.SourceHash, Language = s.Language,
|
||||
};
|
||||
_isNew = false;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
GenerationId = GenerationId, ScriptId = "",
|
||||
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||
};
|
||||
_isNew = true;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (_isNew)
|
||||
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
else
|
||||
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_isNew = false;
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (_editing is null || _isNew) return;
|
||||
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||
_editing = null;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void PreviewDependencies()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||
}
|
||||
|
||||
private async Task RunHarnessAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_harnessBusy = true;
|
||||
try
|
||||
{
|
||||
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||
foreach (var read in _dependencies.Reads)
|
||||
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||
}
|
||||
finally { _harnessBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,373 +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
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
@inject TagService TagSvc
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject EquipmentService EquipmentSvc
|
||||
|
||||
@*
|
||||
#155 — interactive Tag CRUD scoped to a draft generation. Conditional editor: when the
|
||||
selected DriverInstance is Modbus, the address input switches to ModbusAddressEditor (#145)
|
||||
so users get the live-parse preview + grammar validation. Other driver types fall back to
|
||||
a generic JSON textarea, matching the DriversTab pattern from #147.
|
||||
*@
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>Tags (draft gen @GenerationId)</span>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add tag</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<select class="form-select form-select-sm tb-state" @bind="_filterDriverId" @bind:after="ReloadAsync">
|
||||
<option value="">— all drivers —</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
@if (_tags is not null) { <span class="tb-count">@_tags.Count tags</span> }
|
||||
</div>
|
||||
@if (_tags is null) { <p class="p-3">Loading…</p> }
|
||||
else if (_tags.Count == 0 && !_showForm) { <p class="p-3 text-muted">No tags in this filter.</p> }
|
||||
else if (_tags.Count > 0)
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Driver</th><th>Equipment</th><th>DataType</th><th>Access</th><th>TagConfig</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><span class="mono">@t.DriverInstanceId</span></td>
|
||||
<td>@(t.EquipmentId ?? "—")</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@t.AccessLevel</td>
|
||||
<td class="mono" style="max-width:18rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">@t.TagConfig</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => StartEdit(t)">Edit</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.TagRowId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head">@(_editMode ? "Edit tag" : "New tag")</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstance</label>
|
||||
<select class="form-select" @bind="_draft.DriverInstanceId" @bind:after="OnDriverChanged">
|
||||
<option value="">— select driver —</option>
|
||||
@if (_drivers is not null)
|
||||
{
|
||||
@foreach (var d in _drivers) { <option value="@d.DriverInstanceId">@d.Name (@d.DriverType)</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Equipment (optional)</label>
|
||||
<select class="form-select" @bind="_draft.EquipmentId">
|
||||
<option value="">— none (folder-path mode) —</option>
|
||||
@if (_equipment is not null)
|
||||
{
|
||||
@foreach (var e in _equipment) { <option value="@e.EquipmentId">@e.Name</option> }
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DataType</label>
|
||||
<input class="form-control" @bind="_draft.DataType" placeholder="Boolean / Int32 / Float / etc."/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">AccessLevel</label>
|
||||
<select class="form-select" @bind="_draft.AccessLevel">
|
||||
@foreach (var a in Enum.GetValues<TagAccessLevel>())
|
||||
{
|
||||
<option value="@a">@a</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="_draft.WriteIdempotent"/>
|
||||
<label class="form-check-label">WriteIdempotent</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@if (_isModbus)
|
||||
{
|
||||
<ModbusAddressEditor @bind-AddressString="_modbusAddress"
|
||||
@bind-AddressString:after="OnAddressChanged"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">TagConfig (driver-specific JSON or string)</label>
|
||||
<textarea class="form-control font-monospace" rows="3" @bind="_draft.TagConfig"
|
||||
placeholder='@("{\"address\": ...}")'></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* #156 — advanced Modbus fields. Collapsed by default; the basic form covers the
|
||||
typical edit workflow. Expander surfaces Deadband (#141) / UnitId override (#142) /
|
||||
CoalesceProhibited (#143) for the multi-slave / noisy-analog / protected-hole
|
||||
deployments. Save-side flushes these into TagConfig as a structured JSON object
|
||||
that ModbusTagDto's BuildTag honours alongside the address string. *@
|
||||
@if (_isModbus)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-sm btn-link p-0"
|
||||
@onclick="() => _showAdvanced = !_showAdvanced">
|
||||
@(_showAdvanced ? "▼ Advanced" : "▶ Advanced") (Deadband / UnitId override / CoalesceProhibited)
|
||||
</button>
|
||||
</div>
|
||||
@if (_showAdvanced)
|
||||
{
|
||||
<div class="row g-3 mt-1 ps-3 border-start">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Deadband
|
||||
<span class="text-muted">(numeric scalar types only)</span>
|
||||
</label>
|
||||
<input type="number" step="any" class="form-control form-control-sm"
|
||||
@bind="_advancedDeadband" @bind:after="OnAdvancedChanged"
|
||||
placeholder="e.g. 0.5"/>
|
||||
<div class="form-text">Suppress publishes when |new - last| < threshold.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">UnitId override
|
||||
<span class="text-muted">(0–255, blank = use driver default)</span>
|
||||
</label>
|
||||
<input type="number" min="0" max="255" class="form-control form-control-sm"
|
||||
@bind="_advancedUnitId" @bind:after="OnAdvancedChanged"
|
||||
placeholder="leave blank for driver-level"/>
|
||||
<div class="form-text">Per-tag MBAP unit ID. Required when fronting a multi-slave gateway.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">CoalesceProhibited</label>
|
||||
<div class="form-check mt-1">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
@bind="_advancedCoalesceProhibited" @bind:after="OnAdvancedChanged"/>
|
||||
<label class="form-check-label">Read in isolation (#143)</label>
|
||||
</div>
|
||||
<div class="form-text">Use when surrounding registers are write-only or fault on read.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_error is not null) { <section class="panel notice mt-3"><span class="s-bad">@_error</span></section> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="Cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<Tag>? _tags;
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Equipment>? _equipment;
|
||||
private string _filterDriverId = string.Empty;
|
||||
|
||||
private bool _showForm;
|
||||
private bool _editMode;
|
||||
private Tag _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
private bool _isModbus;
|
||||
private string? _modbusAddress;
|
||||
|
||||
// #156 — advanced Modbus fields. Bound separately from _draft.TagConfig because they
|
||||
// round-trip through a structured JSON shape, not a single string. Synced into TagConfig
|
||||
// by OnAdvancedChanged / OnAddressChanged (whichever fires).
|
||||
private bool _showAdvanced;
|
||||
private double? _advancedDeadband;
|
||||
private byte? _advancedUnitId;
|
||||
private bool _advancedCoalesceProhibited;
|
||||
|
||||
private static Tag NewBlankDraft() => new()
|
||||
{
|
||||
TagId = string.Empty, DriverInstanceId = string.Empty, Name = string.Empty,
|
||||
DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = string.Empty,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_tags = await TagSvc.ListAsync(GenerationId,
|
||||
string.IsNullOrWhiteSpace(_filterDriverId) ? null : _filterDriverId,
|
||||
equipmentId: null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_editMode = false;
|
||||
_modbusAddress = null;
|
||||
_isModbus = false;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
ResetAdvanced();
|
||||
}
|
||||
|
||||
private void ResetAdvanced()
|
||||
{
|
||||
_showAdvanced = false;
|
||||
_advancedDeadband = null;
|
||||
_advancedUnitId = null;
|
||||
_advancedCoalesceProhibited = false;
|
||||
}
|
||||
|
||||
private void StartEdit(Tag row)
|
||||
{
|
||||
_draft = new Tag
|
||||
{
|
||||
TagRowId = row.TagRowId,
|
||||
GenerationId = row.GenerationId,
|
||||
TagId = row.TagId,
|
||||
DriverInstanceId = row.DriverInstanceId,
|
||||
DeviceId = row.DeviceId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
Name = row.Name,
|
||||
FolderPath = row.FolderPath,
|
||||
DataType = row.DataType,
|
||||
AccessLevel = row.AccessLevel,
|
||||
WriteIdempotent = row.WriteIdempotent,
|
||||
PollGroupId = row.PollGroupId,
|
||||
TagConfig = row.TagConfig,
|
||||
};
|
||||
_editMode = true;
|
||||
OnDriverChanged();
|
||||
// Try to extract addressString + advanced fields from existing JSON config so the
|
||||
// form pre-fills correctly when an operator hits Edit on an existing row.
|
||||
ResetAdvanced();
|
||||
if (_isModbus) HydrateModbusFromTagConfig(row.TagConfig);
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private void HydrateModbusFromTagConfig(string tagConfig)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("addressString", out var addr) && addr.ValueKind == JsonValueKind.String)
|
||||
_modbusAddress = addr.GetString();
|
||||
if (root.TryGetProperty("deadband", out var db) && db.ValueKind is JsonValueKind.Number)
|
||||
_advancedDeadband = db.GetDouble();
|
||||
if (root.TryGetProperty("unitId", out var uid) && uid.ValueKind is JsonValueKind.Number)
|
||||
_advancedUnitId = uid.GetByte();
|
||||
if (root.TryGetProperty("coalesceProhibited", out var cp) && cp.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
_advancedCoalesceProhibited = cp.GetBoolean();
|
||||
|
||||
// Auto-expand the advanced panel when any of those fields was actually set so
|
||||
// operators see immediately what's been configured.
|
||||
if (_advancedDeadband.HasValue || _advancedUnitId.HasValue || _advancedCoalesceProhibited)
|
||||
_showAdvanced = true;
|
||||
}
|
||||
catch { /* Malformed JSON falls back to empty advanced state. */ }
|
||||
}
|
||||
|
||||
private void OnDriverChanged()
|
||||
{
|
||||
var driver = _drivers?.FirstOrDefault(d => d.DriverInstanceId == _draft.DriverInstanceId);
|
||||
_isModbus = driver is not null
|
||||
&& string.Equals(driver.DriverType, "Modbus", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void OnAddressChanged() => RefreshTagConfigJson();
|
||||
private void OnAdvancedChanged() => RefreshTagConfigJson();
|
||||
|
||||
/// <summary>
|
||||
/// Re-serializes the current address + advanced fields into TagConfig as a structured
|
||||
/// JSON object. ModbusTagDto's BuildTag honours every field — addressString drives
|
||||
/// the parser, while the structured bits (deadband / unitId / coalesceProhibited)
|
||||
/// pass through directly. Fields with default / empty values are omitted from the
|
||||
/// JSON to keep diffs in the existing draft-diff viewer clean.
|
||||
/// </summary>
|
||||
private void RefreshTagConfigJson()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_modbusAddress)
|
||||
&& !_advancedDeadband.HasValue
|
||||
&& !_advancedUnitId.HasValue
|
||||
&& !_advancedCoalesceProhibited)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object?>();
|
||||
if (!string.IsNullOrWhiteSpace(_modbusAddress)) payload["addressString"] = _modbusAddress;
|
||||
if (_advancedDeadband.HasValue) payload["deadband"] = _advancedDeadband.Value;
|
||||
if (_advancedUnitId.HasValue) payload["unitId"] = _advancedUnitId.Value;
|
||||
if (_advancedCoalesceProhibited) payload["coalesceProhibited"] = true;
|
||||
|
||||
_draft.TagConfig = JsonSerializer.Serialize(payload);
|
||||
}
|
||||
|
||||
private void Cancel()
|
||||
{
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_draft.Name) || string.IsNullOrWhiteSpace(_draft.DriverInstanceId))
|
||||
{
|
||||
_error = "Name and DriverInstance are required.";
|
||||
return;
|
||||
}
|
||||
if (_editMode)
|
||||
await TagSvc.UpdateAsync(_draft, CancellationToken.None);
|
||||
else
|
||||
await TagSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
_showForm = false;
|
||||
_editMode = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await TagSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||
refresh-required modal instead of clobbering their work.
|
||||
</section>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>UNS Areas</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
|
||||
</div>
|
||||
|
||||
@if (_areas is null) { <p class="p-3">Loading…</p> }
|
||||
else if (_areas.Count == 0) { <p class="p-3 text-muted">No areas yet.</p> }
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||
@ondragover:preventDefault
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><span class="mono">@a.UnsAreaId</span></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showAreaForm)
|
||||
{
|
||||
<div class="p-3 border-top">
|
||||
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control form-control-sm" @bind="_newAreaName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>UNS Lines</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
|
||||
</div>
|
||||
|
||||
@if (_lines is null) { <p class="p-3">Loading…</p> }
|
||||
else if (_lines.Count == 0) { <p class="p-3 text-muted">No lines yet.</p> }
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr draggable="true"
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><span class="mono">@l.UnsLineId</span></td>
|
||||
<td><span class="mono">@l.UnsAreaId</span></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_showLineForm && _areas is not null)
|
||||
{
|
||||
<div class="p-3 border-top">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select form-select-sm" @bind="_newLineAreaId">
|
||||
@foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2"><label class="form-label">Name</label><input class="form-control form-control-sm" @bind="_newLineName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Preview / confirm modal for a pending drag-drop move *@
|
||||
@if (_pendingPreview is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm UNS move</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||
<p class="text-muted small">
|
||||
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<section class="panel notice small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||
@if (_conflictMessage is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_conflictMessage</p>
|
||||
<p class="small text-muted">
|
||||
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<UnsArea>? _areas;
|
||||
private List<UnsLine>? _lines;
|
||||
private bool _showAreaForm;
|
||||
private bool _showLineForm;
|
||||
private string _newAreaName = string.Empty;
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
private string? _dragLineId;
|
||||
private string? _hoverAreaId;
|
||||
private UnsImpactPreview? _pendingPreview;
|
||||
private UnsMoveOperation? _pendingMove;
|
||||
private bool _committing;
|
||||
private string? _conflictMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None);
|
||||
_lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddAreaAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newAreaName)) return;
|
||||
await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None);
|
||||
_newAreaName = string.Empty;
|
||||
_showAreaForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task AddLineAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return;
|
||||
await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None);
|
||||
_newLineName = string.Empty;
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||
|
||||
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||
{
|
||||
var lineId = _dragLineId;
|
||||
_hoverAreaId = null;
|
||||
_dragLineId = null;
|
||||
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||
|
||||
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||
|
||||
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: ClusterId,
|
||||
TargetClusterId: ClusterId,
|
||||
SourceLineId: lineId,
|
||||
TargetAreaId: targetAreaId);
|
||||
try
|
||||
{
|
||||
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
_pendingMove = move;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMove()
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmMoveAsync()
|
||||
{
|
||||
if (_pendingPreview is null || _pendingMove is null) return;
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
await UnsSvc.MoveLineAsync(
|
||||
GenerationId,
|
||||
_pendingPreview.RevisionToken,
|
||||
_pendingMove.SourceLineId!,
|
||||
_pendingMove.TargetAreaId!,
|
||||
CancellationToken.None);
|
||||
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (DraftRevisionConflictException ex)
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
_conflictMessage = ex.Message;
|
||||
}
|
||||
finally { _committing = false; }
|
||||
}
|
||||
|
||||
private async Task ReloadAfterConflict()
|
||||
{
|
||||
_conflictMessage = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject VirtualTagService VirtualTagSvc
|
||||
@inject ScriptService ScriptSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="panel-head mb-0">Virtual Tags</h4>
|
||||
<small class="text-muted">Computed tags driven by C# scripts. Appear in the Equipment browse tree alongside driver tags.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New virtual tag</button>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted">Loading…</p>
|
||||
}
|
||||
else if (_tags.Count == 0 && !_showForm)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">No virtual tags yet in this draft.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_tags.Count > 0)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>Virtual tags in draft gen @GenerationId</span>
|
||||
<span class="tb-count text-muted">@_tags.Count tag@(_tags.Count == 1 ? "" : "s")</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>DataType</th>
|
||||
<th>Script</th>
|
||||
<th>Triggers</th>
|
||||
<th>Historize</th>
|
||||
<th>Enabled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@t.Name</span></td>
|
||||
<td><span class="mono">@t.EquipmentId</span></td>
|
||||
<td>@t.DataType</td>
|
||||
<td><span class="mono">@(ScriptName(t.ScriptId))</span></td>
|
||||
<td>
|
||||
@if (t.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
|
||||
@if (t.TimerIntervalMs.HasValue) { <span class="chip chip-idle">@t.TimerIntervalMs ms</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (t.Historize) { <span class="chip chip-ok">yes</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (t.Enabled) { <span class="chip chip-ok">enabled</span> }
|
||||
else { <span class="chip chip-idle">disabled</span> }
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => ToggleEnabledAsync(t)">
|
||||
@(t.Enabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(t.VirtualTagId)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise mt-3" style="animation-delay:.08s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<strong>New virtual tag</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Equipment ID</label>
|
||||
<input class="form-control form-control-sm" @bind="_draft.EquipmentId"
|
||||
placeholder="e.g. eq-abc123 — logical FK to Equipment"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Name <small class="text-muted">(browse name, unique in Equipment)</small></label>
|
||||
<input class="form-control form-control-sm" @bind="_draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DataType</label>
|
||||
<select class="form-select form-select-sm" @bind="_draft.DataType">
|
||||
@foreach (var dt in DataTypes)
|
||||
{
|
||||
<option value="@dt">@dt</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Script</label>
|
||||
<select class="form-select form-select-sm" @bind="_draft.ScriptId">
|
||||
<option value="">— select script —</option>
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<option value="@s.ScriptId">@s.Name (@s.ScriptId)</option>
|
||||
}
|
||||
</select>
|
||||
@if (_scripts.Count == 0)
|
||||
{
|
||||
<div class="form-text s-warn">No scripts in this draft — create one in the Scripts tab first.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mt-2">
|
||||
<input type="checkbox" class="form-check-input" id="vtChangeTriggered" @bind="_draft.ChangeTriggered"/>
|
||||
<label class="form-check-label" for="vtChangeTriggered">
|
||||
Change-triggered <small class="text-muted">(re-evaluate on any input change)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Timer interval (ms) <small class="text-muted">leave blank to disable timer</small></label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_timerMs" placeholder="e.g. 5000"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="vtHistorize" @bind="_draft.Historize"/>
|
||||
<label class="form-check-label" for="vtHistorize">
|
||||
Historize <small class="text-muted">(route evaluations to IHistoryWriter)</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<section class="panel notice mt-3"><span class="s-bad">@_error</span></section>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private static readonly string[] DataTypes =
|
||||
["Boolean", "Int32", "Int64", "Float32", "Float64", "String", "DateTime"];
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private bool _showForm;
|
||||
private List<VirtualTag> _tags = [];
|
||||
private List<Script> _scripts = [];
|
||||
private string? _error;
|
||||
|
||||
// Draft form state (VirtualTag doesn't have update besides Enabled — add-only form)
|
||||
private VirtualTag _draft = NewDraft();
|
||||
private int? _timerMs;
|
||||
|
||||
private static VirtualTag NewDraft() => new()
|
||||
{
|
||||
VirtualTagId = string.Empty,
|
||||
EquipmentId = string.Empty,
|
||||
Name = string.Empty,
|
||||
DataType = "Float32",
|
||||
ScriptId = string.Empty,
|
||||
ChangeTriggered = true,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_draft = NewDraft();
|
||||
_timerMs = null;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_draft.EquipmentId) ||
|
||||
string.IsNullOrWhiteSpace(_draft.Name) ||
|
||||
string.IsNullOrWhiteSpace(_draft.ScriptId))
|
||||
{
|
||||
_error = "Equipment ID, Name, and Script are required.";
|
||||
return;
|
||||
}
|
||||
if (!_draft.ChangeTriggered && _timerMs is null)
|
||||
{
|
||||
_error = "At least one trigger must be set (change-triggered or timer).";
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await VirtualTagSvc.AddAsync(
|
||||
GenerationId,
|
||||
_draft.EquipmentId, _draft.Name, _draft.DataType, _draft.ScriptId,
|
||||
_draft.ChangeTriggered, _timerMs, _draft.Historize,
|
||||
CancellationToken.None);
|
||||
_showForm = false;
|
||||
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(string id)
|
||||
{
|
||||
await VirtualTagSvc.DeleteAsync(GenerationId, id, CancellationToken.None);
|
||||
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ToggleEnabledAsync(VirtualTag t)
|
||||
{
|
||||
await VirtualTagSvc.UpdateEnabledAsync(GenerationId, t.VirtualTagId, !t.Enabled, CancellationToken.None);
|
||||
_tags = await VirtualTagSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private string ScriptName(string scriptId)
|
||||
{
|
||||
var s = _scripts.FirstOrDefault(x => x.ScriptId == scriptId);
|
||||
return s is not null ? s.Name : scriptId;
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
@page "/drivers/focas/{InstanceId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject FocasDriverDetailService DetailSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">FOCAS driver <span class="mono">@InstanceId</span></h4>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<section class="panel notice">
|
||||
No FOCAS driver instance with id <span class="mono">@InstanceId</span> was found.
|
||||
<div class="small text-muted mt-1">
|
||||
Either the id is wrong, or the instance's <span class="mono">DriverType</span> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Name</div>
|
||||
<div class="agg-value">@_detail.Instance.Name</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Cluster</div>
|
||||
<div class="agg-value mono">@_detail.Instance.ClusterId</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Namespace</div>
|
||||
<div class="agg-value mono">@_detail.Instance.NamespaceId</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Enabled</div>
|
||||
<div class="agg-value">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_detail.ParseError is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.08s">
|
||||
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
|
||||
<div class="small text-muted mt-1">
|
||||
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
else if (_detail.Config is not null)
|
||||
{
|
||||
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Devices</div>
|
||||
<p class="text-muted" style="padding:.75rem 1rem 0">No devices configured.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Devices</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _detail.Config.Devices)
|
||||
{
|
||||
<tr>
|
||||
<td class="mono">@d.HostAddress</td>
|
||||
<td>@(d.DeviceName ?? "—")</td>
|
||||
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<p class="text-muted" style="padding:.75rem 1rem 0">No tags configured.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Tags</div>
|
||||
<div class="toolbar">
|
||||
<span class="spacer"></span>
|
||||
<span class="tb-count">@_detail.Config.Tags.Count tag(s)</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Config.Tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td class="mono">@t.DeviceHostAddress</td>
|
||||
<td class="mono">@t.Address</td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@(t.Writable ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="card-grid rise" style="animation-delay:.20s">
|
||||
<div class="metric-card">
|
||||
<div class="panel-head">Driver behaviour</div>
|
||||
<div class="kv">
|
||||
<span class="k">Probe</span>
|
||||
<span class="v">
|
||||
@if (_detail.Config.Probe is { } probe)
|
||||
{
|
||||
<span class="chip @(probe.Enabled ? "chip-ok" : "chip-idle")">@(probe.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">default (enabled)</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Alarm projection</span>
|
||||
<span class="v">
|
||||
@if (_detail.Config.AlarmProjection is { } ap)
|
||||
{
|
||||
<span class="chip @(ap.Enabled ? "chip-ok" : "chip-idle")">@(ap.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<span class="k">Handle recycling</span>
|
||||
<span class="v">
|
||||
@if (_detail.Config.HandleRecycle is { } hr)
|
||||
{
|
||||
<span class="chip @(hr.Enabled ? "chip-warn" : "chip-idle")">@(hr.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_detail.HostStatuses.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.26s">
|
||||
No <span class="mono">DriverHostStatus</span> rows yet for this instance. The Server publishes its first
|
||||
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.26s">
|
||||
<div class="panel-head">Host status</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="num" title="Consecutive failures">Fail#</th>
|
||||
<th>Breaker last opened</th>
|
||||
<th>Last recycled</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.HostStatuses)
|
||||
{
|
||||
<tr>
|
||||
<td class="mono">@r.NodeId</td>
|
||||
<td>@r.HostName</td>
|
||||
<td><span class="chip @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="num small">@r.ConsecutiveFailures</td>
|
||||
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
|
||||
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
|
||||
<td class="small @(IsStale(r) ? "s-warn" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="panel rise" style="animation-delay:.32s">
|
||||
<div class="panel-head">Raw DriverConfig JSON</div>
|
||||
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
</section>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
Docs: <span class="mono">docs/drivers/FOCAS.md</span> (getting started) · <span class="mono">docs/v2/focas-deployment.md</span> (NSSM + pipe ACL) · <span class="mono">docs/drivers/FOCAS-Test-Fixture.md</span> (test coverage).
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
private FocasDriverDetail? _detail;
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try { _detail = await DetailSvc.GetAsync(InstanceId, CancellationToken.None); }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private static bool IsStale(FocasHostStatusRow r) =>
|
||||
DateTime.UtcNow - r.LastSeenUtc > TimeSpan.FromSeconds(30);
|
||||
|
||||
private static string StateBadge(string state) => state switch
|
||||
{
|
||||
"Running" => "chip-ok",
|
||||
"Faulted" => "chip-bad",
|
||||
"Starting" => "chip-idle",
|
||||
"Stopped" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatUtc(DateTime? utc) =>
|
||||
utc is null ? "—" : utc.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
|
||||
private static string FormatAge(DateTime utc)
|
||||
{
|
||||
var age = DateTime.UtcNow - utc;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 48) return $"{(int)age.TotalHours}h ago";
|
||||
return utc.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
@page "/fleet"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Fleet status</h4>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice" style="animation-delay:.02s">
|
||||
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Nodes</div>
|
||||
<div class="agg-value numeric">@_rows.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Applied</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Applied")</div>
|
||||
</div>
|
||||
<div class="agg-card caution">
|
||||
<div class="agg-label">Stale</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => IsStale(r))</div>
|
||||
</div>
|
||||
<div class="agg-card alert">
|
||||
<div class="agg-label">Failed</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.Status == "Failed")</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Nodes</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Cluster</th>
|
||||
<th class="num">Generation</th>
|
||||
<th>Status</th>
|
||||
<th>Last applied</th>
|
||||
<th>Last seen</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.NodeId</span></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td class="num">@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td>
|
||||
<span class="chip @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
</td>
|
||||
<td>@FormatAge(r.AppliedAt)</td>
|
||||
<td>@FormatAge(r.SeenAt)</td>
|
||||
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||
// the most recent published state without polling ahead of the broadcaster.
|
||||
private const int RefreshIntervalSeconds = 5;
|
||||
|
||||
private List<FleetNodeRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
// Order on the join's plain columns before projecting into FleetNodeRow —
|
||||
// EF Core cannot translate OrderBy over a property of a constructed record.
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n })
|
||||
.OrderBy(x => x.n.ClusterId)
|
||||
.ThenBy(x => x.s.NodeId)
|
||||
.Select(x => new FleetNodeRow(
|
||||
x.s.NodeId, x.n.ClusterId, x.s.CurrentGenerationId,
|
||||
x.s.LastAppliedStatus != null ? x.s.LastAppliedStatus.ToString() : null,
|
||||
x.s.LastAppliedError, x.s.LastAppliedAt, x.s.LastSeenAt))
|
||||
.ToListAsync();
|
||||
_rows = rows;
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStale(FleetNodeRow r)
|
||||
{
|
||||
if (r.SeenAt is null) return true;
|
||||
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||
{
|
||||
"Failed" => "table-danger",
|
||||
_ when IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StatusBadge(string? status) => status switch
|
||||
{
|
||||
"Applied" => "chip-ok",
|
||||
"Failed" => "chip-bad",
|
||||
"Applying" => "chip-idle",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime? t)
|
||||
{
|
||||
if (t is null) return "—";
|
||||
var age = DateTime.UtcNow - t.Value;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
internal sealed record FleetNodeRow(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
@page "/"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Fleet overview</h4>
|
||||
</div>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Loading</div>
|
||||
<div style="padding:1.4rem;color:var(--ink-faint);font-style:italic">Loading fleet…</div>
|
||||
</section>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.02s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Clusters</div>
|
||||
<div class="agg-value numeric">@_clusters.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Active drafts</div>
|
||||
<div class="agg-value numeric">@_activeDraftCount</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Published generations</div>
|
||||
<div class="agg-value numeric">@_publishedCount</div>
|
||||
</div>
|
||||
<div class="agg-card @(_disabledCount > 0 ? "caution" : "")">
|
||||
<div class="agg-label">Disabled clusters</div>
|
||||
<div class="agg-value numeric">@_disabledCount</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Clusters</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cluster ID</th>
|
||||
<th>Name</th>
|
||||
<th>Enterprise / Site</th>
|
||||
<th>Redundancy</th>
|
||||
<th>State</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr @onclick="@(() => Nav.NavigateTo($"/clusters/{c.ClusterId}"))">
|
||||
<td class="mono">@c.ClusterId</td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td class="mono">@c.RedundancyMode</td>
|
||||
<td>
|
||||
@if (c.Enabled)
|
||||
{
|
||||
<span class="chip chip-ok">enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-idle">disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td><a href="/clusters/@c.ClusterId">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
private int _activeDraftCount;
|
||||
private int _publishedCount;
|
||||
private int _disabledCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
_disabledCount = _clusters.Count(c => !c.Enabled);
|
||||
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
var gens = await GenerationSvc.ListRecentAsync(c.ClusterId, 50, CancellationToken.None);
|
||||
_activeDraftCount += gens.Count(g => g.Status.ToString() == "Draft");
|
||||
_publishedCount += gens.Count(g => g.Status.ToString() == "Published");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
@page "/hosts"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Driver host status</h4>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
|
||||
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
|
||||
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</section>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<section class="panel notice" style="animation-delay:.08s">
|
||||
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <span class="mono">IHostConnectivityProbe</span>.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="agg-grid rise" style="animation-delay:.08s">
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Hosts</div>
|
||||
<div class="agg-value numeric">@_rows.Count</div>
|
||||
</div>
|
||||
<div class="agg-card">
|
||||
<div class="agg-label">Running</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
|
||||
</div>
|
||||
<div class="agg-card caution">
|
||||
<div class="agg-label">Stale</div>
|
||||
<div class="agg-value numeric">@_rows.Count(HostStatusService.IsStale)</div>
|
||||
</div>
|
||||
<div class="agg-card alert">
|
||||
<div class="agg-label">Faulted</div>
|
||||
<div class="agg-value numeric">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (_rows.Any(HostStatusService.IsFlagged))
|
||||
{
|
||||
var flaggedCount = _rows.Count(HostStatusService.IsFlagged);
|
||||
<section class="panel notice rise" style="animation-delay:.14s">
|
||||
<strong>@flaggedCount host@(flaggedCount == 1 ? "" : "s")</strong>
|
||||
reporting ≥ @HostStatusService.FailureFlagThreshold consecutive failures — circuit breaker
|
||||
may trip soon. Inspect the resilience columns below to locate.
|
||||
</section>
|
||||
}
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">Cluster: <span class="mono">@cluster.Key</span></div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="num" title="Consecutive failures — resets when a call succeeds or the breaker closes">Fail#</th>
|
||||
<th class="num" title="In-flight capability calls (bulkhead-depth proxy)">In-flight</th>
|
||||
<th>Breaker opened</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.NodeId</span></td>
|
||||
<td><span class="mono">@r.DriverInstanceId</span></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="chip @StateBadge(r.State)">@r.State</span>
|
||||
@if (HostStatusService.IsStale(r))
|
||||
{
|
||||
<span class="chip chip-warn ms-1">Stale</span>
|
||||
}
|
||||
@if (HostStatusService.IsFlagged(r))
|
||||
{
|
||||
<span class="chip chip-bad ms-1" title="≥ @HostStatusService.FailureFlagThreshold consecutive failures">Flagged</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num small @(HostStatusService.IsFlagged(r) ? "s-bad fw-bold" : "")">
|
||||
@r.ConsecutiveFailures
|
||||
</td>
|
||||
<td class="num small">@r.CurrentBulkheadDepth</td>
|
||||
<td class="small">
|
||||
@(r.LastCircuitBreakerOpenUtc is null ? "—" : FormatAge(r.LastCircuitBreakerOpenUtc.Value))
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "s-warn" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
|
||||
// produces stale-looking rows mid-cycle.
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<HostStatusRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
// Phase 6.1 Stream E.2 — subscribe to FleetStatusHub so resilience deltas upsert the
|
||||
// matching row without waiting for the next RefreshIntervalSeconds tick. The 10 s
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; polling refresh is the fallback. Swallow connect errors
|
||||
// so the page still renders against the initial RefreshAsync pass.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(ResilienceStatusChangedMessage msg)
|
||||
{
|
||||
if (_rows is null) return;
|
||||
var idx = _rows.FindIndex(r =>
|
||||
r.DriverInstanceId == msg.DriverInstanceId && r.HostName == msg.HostName);
|
||||
if (idx < 0) return;
|
||||
var prior = _rows[idx];
|
||||
_rows[idx] = prior with
|
||||
{
|
||||
ConsecutiveFailures = msg.ConsecutiveFailures,
|
||||
LastCircuitBreakerOpenUtc = msg.LastCircuitBreakerOpenUtc,
|
||||
CurrentBulkheadDepth = msg.CurrentBulkheadDepth,
|
||||
LastRecycleUtc = msg.LastRecycleUtc,
|
||||
};
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
|
||||
_rows = (await svc.ListAsync()).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(HostStatusRow r) => r.State switch
|
||||
{
|
||||
DriverHostState.Faulted => "table-danger",
|
||||
_ when HostStatusService.IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "chip-ok",
|
||||
DriverHostState.Stopped => "chip-idle",
|
||||
DriverHostState.Faulted => "chip-bad",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
if (_hub is not null)
|
||||
{
|
||||
try { await _hub.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Live address-string parser preview for Modbus tag editing. Bound to a string
|
||||
AddressString; on every input keystroke the parser runs and surfaces the resolved
|
||||
breakdown (Region, PduOffset, DataType, Bit, ByteOrder, ArrayCount, StringLength) or
|
||||
the parse error. Family flag drives the parser's family-native branch (#144).
|
||||
|
||||
Re-uses the same ModbusAddressParser the wire driver uses, so grammar drift is
|
||||
impossible by construction. Internal-namespace component called from the larger
|
||||
DriverInstance editor.
|
||||
*@
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Address string</label>
|
||||
<input type="text" class="form-control form-control-sm @(IsValid ? "is-valid" : Diagnostic is null ? "" : "is-invalid")"
|
||||
value="@AddressString"
|
||||
@oninput="@OnInputChanged"
|
||||
placeholder="e.g. 40001:F:CDAB:5"/>
|
||||
@if (IsValid && _parsed is not null)
|
||||
{
|
||||
<div class="form-text text-success">
|
||||
<strong>Parsed:</strong>
|
||||
Region=<span class="mono">@_parsed.Region</span>
|
||||
Offset=<span class="mono">@_parsed.Offset</span>
|
||||
Type=<span class="mono">@_parsed.DataType</span>
|
||||
@if (_parsed.Bit.HasValue) { <text>Bit=<span class="mono">@_parsed.Bit</span></text> }
|
||||
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<span class="mono">@_parsed.ByteOrder</span></text> }
|
||||
@if (_parsed.ArrayCount.HasValue) { <text>Array[<span class="mono">@_parsed.ArrayCount</span>]</text> }
|
||||
@if (_parsed.StringLength > 0) { <text>StrLen=<span class="mono">@_parsed.StringLength</span></text> }
|
||||
</div>
|
||||
}
|
||||
else if (Diagnostic is not null)
|
||||
{
|
||||
<div class="invalid-feedback">@Diagnostic</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AddressString { get; set; }
|
||||
[Parameter] public EventCallback<string?> AddressStringChanged { get; set; }
|
||||
[Parameter] public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
[Parameter] public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
[Parameter] public EventCallback<ParsedModbusAddress?> ParsedChanged { get; set; }
|
||||
|
||||
private ParsedModbusAddress? _parsed;
|
||||
private string? Diagnostic;
|
||||
private bool IsValid => _parsed is not null && Diagnostic is null;
|
||||
|
||||
protected override void OnParametersSet() => Reparse();
|
||||
|
||||
private async Task OnInputChanged(ChangeEventArgs e)
|
||||
{
|
||||
AddressString = e.Value as string;
|
||||
await AddressStringChanged.InvokeAsync(AddressString);
|
||||
Reparse();
|
||||
await ParsedChanged.InvokeAsync(_parsed);
|
||||
}
|
||||
|
||||
private void Reparse()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AddressString))
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = null;
|
||||
return;
|
||||
}
|
||||
if (ModbusAddressParser.TryParse(AddressString, Family, MelsecSubFamily, out var parsed, out var err))
|
||||
{
|
||||
_parsed = parsed;
|
||||
Diagnostic = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
@page "/modbus/address-preview"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
|
||||
@*
|
||||
#149 — standalone preview / sanity-check tool for Modbus address strings. The Admin UI
|
||||
doesn't yet have a per-tag CRUD surface (tags are seeded via SQL or arrive at runtime
|
||||
through ITagDiscovery), so the ModbusAddressEditor component shipped in #145 needs a
|
||||
page where operators can paste an address string and confirm it parses to what they
|
||||
expect before committing it to a config row.
|
||||
|
||||
Doubles as a "did the parser ship correctly" smoke target for QA + a copy-pasteable
|
||||
grammar reference for users skimming the docs.
|
||||
*@
|
||||
|
||||
<PageTitle>Modbus address preview</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Modbus address preview</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Paste an address string and watch the parser break it down field by field. Useful for
|
||||
sanity-checking a tag spreadsheet row before adding it to a driver's <span class="mono">DriverConfig</span>.
|
||||
Full grammar: <a href="https://github.com/" target="_blank">docs/v2/modbus-addressing.md</a>.
|
||||
</p>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Family</div>
|
||||
<div style="padding:.75rem 1rem">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">PLC family hint (drives the family-native branch)</label>
|
||||
<select class="form-select form-select-sm" @bind="_family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select form-select-sm" @bind="_melsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<ModbusAddressEditor @bind-AddressString="_address"
|
||||
Family="_family"
|
||||
MelsecSubFamily="_melsecSubFamily"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Quick-reference grammar</div>
|
||||
<pre class="small" style="padding:1rem;margin:0;overflow-x:auto">@_grammarReference</pre>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private string? _address;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSubFamily = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Held as a const string rather than inline markup so the Razor parser doesn't try to
|
||||
// interpret the angle-bracket grammar tokens as element open/close.
|
||||
private const string _grammarReference = @"<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
|
||||
|
||||
Examples (post-#146 type codes):
|
||||
40001 HoldingRegisters[0], Int16
|
||||
400001 same, 6-digit form
|
||||
40001:F Float32 (HR[0..1])
|
||||
40001:F:CDAB Float32 word-swapped
|
||||
40001:STR20 20-char ASCII string
|
||||
40001:S:5 Int16[5] array (3-field shorthand)
|
||||
40001:I:CDAB:10 Int32[10] word-swapped (4-field strict)
|
||||
40001.5 bit 5 of HR[0]
|
||||
HR1:I Int32 via mnemonic region (matches Wonderware)
|
||||
C100 Coil 100 (mnemonic, 1-based)
|
||||
V2000:F:CDAB DL205 V-memory at PDU 1024 (Family=DL205)
|
||||
D100:I MELSEC D-register 100, Int32 (Family=MELSEC)
|
||||
|
||||
Type codes: BOOL, S (Int16), US (UInt16), I (Int32), UI (UInt32),
|
||||
I_64 (Int64), UI_64 (UInt64), F, D, BCD, BCD_32, STR<n>
|
||||
Byte order: ABCD (BE default), CDAB (word-swap), BADC (byte-swap), DCBA (full reverse)";
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
@page "/modbus/diagnostics/{DriverInstanceId}"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject DriverDiagnosticsClient Diagnostics
|
||||
|
||||
@*
|
||||
#154 — operator-facing view of the Server's auto-prohibition state for a Modbus driver.
|
||||
Fetches via DriverDiagnosticsClient (HttpClient against the Server's HealthEndpointsHost).
|
||||
Refreshes on demand; auto-refresh is a future task once a SignalR diag channel exists.
|
||||
*@
|
||||
|
||||
<PageTitle>Modbus diagnostics — @DriverInstanceId</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Modbus auto-prohibitions</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Driver instance <span class="mono">@DriverInstanceId</span>. Live snapshot of coalesced ranges
|
||||
the planner has learned to read individually (#148 / #150 / #151 / #152).
|
||||
</p>
|
||||
|
||||
<div class="toolbar" style="margin-bottom:.75rem">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAsync" disabled="@_loading">
|
||||
@(_loading ? "Loading…" : "Refresh")
|
||||
</button>
|
||||
@if (_lastRefreshed is not null)
|
||||
{
|
||||
<span class="text-muted ms-3 small">Last refreshed @_lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">@_error</section>
|
||||
}
|
||||
else if (_response is null)
|
||||
{
|
||||
<p class="text-muted">Click <strong>Refresh</strong> to load.</p>
|
||||
}
|
||||
else if (_response.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">No auto-prohibitions. The planner is coalescing freely.</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Prohibited ranges</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Unit</th>
|
||||
<th>Region</th>
|
||||
<th class="num">Start</th>
|
||||
<th class="num">End</th>
|
||||
<th class="num">Span</th>
|
||||
<th>Status</th>
|
||||
<th>Last probed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _response.Ranges.OrderBy(r => r.UnitId).ThenBy(r => r.Region).ThenBy(r => r.StartAddress))
|
||||
{
|
||||
<tr>
|
||||
<td class="mono">@r.UnitId</td>
|
||||
<td class="mono">@r.Region</td>
|
||||
<td class="num mono">@r.StartAddress</td>
|
||||
<td class="num mono">@r.EndAddress</td>
|
||||
<td class="num">@(r.EndAddress - r.StartAddress + 1)</td>
|
||||
<td>
|
||||
@if (r.BisectionPending)
|
||||
{
|
||||
<span class="chip chip-warn">BISECTING</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chip chip-bad">ISOLATED</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">@FormatTimeSince(r.LastProbedUtc)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string DriverInstanceId { get; set; } = string.Empty;
|
||||
|
||||
private ModbusAutoProhibitionsResponse? _response;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
private DateTime? _lastRefreshed;
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
_response = await Diagnostics.GetModbusAutoProhibitedRangesAsync(DriverInstanceId);
|
||||
_lastRefreshed = DateTime.UtcNow;
|
||||
if (_response is null)
|
||||
_error = $"Server reports driver '{DriverInstanceId}' is not present or is not a Modbus driver.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Fetch failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatTimeSince(DateTime utc)
|
||||
{
|
||||
var span = DateTime.UtcNow - utc;
|
||||
if (span.TotalSeconds < 60) return $"{(int)span.TotalSeconds}s ago";
|
||||
if (span.TotalMinutes < 60) return $"{(int)span.TotalMinutes}m ago";
|
||||
if (span.TotalHours < 24) return $"{(int)span.TotalHours}h ago";
|
||||
return $"{(int)span.TotalDays}d ago";
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Driver-instance options panel for the Modbus driver. Surfaces every option group
|
||||
added by #136-#144 so users can configure the driver via the UI rather than hand-editing
|
||||
DriverConfig JSON. Bound to a ModbusOptionsViewModel; the parent page round-trips that
|
||||
model to the DriverConfig.json column on save.
|
||||
*@
|
||||
|
||||
<div class="modbus-options-editor">
|
||||
|
||||
<div class="panel-head">Connection</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Host</label>
|
||||
<input class="form-control form-control-sm" @bind="Model.Host"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.Port"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Default UnitId</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.UnitId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-head">Family (#144)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">PLC family</label>
|
||||
<select class="form-select form-select-sm" @bind="Model.Family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (Model.Family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select form-select-sm" @bind="Model.MelsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="panel-head">Keep-alive (#139)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.KeepAliveEnabled"/>
|
||||
<label class="form-check-label">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Time (s)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveTimeSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Interval (s)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveIntervalSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.KeepAliveRetryCount"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-head">Reconnect (#139)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Initial delay (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.ReconnectInitialDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Max delay (ms)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.ReconnectMaxDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<input type="number" step="0.1" class="form-control form-control-sm" @bind="Model.ReconnectBackoffMultiplier"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-head">Protocol (#140)</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / read</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.MaxRegistersPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / write</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.MaxRegistersPerWrite"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max coils / read</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.MaxCoilsPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max read gap (#143)</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="Model.MaxReadGap"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC15ForSingleCoilWrites"/>
|
||||
<label class="form-check-label">Use FC15 for single coil</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC16ForSingleRegisterWrites"/>
|
||||
<label class="form-check-label">Use FC16 for single reg</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.WriteOnChangeOnly"/>
|
||||
<label class="form-check-label">Write-on-change only (#141)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public ModbusOptionsViewModel Model { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// UI binding model. Maps 1:1 onto the JSON DTO the driver factory accepts; serialised
|
||||
/// to DriverConfig.json by the calling save handler. Defaults match
|
||||
/// <c>ModbusDriverOptions</c> defaults so unedited rows produce the historical wire
|
||||
/// output verbatim.
|
||||
/// </summary>
|
||||
public sealed class ModbusOptionsViewModel
|
||||
{
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public byte UnitId { get; set; } = 1;
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSec { get; set; } = 30;
|
||||
public int KeepAliveIntervalSec { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
public int ReconnectInitialDelayMs { get; set; } = 0;
|
||||
public int ReconnectMaxDelayMs { get; set; } = 30000;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
@page "/reservations"
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@attribute [Authorize(Policy = "CanPublish")]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ReservationService ReservationSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">External-ID reservations</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
|
||||
FleetAdmin-only audit-logged action — only release when the physical asset is permanently
|
||||
retired and its ID needs to be reused by a different equipment.
|
||||
</p>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.02s">
|
||||
<div class="panel-head">Active</div>
|
||||
@if (_active is null) { <p class="px-3 py-2">Loading…</p> }
|
||||
else if (_active.Count == 0) { <p class="px-3 py-2 text-muted">No active reservations.</p> }
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _active)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.Kind</span></td>
|
||||
<td><span class="mono">@r.Value</span></td>
|
||||
<td><span class="mono">@r.EquipmentUuid</span></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
|
||||
<td><small>@r.LastPublishedAt.ToString("u")</small></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick='() => OpenReleaseDialog(r)'>Release…</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Released (most recent 100)</div>
|
||||
@if (_released is null) { <p class="px-3 py-2">Loading…</p> }
|
||||
else if (_released.Count == 0) { <p class="px-3 py-2 text-muted">No released reservations yet.</p> }
|
||||
else
|
||||
{
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _released)
|
||||
{
|
||||
<tr><td><span class="mono">@r.Kind</span></td><td><span class="mono">@r.Value</span></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (_releasing is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Release reservation <span class="mono">@_releasing.Kind</span> = <span class="mono">@_releasing.Value</span></h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p>
|
||||
<label class="form-label">Reason (required)</label>
|
||||
<textarea class="form-control form-control-sm" rows="3" @bind="_reason"></textarea>
|
||||
@if (_error is not null) { <section class="panel notice mt-2">@_error</section> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>
|
||||
<button class="btn btn-danger" @onclick="ReleaseAsync" disabled="@_busy">Release</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Admin-008: capture the signed-in operator so the release is attributed correctly in the
|
||||
// ExternalIdReservation.ReleasedBy column and the ConfigAuditLog.Principal column.
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
private List<ExternalIdReservation>? _active;
|
||||
private List<ExternalIdReservation>? _released;
|
||||
private ExternalIdReservation? _releasing;
|
||||
private string _reason = string.Empty;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_active = await ReservationSvc.ListActiveAsync(CancellationToken.None);
|
||||
_released = await ReservationSvc.ListReleasedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void OpenReleaseDialog(ExternalIdReservation r)
|
||||
{
|
||||
_releasing = r;
|
||||
_reason = string.Empty;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task ReleaseAsync()
|
||||
{
|
||||
if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; }
|
||||
|
||||
// Resolve the operator principal. The page is [Authorize(Policy="CanPublish")] so
|
||||
// AuthState will be available with an authenticated user; fall back to "unknown" only
|
||||
// as a defensive last resort (should never happen in practice).
|
||||
var user = AuthState is not null ? (await AuthState).User : null;
|
||||
var operatorName = user?.FindFirstValue(ClaimTypes.Name)
|
||||
?? user?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? "unknown";
|
||||
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
await ReservationSvc.ReleaseAsync(
|
||||
_releasing.Kind.ToString(), _releasing.Value, _reason, operatorName, CancellationToken.None);
|
||||
_releasing = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
@page "/role-grants"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "CanPublish")]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Services
|
||||
@inject ILdapGroupRoleMappingService RoleSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject AclChangeNotifier Notifier
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">LDAP group → Admin role grants</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No role grants defined yet. Without at least one FleetAdmin grant,
|
||||
only the bootstrap admin can publish drafts.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Grants</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>LDAP group</th><th>Role</th><th>Scope</th><th>Created</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@r.LdapGroup</span></td>
|
||||
<td><span class="chip chip-idle">@r.Role</span></td>
|
||||
<td>@(r.IsSystemWide ? "Fleet-wide" : $"Cluster: {r.ClusterId}")</td>
|
||||
<td class="small">@r.CreatedAtUtc.ToString("yyyy-MM-dd")</td>
|
||||
<td class="small text-muted">@r.Notes</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(r.Id)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.14s">
|
||||
<div class="panel-head">New role grant</div>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group (DN)</label>
|
||||
<input class="form-control form-control-sm" @bind="_group" placeholder="cn=fleet-admin,ou=groups,dc=…"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_role">
|
||||
@foreach (var r in Enum.GetValues<AdminRole>())
|
||||
{
|
||||
<option value="@r">@r</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 pt-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="systemWide" @bind="_isSystemWide"/>
|
||||
<label class="form-check-label" for="systemWide">Fleet-wide</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Cluster @(_isSystemWide ? "(disabled)" : "")</label>
|
||||
<select class="form-select form-select-sm" @bind="_clusterId" disabled="@_isSystemWide">
|
||||
<option value="">-- select --</option>
|
||||
@if (_clusters is not null)
|
||||
{
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.ClusterId</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<input class="form-control form-control-sm" @bind="_notes"/>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <section class="panel notice mt-3">@_error</section> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<LdapGroupRoleMapping>? _rows;
|
||||
private List<ServerCluster>? _clusters;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private AdminRole _role = AdminRole.ConfigViewer;
|
||||
private bool _isSystemWide;
|
||||
private string _clusterId = string.Empty;
|
||||
private string? _notes;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_rows = await RoleSvc.ListAllAsync(CancellationToken.None);
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_group = string.Empty;
|
||||
_role = AdminRole.ConfigViewer;
|
||||
_isSystemWide = false;
|
||||
_clusterId = string.Empty;
|
||||
_notes = null;
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var row = new LdapGroupRoleMapping
|
||||
{
|
||||
LdapGroup = _group.Trim(),
|
||||
Role = _role,
|
||||
IsSystemWide = _isSystemWide,
|
||||
ClusterId = _isSystemWide ? null : (string.IsNullOrWhiteSpace(_clusterId) ? null : _clusterId),
|
||||
Notes = string.IsNullOrWhiteSpace(_notes) ? null : _notes,
|
||||
};
|
||||
await RoleSvc.CreateAsync(row, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await RoleSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await Notifier.NotifyRoleGrantsChangedAsync(CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || _hub is not null) return;
|
||||
_hub = HubFactory.Create("/hubs/fleet");
|
||||
_hub.On<RoleGrantsChangedMessage>("RoleGrantsChanged", async _ =>
|
||||
{
|
||||
await ReloadAsync();
|
||||
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 page still renders. Live role-grant updates degrade.
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort live updates — see comment above
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) { await _hub.DisposeAsync(); _hub = null; }
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
@page "/script-log"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@inject NavigationManager Nav
|
||||
@inject AdminHubConnectionFactory HubFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Script log viewer</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Live tail of the <code class="mono">scripts-*.log</code> file produced by the OPC UA Server's
|
||||
Roslyn script runtime. Useful for diagnosing virtual-tag and scripted-alarm script errors in production.
|
||||
Filter by script name to see only events from one script.
|
||||
</p>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<input class="form-control form-control-sm"
|
||||
style="max-width:22rem"
|
||||
placeholder="Filter by script name (optional)"
|
||||
@bind="_scriptNameFilter"
|
||||
@bind:event="oninput"
|
||||
disabled="@_streaming"/>
|
||||
<select class="form-select form-select-sm ms-2" style="max-width:10rem" @bind="_minLevel" disabled="@_streaming">
|
||||
<option value="VRB">All (VRB+)</option>
|
||||
<option value="DBG">DBG+</option>
|
||||
<option value="INF">INF+</option>
|
||||
<option value="WRN">WRN+</option>
|
||||
<option value="ERR">ERR+</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary ms-2" @onclick="StartAsync" disabled="@_streaming">Start</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="StopAsync" disabled="@(!_streaming)">Stop</button>
|
||||
<button class="btn btn-sm btn-outline-danger ms-1" @onclick="ClearLines">Clear</button>
|
||||
<span class="spacer"></span>
|
||||
@if (_streaming)
|
||||
{
|
||||
<span class="chip chip-ok">Streaming</span>
|
||||
}
|
||||
else if (_stopped)
|
||||
{
|
||||
<span class="chip chip-idle">Stopped</span>
|
||||
}
|
||||
@if (_lines.Count > 0) { <span class="tb-count ms-2">@_lines.Count line@(_lines.Count == 1 ? "" : "s")</span> }
|
||||
</div>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
<span class="s-bad">@_error</span>
|
||||
<button type="button" class="btn-close float-end" @onclick="() => _error = null"></button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (_lines.Count == 0 && !_streaming && !_stopped)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.04s">
|
||||
Press <strong>Start</strong> to begin tailing the script log. The last @ScriptLogHub.TailSeedLines lines
|
||||
are replayed first, then new lines appear as they are written by the OPC UA Server script runtime.
|
||||
</section>
|
||||
}
|
||||
else if (_lines.Count == 0 && (_streaming || _stopped))
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.04s">
|
||||
No matching log lines found. Check that the OPC UA Server is running and has executed at least one script,
|
||||
and that the <code class="mono">ScriptLog:Directory</code> setting points to the correct log folder.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head d-flex justify-content-between align-items-center">
|
||||
<span>Script log</span>
|
||||
<small class="text-muted">Latest @_lines.Count entries — oldest first</small>
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:60vh;overflow-y:auto" @ref="_tableContainer">
|
||||
<table class="data-table" style="font-size:.85rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:7rem">Level</th>
|
||||
<th style="width:14rem">Script</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var line in _lines)
|
||||
{
|
||||
<tr class="@RowClass(line.Level)">
|
||||
<td><span class="chip @LevelBadge(line.Level)">@line.Level</span></td>
|
||||
<td><span class="mono small">@(line.ScriptName ?? "—")</span></td>
|
||||
<td><span class="mono small" style="white-space:pre-wrap;word-break:break-all">@line.Raw</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Keep at most this many lines in-memory to avoid DOM growth.
|
||||
private const int MaxLines = 1000;
|
||||
|
||||
private HubConnection? _hub;
|
||||
private CancellationTokenSource? _streamCts;
|
||||
private List<ScriptLogLine> _lines = [];
|
||||
private string _scriptNameFilter = string.Empty;
|
||||
private string _minLevel = "INF";
|
||||
private bool _streaming;
|
||||
private bool _stopped;
|
||||
private string? _error;
|
||||
private ElementReference _tableContainer;
|
||||
|
||||
private static readonly string[] LevelOrder = ["VRB", "DBG", "INF", "WRN", "ERR", "FTL"];
|
||||
|
||||
private async Task StartAsync()
|
||||
{
|
||||
_error = null;
|
||||
_streaming = false;
|
||||
_stopped = false;
|
||||
|
||||
try
|
||||
{
|
||||
_hub ??= HubFactory.Create("/hubs/script-log");
|
||||
|
||||
if (_hub.State == HubConnectionState.Disconnected)
|
||||
await _hub.StartAsync();
|
||||
|
||||
_streamCts = new CancellationTokenSource();
|
||||
_streaming = true;
|
||||
|
||||
// Fire-and-forget into the background; updates come via StateHasChanged.
|
||||
_ = Task.Run(() => ConsumeStreamAsync(_streamCts.Token));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Failed to connect to script log hub: {ex.Message}";
|
||||
_streaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConsumeStreamAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = _hub!.StreamAsync<ScriptLogLine>(
|
||||
"TailLogAsync", _scriptNameFilter, ct);
|
||||
|
||||
await foreach (var line in stream.WithCancellation(ct))
|
||||
{
|
||||
if (!PassesLevelFilter(line.Level)) continue;
|
||||
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
_lines.Add(line);
|
||||
if (_lines.Count > MaxLines)
|
||||
_lines.RemoveRange(0, _lines.Count - MaxLines);
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* normal stop */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
_error = $"Stream error: {ex.Message}";
|
||||
_streaming = false;
|
||||
_stopped = true;
|
||||
StateHasChanged();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
_streaming = false;
|
||||
_stopped = true;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task StopAsync()
|
||||
{
|
||||
if (_streamCts is not null)
|
||||
{
|
||||
await _streamCts.CancelAsync();
|
||||
_streamCts.Dispose();
|
||||
_streamCts = null;
|
||||
}
|
||||
_streaming = false;
|
||||
_stopped = true;
|
||||
}
|
||||
|
||||
private void ClearLines()
|
||||
{
|
||||
_lines.Clear();
|
||||
_stopped = false;
|
||||
}
|
||||
|
||||
private bool PassesLevelFilter(string level)
|
||||
{
|
||||
var minIdx = Array.IndexOf(LevelOrder, _minLevel);
|
||||
var lineIdx = Array.IndexOf(LevelOrder, level);
|
||||
return lineIdx >= minIdx;
|
||||
}
|
||||
|
||||
private static string LevelBadge(string level) => level switch
|
||||
{
|
||||
"ERR" or "FTL" => "chip-bad",
|
||||
"WRN" => "chip-warn",
|
||||
"INF" => "chip-ok",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string RowClass(string level) => level switch
|
||||
{
|
||||
"ERR" or "FTL" => "table-danger",
|
||||
"WRN" => "table-warning",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_streamCts is not null)
|
||||
{
|
||||
await _streamCts.CancelAsync();
|
||||
_streamCts.Dispose();
|
||||
}
|
||||
if (_hub is not null)
|
||||
{
|
||||
await _hub.DisposeAsync();
|
||||
_hub = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
@page "/scripted-alarms"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ScriptedAlarmService AlarmSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Scripted Alarms</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
OPC UA Part 9 alarms raised by C# predicate scripts. To author scripted alarms, open a cluster
|
||||
draft and use the <strong>Scripted Alarms</strong> tab in the draft editor.
|
||||
This view lists all scripted alarms across clusters and generations for reference.
|
||||
</p>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<select class="form-select form-select-sm tb-state" @bind="_filterClusterId" @bind:after="OnClusterChangedAsync">
|
||||
<option value="">— all clusters —</option>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.Name (@c.ClusterId)</option>
|
||||
}
|
||||
</select>
|
||||
<select class="form-select form-select-sm tb-state ms-2" @bind="_filterGenerationId" @bind:after="LoadAlarmsAsync"
|
||||
disabled="@(string.IsNullOrEmpty(_filterClusterId))">
|
||||
<option value="0">— all generations —</option>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<option value="@g.GenerationId">gen @g.GenerationId (@g.Status) @(g.PublishedAt?.ToString("yyyy-MM-dd") ?? "")</option>
|
||||
}
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
@if (_alarms is not null) { <span class="tb-count">@_alarms.Count alarm@(_alarms.Count == 1 ? "" : "s")</span> }
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted">Loading…</p>
|
||||
}
|
||||
else if (_alarms is null || _alarms.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No scripted alarms found for the selected filter.
|
||||
Open a cluster draft and use the <strong>Scripted Alarms</strong> tab to author scripted alarms.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Scripted alarms</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>Type</th>
|
||||
<th class="num">Severity</th>
|
||||
<th>Message template</th>
|
||||
<th>Predicate script</th>
|
||||
<th>Historize</th>
|
||||
<th>Retain</th>
|
||||
<th>Enabled</th>
|
||||
<th class="num">Generation</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in _alarms)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@a.Name</span></td>
|
||||
<td><span class="mono">@a.EquipmentId</span></td>
|
||||
<td><span class="chip chip-idle">@a.AlarmType</span></td>
|
||||
<td class="num">@a.Severity <small class="text-muted">@SeverityBand(a.Severity)</small></td>
|
||||
<td class="text-truncate" style="max-width:18rem" title="@a.MessageTemplate">
|
||||
<span class="mono small">@a.MessageTemplate</span>
|
||||
</td>
|
||||
<td><span class="mono">@a.PredicateScriptId</span></td>
|
||||
<td>
|
||||
@if (a.HistorizeToAveva) { <span class="chip chip-ok">Aveva</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (a.Retain) { <span class="chip chip-ok">yes</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (a.Enabled) { <span class="chip chip-ok">enabled</span> }
|
||||
else { <span class="chip chip-idle">disabled</span> }
|
||||
</td>
|
||||
<td class="num">@a.GenerationId</td>
|
||||
<td>
|
||||
@if (DraftLink(a.GenerationId) is { } link)
|
||||
{
|
||||
<a class="btn btn-sm btn-outline-secondary" href="@link">Edit draft</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster> _clusters = [];
|
||||
private List<ConfigGeneration> _generations = [];
|
||||
private List<ScriptedAlarm>? _alarms;
|
||||
private string _filterClusterId = string.Empty;
|
||||
private long _filterGenerationId;
|
||||
private bool _loading;
|
||||
private Dictionary<long, (string clusterId, bool isDraft)> _genMap = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
await LoadAlarmsAsync();
|
||||
}
|
||||
|
||||
private async Task OnClusterChangedAsync()
|
||||
{
|
||||
_filterGenerationId = 0;
|
||||
_generations = string.IsNullOrEmpty(_filterClusterId)
|
||||
? []
|
||||
: await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
|
||||
await LoadAlarmsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAlarmsAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_alarms = null;
|
||||
try
|
||||
{
|
||||
var all = new List<ScriptedAlarm>();
|
||||
_genMap = [];
|
||||
|
||||
IEnumerable<ConfigGeneration> gens;
|
||||
if (!string.IsNullOrEmpty(_filterClusterId) && _filterGenerationId != 0)
|
||||
{
|
||||
gens = _generations.Where(g => g.GenerationId == _filterGenerationId);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_filterClusterId))
|
||||
{
|
||||
gens = await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
|
||||
_generations = gens.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
var allGens = new List<ConfigGeneration>();
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
var cGens = await GenerationSvc.ListRecentAsync(c.ClusterId, 5, CancellationToken.None);
|
||||
allGens.AddRange(cGens);
|
||||
}
|
||||
gens = allGens;
|
||||
}
|
||||
|
||||
foreach (var g in gens)
|
||||
{
|
||||
_genMap[g.GenerationId] = (g.ClusterId, g.Status == GenerationStatus.Draft);
|
||||
var alarms = await AlarmSvc.ListAsync(g.GenerationId, CancellationToken.None);
|
||||
all.AddRange(alarms);
|
||||
}
|
||||
|
||||
_alarms = all;
|
||||
}
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private string? DraftLink(long generationId)
|
||||
{
|
||||
if (_genMap.TryGetValue(generationId, out var info) && info.isDraft)
|
||||
return $"/clusters/{info.clusterId}/draft/{generationId}";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string SeverityBand(int s) => s switch
|
||||
{
|
||||
<= 250 => "(Low)",
|
||||
<= 500 => "(Medium)",
|
||||
<= 750 => "(High)",
|
||||
_ => "(Critical)",
|
||||
};
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
@page "/virtual-tags"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject VirtualTagService VirtualTagSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Virtual Tags</h4>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Computed tags driven by C# scripts. To author virtual tags, open a cluster draft and use the
|
||||
<strong>Virtual Tags</strong> tab in the draft editor.
|
||||
This view lists all virtual tags across clusters and generations for reference.
|
||||
</p>
|
||||
|
||||
<div class="toolbar mb-3">
|
||||
<select class="form-select form-select-sm tb-state" @bind="_filterClusterId" @bind:after="OnClusterChangedAsync">
|
||||
<option value="">— all clusters —</option>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<option value="@c.ClusterId">@c.Name (@c.ClusterId)</option>
|
||||
}
|
||||
</select>
|
||||
<select class="form-select form-select-sm tb-state ms-2" @bind="_filterGenerationId" @bind:after="LoadTagsAsync"
|
||||
disabled="@(string.IsNullOrEmpty(_filterClusterId))">
|
||||
<option value="0">— all generations —</option>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<option value="@g.GenerationId">gen @g.GenerationId (@g.Status) @(g.PublishedAt?.ToString("yyyy-MM-dd") ?? "")</option>
|
||||
}
|
||||
</select>
|
||||
<span class="spacer"></span>
|
||||
@if (_tags is not null) { <span class="tb-count">@_tags.Count tag@(_tags.Count == 1 ? "" : "s")</span> }
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted">Loading…</p>
|
||||
}
|
||||
else if (_tags is null || _tags.Count == 0)
|
||||
{
|
||||
<section class="panel notice rise" style="animation-delay:.02s">
|
||||
No virtual tags found for the selected filter.
|
||||
Open a cluster draft and use the <strong>Virtual Tags</strong> tab to author virtual tags.
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<section class="panel rise" style="animation-delay:.08s">
|
||||
<div class="panel-head">Virtual tags</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Equipment</th>
|
||||
<th>DataType</th>
|
||||
<th>Script</th>
|
||||
<th>Triggers</th>
|
||||
<th>Historize</th>
|
||||
<th>Enabled</th>
|
||||
<th class="num">Generation</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var t in _tags)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="mono">@t.Name</span></td>
|
||||
<td><span class="mono">@t.EquipmentId</span></td>
|
||||
<td>@t.DataType</td>
|
||||
<td><span class="mono">@t.ScriptId</span></td>
|
||||
<td>
|
||||
@if (t.ChangeTriggered) { <span class="chip chip-idle me-1">change</span> }
|
||||
@if (t.TimerIntervalMs.HasValue) { <span class="chip chip-idle">@t.TimerIntervalMs ms</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (t.Historize) { <span class="chip chip-ok">yes</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (t.Enabled) { <span class="chip chip-ok">enabled</span> }
|
||||
else { <span class="chip chip-idle">disabled</span> }
|
||||
</td>
|
||||
<td class="num">@t.GenerationId</td>
|
||||
<td>
|
||||
@if (DraftGenId(t.GenerationId) is { } draftUrl)
|
||||
{
|
||||
<a class="btn btn-sm btn-outline-secondary" href="@draftUrl">Edit draft</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster> _clusters = [];
|
||||
private List<ConfigGeneration> _generations = [];
|
||||
private List<VirtualTag>? _tags;
|
||||
private string _filterClusterId = string.Empty;
|
||||
private long _filterGenerationId;
|
||||
private bool _loading;
|
||||
|
||||
// Map generationId → (clusterId, isDraft) for the "Edit draft" link
|
||||
private Dictionary<long, (string clusterId, bool isDraft)> _genMap = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
await LoadTagsAsync();
|
||||
}
|
||||
|
||||
private async Task OnClusterChangedAsync()
|
||||
{
|
||||
_filterGenerationId = 0;
|
||||
_generations = string.IsNullOrEmpty(_filterClusterId)
|
||||
? []
|
||||
: await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
|
||||
await LoadTagsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadTagsAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_tags = null;
|
||||
try
|
||||
{
|
||||
var allTags = new List<VirtualTag>();
|
||||
_genMap = [];
|
||||
|
||||
// Determine which generations to query
|
||||
IEnumerable<ConfigGeneration> gens;
|
||||
if (!string.IsNullOrEmpty(_filterClusterId) && _filterGenerationId != 0)
|
||||
{
|
||||
// specific cluster + specific generation
|
||||
gens = _generations.Where(g => g.GenerationId == _filterGenerationId);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_filterClusterId))
|
||||
{
|
||||
// specific cluster, all recent generations
|
||||
gens = await GenerationSvc.ListRecentAsync(_filterClusterId, 20, CancellationToken.None);
|
||||
_generations = gens.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
// all clusters — load generations per cluster
|
||||
var allGens = new List<ConfigGeneration>();
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
var cGens = await GenerationSvc.ListRecentAsync(c.ClusterId, 5, CancellationToken.None);
|
||||
allGens.AddRange(cGens);
|
||||
}
|
||||
gens = allGens;
|
||||
}
|
||||
|
||||
foreach (var g in gens)
|
||||
{
|
||||
_genMap[g.GenerationId] = (g.ClusterId, g.Status == ZB.MOM.WW.OtOpcUa.Configuration.Enums.GenerationStatus.Draft);
|
||||
var tags = await VirtualTagSvc.ListAsync(g.GenerationId, CancellationToken.None);
|
||||
allTags.AddRange(tags);
|
||||
}
|
||||
|
||||
_tags = allTags;
|
||||
}
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private string? DraftGenId(long generationId)
|
||||
{
|
||||
if (_genMap.TryGetValue(generationId, out var info) && info.isDraft)
|
||||
return $"/clusters/{info.clusterId}/draft/{generationId}";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
@* Server-side redirect to the login page for an unauthenticated request.
|
||||
Used by AuthorizeRouteView's NotAuthorized slot (Admin-001). The current
|
||||
path is carried through as returnUrl so the operator lands back where
|
||||
they aimed after signing in. *@
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = Nav.ToBaseRelativePath(Nav.Uri);
|
||||
var target = string.IsNullOrEmpty(returnUrl) || returnUrl == "login"
|
||||
? "login"
|
||||
: $"login?returnUrl={Uri.EscapeDataString("/" + returnUrl)}";
|
||||
Nav.NavigateTo(target, forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
@* AuthorizeRouteView (not a plain RouteView) is what makes a page-level
|
||||
[Authorize] attribute actually enforced — with RouteView the attribute
|
||||
is inert (Admin-001). Unauthenticated users hit the NotAuthorized slot
|
||||
and are bounced to /login; the route is preserved as returnUrl. *@
|
||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p class="text-danger">You do not have permission to view this page.</p>
|
||||
</LayoutView>
|
||||
}
|
||||
</NotAuthorized>
|
||||
<Authorizing>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Authorizing…</p></LayoutView>
|
||||
</Authorizing>
|
||||
</AuthorizeRouteView>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Not found.</p></LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -1,16 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Shared
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@@ -1,37 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
|
||||
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
|
||||
/// from the UI via <see cref="AcknowledgeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AuthorizeAttribute"/> gates the hub so failure-detail alert text is not pushed
|
||||
/// to anonymous connections (Admin-003).
|
||||
/// </remarks>
|
||||
[Authorize]
|
||||
public sealed class AlertHub : Hub
|
||||
{
|
||||
public const string AllAlertsGroup = "__alerts__";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
|
||||
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record AlertMessage(
|
||||
string AlertId,
|
||||
string Severity,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime RaisedAtUtc,
|
||||
string? ClusterId,
|
||||
string? NodeId);
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
|
||||
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
|
||||
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
|
||||
/// observes a delta.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="AuthorizeAttribute"/> gates the hub: an unauthenticated client cannot open the
|
||||
/// connection, so the fleet generation/role/resilience stream is not an anonymous
|
||||
/// information-disclosure channel (Admin-003).
|
||||
/// </remarks>
|
||||
[Authorize]
|
||||
public sealed class FleetStatusHub : Hub
|
||||
{
|
||||
public Task SubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
public Task UnsubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
|
||||
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||
|
||||
public const string FleetGroup = "__fleet__";
|
||||
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
|
||||
}
|
||||
|
||||
public sealed record NodeStateChangedMessage(
|
||||
string NodeId,
|
||||
string ClusterId,
|
||||
long? CurrentGenerationId,
|
||||
string? LastAppliedStatus,
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <c>FleetStatusPoller</c> when it observes a change in a
|
||||
/// <c>DriverInstanceResilienceStatus</c> row. Closes the last Phase 6.1 Stream E.2/E.3
|
||||
/// deferral — lets the Admin <c>/hosts</c> page upsert the matching row without the
|
||||
/// 10-second polling round-trip. Keyed on (DriverInstanceId, HostName); the client
|
||||
/// fan-outs to the matching row by matching both.
|
||||
/// </summary>
|
||||
public sealed record ResilienceStatusChangedMessage(
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
Binary file not shown.
@@ -1,228 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Streams lines from the server's <c>scripts-*.log</c> file(s) to the Admin UI
|
||||
/// (Phase 7 Stream F.5). Clients call <see cref="TailLogAsync"/> with an optional
|
||||
/// <paramref name="scriptNameFilter"/> to see only events from a named script.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Log files are looked up at <c>ScriptLog:Directory</c> (appsettings.json) relative
|
||||
/// to the current working directory, defaulting to <c>logs</c>. The glob pattern
|
||||
/// <c>scripts-*.log</c> is applied and the most-recently-written file is tailed.
|
||||
/// If no matching file is found an empty stream is returned — the UI shows a notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each streamed <see cref="ScriptLogLine"/> carries the raw text, an extracted level
|
||||
/// (parsed from the Serilog compact format <c>[INF]</c> / <c>[WRN]</c> / <c>[ERR]</c>),
|
||||
/// and the extracted <c>ScriptName</c> property value when present.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tail semantics: up to <see cref="TailSeedLines"/> of the existing file are replayed
|
||||
/// first, then new lines are emitted as they are appended. The stream is cancelled when
|
||||
/// the client disconnects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="AuthorizeAttribute"/> gates the hub: only an authenticated operator can
|
||||
/// open the connection and tail the server <c>scripts-*.log</c> contents (Admin-003).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Authorize]
|
||||
public sealed class ScriptLogHub(IConfiguration configuration, ILogger<ScriptLogHub> logger) : Hub
|
||||
{
|
||||
/// <summary>Number of existing lines to replay from the end of the file before live-tailing.</summary>
|
||||
public const int TailSeedLines = 200;
|
||||
|
||||
/// <summary>Poll cadence for new lines while the log is not being actively appended.</summary>
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// SignalR server-to-client stream. The caller awaits <c>await foreach</c> on the returned
|
||||
/// <see cref="IAsyncEnumerable{T}"/> via the hub-stream protocol. Cancelled automatically
|
||||
/// when the client disconnects or the provided <paramref name="ct"/> fires.
|
||||
/// </summary>
|
||||
/// <param name="scriptNameFilter">
|
||||
/// Optional script name. When non-empty only lines whose <c>ScriptName</c> property
|
||||
/// matches (case-insensitive contains) are emitted.
|
||||
/// </param>
|
||||
/// <param name="ct">Hub-provided cancellation token, cancelled on disconnect.</param>
|
||||
public async IAsyncEnumerable<ScriptLogLine> TailLogAsync(
|
||||
string? scriptNameFilter,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
var logDir = configuration["ScriptLog:Directory"] ?? "logs";
|
||||
var pattern = "scripts-*.log";
|
||||
|
||||
// Find the most recently written matching file.
|
||||
string? logFile;
|
||||
try
|
||||
{
|
||||
logFile = Directory.GetFiles(logDir, pattern, SearchOption.TopDirectoryOnly)
|
||||
.OrderByDescending(File.GetLastWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
logger.LogDebug("Script log directory '{Dir}' not found — yielding empty stream", logDir);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (logFile is null)
|
||||
{
|
||||
logger.LogDebug("No files matching '{Pattern}' in '{Dir}' — yielding empty stream", pattern, logDir);
|
||||
yield break;
|
||||
}
|
||||
|
||||
logger.LogDebug("Tailing script log '{File}' filter='{Filter}'", logFile, scriptNameFilter);
|
||||
|
||||
// Replay seed lines from the end of the current file, then tail.
|
||||
long seekPosition;
|
||||
var seedLines = ReadTailLines(logFile, TailSeedLines, out seekPosition);
|
||||
|
||||
foreach (var line in seedLines)
|
||||
{
|
||||
if (ct.IsCancellationRequested) yield break;
|
||||
var parsed = ParseLine(line);
|
||||
if (Matches(parsed, scriptNameFilter))
|
||||
yield return parsed;
|
||||
}
|
||||
|
||||
// Live-tail: poll for new bytes appended after seekPosition.
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(PollInterval, ct); }
|
||||
catch (OperationCanceledException) { yield break; }
|
||||
|
||||
IReadOnlyList<string> newLines;
|
||||
try
|
||||
{
|
||||
newLines = ReadNewLines(logFile, ref seekPosition);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "Error reading script log '{File}'", logFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var line in newLines)
|
||||
{
|
||||
if (ct.IsCancellationRequested) yield break;
|
||||
var parsed = ParseLine(line);
|
||||
if (Matches(parsed, scriptNameFilter))
|
||||
yield return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static readonly Regex LevelPattern =
|
||||
new(@"\[(?<lvl>VRB|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// Serilog compact text format: ScriptName property appears as ScriptName="value" in the output.
|
||||
private static readonly Regex ScriptNamePattern =
|
||||
new(@"ScriptName=""(?<name>[^""]+)""", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100));
|
||||
|
||||
internal static ScriptLogLine ParseLine(string raw)
|
||||
{
|
||||
var level = "INF";
|
||||
var lvlMatch = LevelPattern.Match(raw);
|
||||
if (lvlMatch.Success) level = lvlMatch.Groups["lvl"].Value;
|
||||
|
||||
string? scriptName = null;
|
||||
var snMatch = ScriptNamePattern.Match(raw);
|
||||
if (snMatch.Success) scriptName = snMatch.Groups["name"].Value;
|
||||
|
||||
return new ScriptLogLine(raw, level, scriptName, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
internal static bool Matches(ScriptLogLine line, string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter)) return true;
|
||||
return line.ScriptName is not null &&
|
||||
line.ScriptName.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the last <paramref name="n"/> lines from <paramref name="path"/> using a
|
||||
/// shared-read stream (so the writer doesn't need an exclusive lock). Returns the lines
|
||||
/// and outputs the final byte offset so the caller can resume from there.
|
||||
/// </summary>
|
||||
internal static List<string> ReadTailLines(string path, int n, out long endPosition)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
endPosition = fs.Length;
|
||||
|
||||
if (fs.Length == 0) return [];
|
||||
|
||||
// Walk backwards collecting newlines until we have n+1 occurrences (n lines from end).
|
||||
const int bufferSize = 4096;
|
||||
var position = fs.Length;
|
||||
var lineBreaks = 0;
|
||||
var chunks = new List<byte[]>();
|
||||
|
||||
while (position > 0 && lineBreaks <= n)
|
||||
{
|
||||
var readSize = (int)Math.Min(bufferSize, position);
|
||||
position -= readSize;
|
||||
fs.Seek(position, SeekOrigin.Begin);
|
||||
var buf = new byte[readSize];
|
||||
_ = fs.Read(buf, 0, readSize);
|
||||
chunks.Add(buf);
|
||||
|
||||
foreach (var b in buf)
|
||||
if (b == (byte)'\n') lineBreaks++;
|
||||
|
||||
if (lineBreaks > n) break;
|
||||
}
|
||||
|
||||
// Reassemble bytes in correct order.
|
||||
chunks.Reverse();
|
||||
var allBytes = new byte[chunks.Sum(c => c.Length)];
|
||||
var offset = 0;
|
||||
foreach (var chunk in chunks) { chunk.CopyTo(allBytes, offset); offset += chunk.Length; }
|
||||
|
||||
var fullText = System.Text.Encoding.UTF8.GetString(allBytes);
|
||||
var allLines = fullText.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.TrimEnd('\r'))
|
||||
.Where(l => l.Length > 0)
|
||||
.ToArray();
|
||||
return allLines.Length <= n ? allLines.ToList() : allLines[^n..].ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads any bytes appended to <paramref name="path"/> beyond <paramref name="position"/>.
|
||||
/// Updates <paramref name="position"/> to the new end of file.
|
||||
/// </summary>
|
||||
internal static List<string> ReadNewLines(string path, ref long position)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
if (fs.Length <= position) return [];
|
||||
|
||||
fs.Seek(position, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(fs, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
|
||||
var lines = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
lines.Add(line);
|
||||
|
||||
position = fs.Position;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A single parsed script log line sent to the browser.</summary>
|
||||
/// <param name="Raw">Full raw text of the line.</param>
|
||||
/// <param name="Level">Serilog short level: VRB, DBG, INF, WRN, ERR, FTL.</param>
|
||||
/// <param name="ScriptName">Value of the <c>ScriptName</c> structured property, if present.</param>
|
||||
/// <param name="ReceivedAtUtc">Wall-clock time the Admin process forwarded this line.</param>
|
||||
public sealed record ScriptLogLine(
|
||||
string Raw,
|
||||
string Level,
|
||||
string? ScriptName,
|
||||
DateTime ReceivedAtUtc);
|
||||
@@ -1,191 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenTelemetry.Metrics;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "OtOpcUa.Admin";
|
||||
o.LoginPath = "/login";
|
||||
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
})
|
||||
// Bearer-token scheme for the SignalR hub clients. A server-side Blazor circuit cannot
|
||||
// forward the browser's HttpOnly auth cookie to the loopback hub connection, so pages
|
||||
// authenticate to the hubs with a HubTokenService token instead (see HubTokenService).
|
||||
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||
HubTokenDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
builder.Services.AddSingleton<HubTokenService>();
|
||||
builder.Services.AddScoped<AdminHubConnectionFactory>();
|
||||
|
||||
// Secure-by-default: the fallback policy requires an authenticated user for any
|
||||
// endpoint (and any routable page) that carries no explicit authorization metadata,
|
||||
// so a newly added page cannot accidentally ship anonymously reachable (Admin-001/002).
|
||||
// Pages/endpoints that must stay anonymous opt out with [AllowAnonymous] — the login
|
||||
// page, the /auth/* endpoints and static files all do.
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin))
|
||||
// SignalR hubs accept either the browser auth cookie or a HubToken bearer token, so the
|
||||
// hub endpoint authorization must run both schemes (the default fallback policy is
|
||||
// cookie-only and would 401 the token-authenticated hub clients).
|
||||
.AddPolicy("HubClients", p => p
|
||||
.AddAuthenticationSchemes(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme, HubTokenDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser())
|
||||
.SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build());
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
// Admin-004: the connection string is a secret — it is NOT committed to appsettings.json.
|
||||
// Supply it via user-secrets (dev) or the ConnectionStrings__ConfigDb environment variable
|
||||
// (prod). An empty/missing value is treated as "not configured" so a misconfigured deploy
|
||||
// fails fast with a clear message rather than a downstream SqlClient parse error.
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>((sp, opt) =>
|
||||
{
|
||||
var connectionString = sp.GetRequiredService<IConfiguration>().GetConnectionString("ConfigDb");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
throw new InvalidOperationException(
|
||||
"ConnectionStrings:ConfigDb is not configured. Set it via user-secrets " +
|
||||
"(dotnet user-secrets set \"ConnectionStrings:ConfigDb\" \"...\") or the " +
|
||||
"ConnectionStrings__ConfigDb environment variable.");
|
||||
opt.UseSqlServer(connectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<ClusterService>();
|
||||
builder.Services.AddScoped<GenerationService>();
|
||||
builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<TagService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<FocasDriverDetailService>();
|
||||
|
||||
// #154 — Server diagnostics client. Default base URL points at the same machine's
|
||||
// HealthEndpointsHost (loopback :4841); deployments with remote Servers set
|
||||
// "DriverDiagnostics:ServerBaseUrl" in appsettings.json.
|
||||
builder.Services.AddHttpClient<DriverDiagnosticsClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["DriverDiagnostics:ServerBaseUrl"] ?? "http://localhost:4841/";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
});
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
builder.Services.AddScoped<ClusterNodeService>();
|
||||
builder.Services.AddSingleton<RedundancyMetrics>();
|
||||
builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
// EF-backed inner service registered under the keyed-service key so the resilient
|
||||
// singleton decorator resolves it per-scope without a captive-dependency issue.
|
||||
builder.Services.AddKeyedScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>(
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService.InnerServiceKey);
|
||||
// Resilient singleton decorator: timeout 2 s → retry 3× jittered → fallback to in-memory snapshot.
|
||||
// Uses IServiceScopeFactory to open a short-lived scope for each DB call.
|
||||
// The static LdapOptions.GroupToRole bootstrap dictionary in AdminRoleGrantResolver is the
|
||||
// lock-out-proof floor; this decorator only guards the DB-backed augmentation rows.
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Admin.Security.ResilientLdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||
// from whichever sink is provided at composition time.
|
||||
builder.Services.AddScoped<ScriptService>();
|
||||
builder.Services.AddScoped<VirtualTagService>();
|
||||
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
// filesystem operations.
|
||||
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
||||
builder.Services.AddSingleton<CertTrustService>();
|
||||
|
||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// Resolves Admin-role grants from LDAP groups at sign-in: the static appsettings bootstrap
|
||||
// dictionary augmented by the DB-backed LdapGroupRoleMapping rows (fleet-wide + cluster-scoped).
|
||||
builder.Services.AddScoped<IAdminRoleGrantResolver, AdminRoleGrantResolver>();
|
||||
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
// OpenTelemetry Prometheus exporter — Meter stream from RedundancyMetrics + any future
|
||||
// Admin-side instrumentation lands on the /metrics endpoint Prometheus scrapes. Pull-based
|
||||
// means no OTel Collector deployment required for the common deploy-in-a-K8s case; appsettings
|
||||
// Metrics:Prometheus:Enabled=false disables the endpoint entirely for locked-down deployments.
|
||||
var metricsEnabled = builder.Configuration.GetValue("Metrics:Prometheus:Enabled", true);
|
||||
if (metricsEnabled)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(m => m
|
||||
.AddMeter(RedundancyMetrics.MeterName)
|
||||
.AddPrometheusExporter());
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Admin-005: login + logout are minimal-API endpoints. The login does the LDAP bind,
|
||||
// grant resolution, cookie SignInAsync and redirect while the HTTP response is still
|
||||
// owned by the endpoint — a static-rendered Login.razor form posts here.
|
||||
app.MapAuthEndpoints();
|
||||
|
||||
// Admin-003: every SignalR hub requires an authenticated caller. The [Authorize] attribute
|
||||
// on each Hub class is the primary gate; .RequireAuthorization("HubClients") on the endpoint
|
||||
// is the belt-and-braces backstop AND the scheme gate — the "HubClients" policy runs both the
|
||||
// cookie and the HubToken scheme so server-side Blazor hub clients (which cannot present the
|
||||
// browser cookie) authenticate with a bearer token instead.
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet").RequireAuthorization("HubClients");
|
||||
app.MapHub<AlertHub>("/hubs/alerts").RequireAuthorization("HubClients");
|
||||
app.MapHub<ScriptLogHub>("/hubs/script-log").RequireAuthorization("HubClients");
|
||||
|
||||
if (metricsEnabled)
|
||||
{
|
||||
// Prometheus scrape endpoint — expose instrumentation registered in the OTel MeterProvider
|
||||
// above. Emits text-format metrics at /metrics; auth is intentionally NOT required (Prometheus
|
||||
// scrape jobs typically run on a trusted network). Operators who need auth put the endpoint
|
||||
// behind a reverse-proxy basic-auth gate per fleet-ops convention.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
}
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
@@ -1,48 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IAdminRoleGrantResolver"/>. Merges the static <c>appsettings.json</c>
|
||||
/// bootstrap dictionary with the DB-backed <c>LdapGroupRoleMapping</c> rows. See
|
||||
/// <see cref="AdminRoleGrants"/> for the scope split and the decision-#150 control-plane note.
|
||||
/// </summary>
|
||||
public sealed class AdminRoleGrantResolver(
|
||||
ILdapGroupRoleMappingService mappingService,
|
||||
IOptions<LdapOptions> ldapOptions) : IAdminRoleGrantResolver
|
||||
{
|
||||
private readonly LdapOptions _ldap = ldapOptions.Value;
|
||||
|
||||
public async Task<AdminRoleGrants> ResolveAsync(
|
||||
IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ldapGroups);
|
||||
if (ldapGroups.Count == 0) return AdminRoleGrants.Empty;
|
||||
|
||||
// Static bootstrap dictionary — always fleet-wide, lock-out-proof fallback.
|
||||
var fleet = new HashSet<string>(
|
||||
RoleMapper.Map(ldapGroups, _ldap.GroupToRole), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// DB-backed grants stack additively. A system-wide row folds into the fleet set;
|
||||
// a cluster-scoped row becomes a (cluster, role) grant, deduped on that pair.
|
||||
var mappings = await mappingService.GetByGroupsAsync(ldapGroups, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var cluster = new Dictionary<(string, string), ClusterRoleGrant>();
|
||||
foreach (var m in mappings)
|
||||
{
|
||||
var roleName = m.Role.ToString();
|
||||
if (m.IsSystemWide || string.IsNullOrEmpty(m.ClusterId))
|
||||
{
|
||||
fleet.Add(roleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = (m.ClusterId, roleName);
|
||||
cluster[key] = new ClusterRoleGrant(m.ClusterId, roleName);
|
||||
}
|
||||
}
|
||||
|
||||
return new AdminRoleGrants([.. fleet], [.. cluster.Values]);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>A cluster-scoped Admin-role grant — the <see cref="Role"/> binds only within <see cref="ClusterId"/>.</summary>
|
||||
public sealed record ClusterRoleGrant(string ClusterId, string Role);
|
||||
|
||||
/// <summary>
|
||||
/// The Admin roles a user holds after sign-in, split by scope. <see cref="FleetRoles"/> apply
|
||||
/// across every cluster; each entry in <see cref="ClusterRoles"/> binds only within its named
|
||||
/// cluster. Resolved by <see cref="IAdminRoleGrantResolver"/> from the user's LDAP groups.
|
||||
/// </summary>
|
||||
public sealed record AdminRoleGrants(
|
||||
IReadOnlyList<string> FleetRoles,
|
||||
IReadOnlyList<ClusterRoleGrant> ClusterRoles)
|
||||
{
|
||||
/// <summary>No grants — sign-in is blocked when a resolution yields this.</summary>
|
||||
public static readonly AdminRoleGrants Empty = new([], []);
|
||||
|
||||
/// <summary>True when the user holds no Admin role at any scope.</summary>
|
||||
public bool IsEmpty => FleetRoles.Count == 0 && ClusterRoles.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Admin-role grants a set of LDAP groups confers. Augments the static
|
||||
/// <see cref="LdapOptions.GroupToRole"/> bootstrap dictionary (always fleet-wide) with the
|
||||
/// DB-backed <c>LdapGroupRoleMapping</c> rows authored on the role-grants page — fleet-wide
|
||||
/// and cluster-scoped. The static dictionary is the lock-out-proof fallback; DB grants stack
|
||||
/// additively on top of it.
|
||||
/// </summary>
|
||||
public interface IAdminRoleGrantResolver
|
||||
{
|
||||
Task<AdminRoleGrants> ResolveAsync(IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal-API authentication endpoints. Admin-005: the login is a static-rendered HTML
|
||||
/// form (<c>Login.razor</c>, <c>data-enhance="false"</c>) POSTing here, so the LDAP bind,
|
||||
/// grant resolution, <see cref="AuthenticationHttpContextExtensions.SignInAsync(HttpContext,
|
||||
/// string?, ClaimsPrincipal)"/> cookie write and the redirect all happen while the endpoint
|
||||
/// still owns an unstarted HTTP response. Performing <c>SignInAsync</c> from an interactive
|
||||
/// Blazor circuit (the previous implementation) could not emit the auth cookie because the
|
||||
/// original HTTP response had already completed.
|
||||
/// </summary>
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
/// <summary>Maps <c>POST /auth/login</c> and <c>POST /auth/logout</c>.</summary>
|
||||
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
// Anonymous: the login POST is the only way in, so the fallback authorization policy
|
||||
// (Admin-001) must not gate it. DisableAntiforgery — the static Login.razor form posts
|
||||
// with data-enhance="false" and renders no antiforgery token; the cookie scheme + LDAP
|
||||
// bind are the authentication gate here. Login is not a state-changing operation that
|
||||
// CSRF can abuse (the attacker cannot know the resulting cookie), so tokenless-login is
|
||||
// the standard Web pattern.
|
||||
endpoints.MapPost("/auth/login", (Delegate)LoginAsync)
|
||||
.AllowAnonymous()
|
||||
.DisableAntiforgery();
|
||||
|
||||
// Admin-006: the logout form in MainLayout.razor emits <AntiforgeryToken />.
|
||||
// The endpoint validates the token explicitly via IAntiforgery.ValidateRequestAsync
|
||||
// (minimal API endpoints do not participate in the UseAntiforgery() pipeline by default).
|
||||
// Calling .DisableAntiforgery() suppresses the middleware pass so the manual check in
|
||||
// LogoutAsync is the single validation point — duplicating both would cause double-reads
|
||||
// of the request body.
|
||||
endpoints.MapPost("/auth/logout", (Delegate)LogoutAsync)
|
||||
.DisableAntiforgery();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> LoginAsync(
|
||||
HttpContext ctx,
|
||||
[FromForm] string? username,
|
||||
[FromForm] string? password,
|
||||
[FromForm] string? returnUrl,
|
||||
ILdapAuthService ldapAuth,
|
||||
IAdminRoleGrantResolver grantResolver,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return RedirectToLogin("Username and password are required", returnUrl);
|
||||
|
||||
var result = await ldapAuth.AuthenticateAsync(username, password, ct);
|
||||
if (!result.Success)
|
||||
return RedirectToLogin(result.Error ?? "Sign-in failed", returnUrl);
|
||||
|
||||
// Grants come from the static bootstrap dictionary + DB-backed role grants;
|
||||
// result.Roles (static-only) is intentionally not consulted here.
|
||||
var grants = await grantResolver.ResolveAsync(result.Groups, ct);
|
||||
if (grants.IsEmpty)
|
||||
return RedirectToLogin(
|
||||
"Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.",
|
||||
returnUrl);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? username),
|
||||
new(ClaimTypes.NameIdentifier, username),
|
||||
};
|
||||
foreach (var role in grants.FleetRoles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
foreach (var clusterGrant in grants.ClusterRoles)
|
||||
claims.Add(new Claim(ClusterRoleClaims.ClaimType,
|
||||
ClusterRoleClaims.Encode(clusterGrant.ClusterId, clusterGrant.Role)));
|
||||
foreach (var group in result.Groups)
|
||||
claims.Add(new Claim("ldap_group", group));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
return Results.Redirect(SafeReturnUrl(returnUrl));
|
||||
}
|
||||
|
||||
private static async Task<IResult> LogoutAsync(HttpContext ctx, IAntiforgery antiforgery)
|
||||
{
|
||||
try
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(ctx);
|
||||
}
|
||||
catch (AntiforgeryValidationException)
|
||||
{
|
||||
return Results.BadRequest("Invalid or missing antiforgery token.");
|
||||
}
|
||||
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.Redirect("/login");
|
||||
}
|
||||
|
||||
private static IResult RedirectToLogin(string error, string? returnUrl)
|
||||
{
|
||||
var target = $"/login?error={Uri.EscapeDataString(error)}";
|
||||
if (!string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl))
|
||||
target += $"&returnUrl={Uri.EscapeDataString(returnUrl)}";
|
||||
return Results.Redirect(target);
|
||||
}
|
||||
|
||||
/// <summary>Open-redirect guard — only same-site relative paths are honoured.</summary>
|
||||
private static string SafeReturnUrl(string? returnUrl) =>
|
||||
!string.IsNullOrWhiteSpace(returnUrl) && IsLocalUrl(returnUrl) ? returnUrl : "/";
|
||||
|
||||
private static bool IsLocalUrl(string url) =>
|
||||
url.StartsWith('/') && !url.StartsWith("//") && !url.StartsWith("/\\");
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Encoding for the cluster-scoped role claim. A fleet-wide grant is a standard
|
||||
/// <see cref="ClaimTypes.Role"/> claim (so the existing <c>CanEdit</c>/<c>CanPublish</c>
|
||||
/// policies keep working); a cluster-scoped grant is a <see cref="ClaimType"/> claim whose
|
||||
/// value packs the cluster id and role together. A cluster-scoped role deliberately does NOT
|
||||
/// satisfy a fleet-wide <c>RequireRole</c> policy.
|
||||
/// </summary>
|
||||
public static class ClusterRoleClaims
|
||||
{
|
||||
/// <summary>Claim type carrying one cluster-scoped role grant.</summary>
|
||||
public const string ClaimType = "cluster_role";
|
||||
|
||||
// Unit separator (U+001F) — cannot occur in a cluster id or an AdminRole name.
|
||||
private const char Separator = '';
|
||||
|
||||
/// <summary>Pack a (cluster, role) pair into a claim value.</summary>
|
||||
public static string Encode(string clusterId, string role) => $"{clusterId}{Separator}{role}";
|
||||
|
||||
/// <summary>Unpack a claim value; null when the value is malformed.</summary>
|
||||
public static (string ClusterId, string Role)? Decode(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return null;
|
||||
var i = value.IndexOf(Separator);
|
||||
return i <= 0 || i == value.Length - 1
|
||||
? null
|
||||
: (value[..i], value[(i + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ClaimsPrincipal"/> helpers for cluster-scoped authorization. The effective role
|
||||
/// for a cluster is the highest of the user's fleet-wide roles and any cluster-scoped grant
|
||||
/// for that cluster.
|
||||
/// </summary>
|
||||
public static class ClaimsPrincipalClusterExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Highest <see cref="AdminRole"/> the user holds for <paramref name="clusterId"/>,
|
||||
/// combining fleet-wide and cluster-scoped grants; null when the user holds none.
|
||||
/// </summary>
|
||||
public static AdminRole? EffectiveClusterRole(this ClaimsPrincipal user, string clusterId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
AdminRole? best = null;
|
||||
|
||||
foreach (var c in user.FindAll(ClaimTypes.Role))
|
||||
if (Enum.TryParse<AdminRole>(c.Value, out var role))
|
||||
best = Higher(best, role);
|
||||
|
||||
foreach (var c in user.FindAll(ClusterRoleClaims.ClaimType))
|
||||
{
|
||||
if (ClusterRoleClaims.Decode(c.Value) is not { } grant) continue;
|
||||
if (!string.Equals(grant.ClusterId, clusterId, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (Enum.TryParse<AdminRole>(grant.Role, out var role))
|
||||
best = Higher(best, role);
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>True when the user's effective role for the cluster is at least <paramref name="minRole"/>.</summary>
|
||||
public static bool HasClusterRole(this ClaimsPrincipal user, string clusterId, AdminRole minRole)
|
||||
=> user.EffectiveClusterRole(clusterId) is { } role && role >= minRole;
|
||||
|
||||
// AdminRole ordinals ascend ConfigViewer < ConfigEditor < FleetAdmin, so >= is the hierarchy.
|
||||
private static AdminRole Higher(AdminRole? current, AdminRole candidate)
|
||||
=> current is { } c && c >= candidate ? c : candidate;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>Scheme name for the SignalR hub bearer-token authentication.</summary>
|
||||
public static class HubTokenDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "HubToken";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates SignalR hub requests that carry a <see cref="HubTokenService"/>-minted
|
||||
/// token. SignalR supplies the token as an <c>Authorization: Bearer</c> header on the
|
||||
/// negotiate / long-poll requests and as an <c>access_token</c> query-string parameter on
|
||||
/// the WebSocket upgrade (custom headers cannot be set on a browser WebSocket handshake) —
|
||||
/// this handler accepts either.
|
||||
/// </summary>
|
||||
public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly HubTokenService _tokens;
|
||||
|
||||
public HubTokenAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
HubTokenService tokens)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_tokens = tokens;
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var token = ExtractToken();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var principal = _tokens.Validate(token);
|
||||
if (principal is null)
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid or expired hub token."));
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(
|
||||
new AuthenticationTicket(principal, Scheme.Name)));
|
||||
}
|
||||
|
||||
private string? ExtractToken()
|
||||
{
|
||||
var header = Request.Headers.Authorization.ToString();
|
||||
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return header["Bearer ".Length..].Trim();
|
||||
|
||||
return Request.Query.TryGetValue("access_token", out var queryToken)
|
||||
? queryToken.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Mints and validates short-lived bearer tokens that let the Admin UI's server-side
|
||||
/// Blazor <see cref="Microsoft.AspNetCore.SignalR.Client.HubConnection"/> clients
|
||||
/// authenticate to the <c>[Authorize]</c>-gated SignalR hubs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Admin-003 fix gated every hub with authorization, but a server-side Blazor circuit
|
||||
/// cannot forward the browser's HttpOnly auth cookie to the loopback hub connection — so the
|
||||
/// hub negotiate would 401. Instead, a component mints a token here for its already-
|
||||
/// authenticated user and supplies it through <c>HttpConnectionOptions.AccessTokenProvider</c>;
|
||||
/// <see cref="HubTokenAuthenticationHandler"/> validates it on the hub endpoint.
|
||||
/// <para>
|
||||
/// The token is an ASP.NET Core Data Protection time-limited payload — the same
|
||||
/// primitive that already protects the auth cookie — so there is no signing-key
|
||||
/// management and no extra package. It is only ever presented loopback (the Admin
|
||||
/// server connecting to its own hub).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HubTokenService
|
||||
{
|
||||
private const string ProtectorPurpose = "ZB.MOM.WW.OtOpcUa.Admin.HubToken.v1";
|
||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
|
||||
public HubTokenService(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
|
||||
}
|
||||
|
||||
/// <summary>Mints a token carrying the user's name and roles, valid for 30 minutes.</summary>
|
||||
public string Issue(ClaimsPrincipal user)
|
||||
{
|
||||
var payload = new HubTokenPayload(
|
||||
user.Identity?.Name,
|
||||
user.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray());
|
||||
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a token and rebuilds the <see cref="ClaimsPrincipal"/>. Returns
|
||||
/// <c>null</c> when the token is missing, tampered with, or expired.
|
||||
/// </summary>
|
||||
public ClaimsPrincipal? Validate(string? token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<HubTokenPayload>(_protector.Unprotect(token));
|
||||
if (payload is null) return null;
|
||||
|
||||
var claims = new List<Claim>();
|
||||
if (!string.IsNullOrEmpty(payload.Name))
|
||||
claims.Add(new Claim(ClaimTypes.Name, payload.Name));
|
||||
if (!string.IsNullOrEmpty(payload.NameIdentifier))
|
||||
claims.Add(new Claim(ClaimTypes.NameIdentifier, payload.NameIdentifier));
|
||||
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
|
||||
|
||||
var identity = new ClaimsIdentity(
|
||||
claims, HubTokenDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role);
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Crypto failure (tampered / expired / wrong key) or malformed JSON — unauthenticated.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Polly.Timeout;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Resilience decorator for <see cref="ILdapGroupRoleMappingService"/> that wraps the
|
||||
/// hot-path <see cref="GetByGroupsAsync"/> call in the Phase 6.1-style pipeline:
|
||||
/// <b>timeout 2 s → retry 3× jittered → fallback to in-memory sealed snapshot</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Registered as a singleton so the in-memory snapshot survives across sign-in
|
||||
/// requests. The inner <see cref="ILdapGroupRoleMappingService"/> is resolved via the
|
||||
/// keyed-service key <c>"inner"</c>, allowing the EF-backed scoped service to be
|
||||
/// registered as the "inner" implementation while this singleton decorator is the primary
|
||||
/// <see cref="ILdapGroupRoleMappingService"/> binding.</para>
|
||||
///
|
||||
/// <para>Because the inner service is scoped (it owns an EF <c>DbContext</c>), this
|
||||
/// singleton uses <see cref="IServiceScopeFactory"/> to open a short-lived scope for
|
||||
/// each DB call. The scope is disposed immediately after the call completes.</para>
|
||||
///
|
||||
/// <para>On each successful <see cref="GetByGroupsAsync"/> the result is stored in a
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by the canonicalised group set. On
|
||||
/// any failure (DB unreachable, SQL exception, timeout) after all retries, the cached
|
||||
/// result for that exact group set is returned. When no prior success exists for the group
|
||||
/// set, an empty list is returned — the static <see cref="LdapOptions.GroupToRole"/>
|
||||
/// bootstrap dictionary in <see cref="AdminRoleGrantResolver"/> is the lock-out-proof
|
||||
/// floor that remains functional regardless of DB state.</para>
|
||||
///
|
||||
/// <para>Write methods (<see cref="CreateAsync"/>, <see cref="DeleteAsync"/>) and
|
||||
/// <see cref="ListAllAsync"/> are passed through unchanged — the resilience layer is
|
||||
/// read-path only, consistent with the Phase 6.1 design decision that writes must fail
|
||||
/// hard on DB outage rather than landing against a stale state.</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilientLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>
|
||||
/// DI keyed-service key used to register the inner (EF-backed) implementation so the
|
||||
/// decorator can resolve it without creating a circular dependency on itself.
|
||||
/// </summary>
|
||||
public const string InnerServiceKey = "LdapGroupRoleMappingService.Inner";
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientLdapGroupRoleMappingService> _logger;
|
||||
|
||||
// Keyed by the normalised group set (NUL-separated sorted group names, lower-case).
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<LdapGroupRoleMapping>> _snapshot =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ResilientLdapGroupRoleMappingService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ResilientLdapGroupRoleMappingService> logger,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 3)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
|
||||
var builder = new ResiliencePipelineBuilder()
|
||||
.AddTimeout(new TimeoutStrategyOptions
|
||||
{
|
||||
Timeout = timeout ?? TimeSpan.FromSeconds(2),
|
||||
});
|
||||
|
||||
if (retryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = retryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(1),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(
|
||||
ex => ex is not OperationCanceledException),
|
||||
});
|
||||
}
|
||||
|
||||
_pipeline = builder.Build();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Executed through the timeout → retry pipeline. On full failure the last snapshot
|
||||
/// for this group set (if any) is returned; otherwise an empty list. The static
|
||||
/// <c>appsettings.json</c> bootstrap dictionary in <see cref="AdminRoleGrantResolver"/>
|
||||
/// remains the ultimate fallback — a DB outage never causes a total login denial.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ldapGroups);
|
||||
|
||||
var groupList = ldapGroups.ToList();
|
||||
if (groupList.Count == 0) return [];
|
||||
|
||||
var cacheKey = CacheKey(groupList);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _pipeline.ExecuteAsync(async ct =>
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.GetByGroupsAsync(groupList, ct).ConfigureAwait(false);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Seal the snapshot so a subsequent DB outage can fall back to it.
|
||||
_snapshot[cacheKey] = result;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"LDAP role-mapping DB read failed after retries; falling back to snapshot for group set [{Groups}]",
|
||||
string.Join(", ", groupList));
|
||||
|
||||
return _snapshot.TryGetValue(cacheKey, out var cached)
|
||||
? cached
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — not covered by the resilience pipeline (Admin UI listing only).</remarks>
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.ListAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
return await inner.CreateAsync(row, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>Pass-through — writes must fail hard on DB outage per Phase 6.1 design decision.</remarks>
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var inner = (ILdapGroupRoleMappingService)scope.ServiceProvider
|
||||
.GetRequiredKeyedService<ILdapGroupRoleMappingService>(InnerServiceKey);
|
||||
await inner.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalise a group set into a stable cache key: sort, lower-case, join with NUL.
|
||||
/// Two calls with the same groups in different orders produce the same key.
|
||||
/// </summary>
|
||||
internal static string CacheKey(IEnumerable<string> groups)
|
||||
=> string.Join('\0', groups
|
||||
.Select(g => g.ToLowerInvariant())
|
||||
.Order(StringComparer.Ordinal));
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin SignalR push helper for ACL + role-grant invalidation — slice 2 of task #196.
|
||||
/// Lets the Admin services + razor pages invalidate connected peers' views without each
|
||||
/// one having to know the hub wiring. Two message kinds: <c>NodeAclChanged</c> (cluster-scoped)
|
||||
/// and <c>RoleGrantsChanged</c> (fleet-wide — role mappings cross cluster boundaries).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally fire-and-forget — a failed hub send doesn't rollback the DB write that
|
||||
/// triggered it. Worst-case an operator sees stale data until their next poll or manual
|
||||
/// refresh; better than a transient hub blip blocking the authoritative write path.
|
||||
/// </remarks>
|
||||
public sealed class AclChangeNotifier(IHubContext<FleetStatusHub> fleetHub, ILogger<AclChangeNotifier> logger)
|
||||
{
|
||||
public async Task NotifyNodeAclChangedAsync(string clusterId, long generationId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new NodeAclChangedMessage(ClusterId: clusterId, GenerationId: generationId, ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(clusterId))
|
||||
.SendAsync("NodeAclChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "NodeAclChanged push failed for cluster {ClusterId} gen {GenerationId}", clusterId, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyRoleGrantsChangedAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var msg = new RoleGrantsChangedMessage(ObservedAtUtc: DateTime.UtcNow);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("RoleGrantsChanged", msg, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RoleGrantsChanged push failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record NodeAclChangedMessage(string ClusterId, long GenerationId, DateTime ObservedAtUtc);
|
||||
public sealed record RoleGrantsChangedMessage(DateTime ObservedAtUtc);
|
||||
@@ -1,55 +0,0 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="HubConnection"/>s to the Admin UI's own SignalR hubs with bearer-token
|
||||
/// authentication.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The hubs are <c>[Authorize]</c>-gated (Admin-003). A server-side Blazor circuit cannot
|
||||
/// forward the browser's HttpOnly auth cookie to the loopback hub connection, so every page
|
||||
/// that needs live updates resolves this factory and lets it wire an
|
||||
/// <c>AccessTokenProvider</c> that mints a <see cref="HubTokenService"/> token for the
|
||||
/// circuit's authenticated user. The provider re-mints on every (re)connect so a long-lived
|
||||
/// page outlives the token lifetime.
|
||||
/// </remarks>
|
||||
public sealed class AdminHubConnectionFactory
|
||||
{
|
||||
private readonly NavigationManager _nav;
|
||||
private readonly HubTokenService _tokens;
|
||||
private readonly AuthenticationStateProvider _authState;
|
||||
|
||||
public AdminHubConnectionFactory(
|
||||
NavigationManager nav,
|
||||
HubTokenService tokens,
|
||||
AuthenticationStateProvider authState)
|
||||
{
|
||||
_nav = nav;
|
||||
_tokens = tokens;
|
||||
_authState = authState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an auto-reconnecting connection to <paramref name="hubPath"/>
|
||||
/// (e.g. <c>/hubs/fleet</c>), authenticated as the current circuit's user.
|
||||
/// </summary>
|
||||
public HubConnection Create(string hubPath)
|
||||
{
|
||||
var hubUrl = _nav.ToAbsoluteUri(hubPath);
|
||||
return new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.AccessTokenProvider = async () =>
|
||||
{
|
||||
var state = await _authState.GetAuthenticationStateAsync();
|
||||
return _tokens.Issue(state.User);
|
||||
};
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user